Some checks are pending
Security Checks / security (push) Waiting to run
补齐控制器测试中的会话与安全版本上下文,更新Telegram用户配置协议及前端回归断言。
609 lines
22 KiB
Go
609 lines
22 KiB
Go
package controllers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/config"
|
|
"git.pcux.cn/xuans/Smail/db"
|
|
"git.pcux.cn/xuans/Smail/models"
|
|
"git.pcux.cn/xuans/Smail/services/access"
|
|
"git.pcux.cn/xuans/Smail/services/auth"
|
|
"git.pcux.cn/xuans/Smail/services/credentials"
|
|
"git.pcux.cn/xuans/Smail/services/externalemail"
|
|
"git.pcux.cn/xuans/Smail/services/verification"
|
|
appcontext "git.pcux.cn/xuans/Smail/utils/context"
|
|
"git.pcux.cn/xuans/Smail/utils/password"
|
|
)
|
|
|
|
func TestEditUserProtectsAdministrativeCredentialsAndRevokesUserSessions(t *testing.T) {
|
|
oldConfig := config.Instance
|
|
oldDB := db.Instance
|
|
testRoot := t.TempDir()
|
|
t.Cleanup(func() {
|
|
if db.Instance != nil && db.Instance != oldDB {
|
|
_ = db.Instance.Close()
|
|
}
|
|
config.Instance = oldConfig
|
|
db.Instance = oldDB
|
|
})
|
|
|
|
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "user-security.db")}
|
|
if err := db.Init(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin := models.User{Account: "admin-user", Name: "Admin", IsAdmin: 1, SessionVersion: 1, SecurityVersion: 1}
|
|
if err := auth.SetPassword(&admin, "AdminPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
regular := models.User{Account: "regular-user", Name: "User", SessionVersion: 1}
|
|
if err := auth.SetPassword(®ular, "UserPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Instance.Insert(&admin, ®ular); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := &appcontext.Context{
|
|
UserID: admin.ID, SessionVersion: admin.SessionVersion, SecurityVersion: admin.SecurityVersion,
|
|
UserAccount: admin.Account, IsAdmin: true, Lang: "zhCn",
|
|
RecentAuth: true, RecoveryCodeConfigured: true,
|
|
}
|
|
|
|
result := editUserRequest(t, ctx, `{"id":`+itoa(admin.ID)+`,"password":"BypassPass123","disabled":0}`)
|
|
if result == 0 {
|
|
t.Fatal("administrator changed the current password without current-password verification")
|
|
}
|
|
var loadedAdmin models.User
|
|
if _, err := db.Instance.ID(admin.ID).Get(&loadedAdmin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !password.Verify(loadedAdmin.Password, "AdminPass123") {
|
|
t.Fatal("administrator password changed through the user management bypass")
|
|
}
|
|
|
|
result = editUserRequest(t, ctx, `{"id":`+itoa(admin.ID)+`,"disabled":1}`)
|
|
if result == 0 {
|
|
t.Fatal("administrator account was disabled")
|
|
}
|
|
|
|
result = editUserRequest(t, ctx, `{"id":`+itoa(regular.ID)+`,"password":"ResetPass123","disabled":0}`)
|
|
if result != 0 {
|
|
t.Fatal("administrator could not reset another user password")
|
|
}
|
|
var resetUser models.User
|
|
if _, err := db.Instance.ID(regular.ID).Get(&resetUser); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resetUser.SessionVersion != regular.SessionVersion+1 || !password.Verify(resetUser.Password, "ResetPass123") {
|
|
t.Fatalf("password reset did not revoke existing sessions: version=%d", resetUser.SessionVersion)
|
|
}
|
|
|
|
result = editUserRequest(t, ctx, `{"id":`+itoa(regular.ID)+`,"disabled":1}`)
|
|
if result != 0 {
|
|
t.Fatal("administrator could not disable a regular user")
|
|
}
|
|
var disabledUser models.User
|
|
if _, err := db.Instance.ID(regular.ID).Get(&disabledUser); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if disabledUser.Disabled != 1 || disabledUser.SessionVersion != resetUser.SessionVersion+1 {
|
|
t.Fatalf("disabling a user did not revoke existing sessions: %+v", disabledUser)
|
|
}
|
|
}
|
|
|
|
func TestUserListReturnsNormalizedRolesAndSecurityStatus(t *testing.T) {
|
|
oldConfig := config.Instance
|
|
oldDB := db.Instance
|
|
testRoot := t.TempDir()
|
|
t.Cleanup(func() {
|
|
if db.Instance != nil && db.Instance != oldDB {
|
|
_ = db.Instance.Close()
|
|
}
|
|
config.Instance = oldConfig
|
|
db.Instance = oldDB
|
|
})
|
|
|
|
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "user-list.db")}
|
|
if err := db.Init(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacyAdmin := models.User{Account: "legacy-admin", Name: "Legacy", IsAdmin: 1, ExternalEmailEnabled: 1, SessionVersion: 1, SecurityVersion: 1}
|
|
regularUser := models.User{Account: "regular-list", Name: "Regular", Role: string(access.RoleUser), ExternalEmailEnabled: 0, SessionVersion: 1, SecurityVersion: 1}
|
|
if err := auth.SetPassword(&legacyAdmin, "AdminPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := auth.SetPassword(®ularUser, "UserPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Instance.Insert(&legacyAdmin, ®ularUser); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
usedAt := time.Now().UTC()
|
|
fixtures := []any{
|
|
&models.RecoveryCode{UserID: legacyAdmin.ID, CodeHash: "active", CodeVersion: 1},
|
|
&models.RecoveryCode{UserID: regularUser.ID, CodeHash: "used", CodeVersion: 1, UsedAt: &usedAt},
|
|
&models.ExternalEmail{UserID: legacyAdmin.ID, EmailCiphertext: "ciphertext-verified", EmailFingerprint: "verified-email", EmailType: models.ExternalEmailTypePrimary, Status: models.ExternalEmailStatusVerified},
|
|
&models.ExternalEmail{UserID: regularUser.ID, EmailCiphertext: "ciphertext-pending", EmailFingerprint: "pending-email", EmailType: models.ExternalEmailTypePrimary, Status: models.ExternalEmailStatusPending},
|
|
}
|
|
for _, fixture := range fixtures {
|
|
if _, err := db.Instance.Insert(fixture); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
ctx := &appcontext.Context{UserID: legacyAdmin.ID, UserAccount: legacyAdmin.Account, IsAdmin: true, Lang: "zhCn"}
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/user/list", strings.NewReader(`{"current_page":1,"page_size":15}`))
|
|
UserList(ctx, recorder, request)
|
|
var result struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
Data struct {
|
|
List []userListItem `json:"list"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ErrorNo != 0 || len(result.Data.List) != 2 {
|
|
t.Fatalf("unexpected user list response: %s", recorder.Body.String())
|
|
}
|
|
items := make(map[string]userListItem, len(result.Data.List))
|
|
for _, item := range result.Data.List {
|
|
items[item.Account] = item
|
|
}
|
|
adminItem := items[legacyAdmin.Account]
|
|
if adminItem.Role != access.RoleSuperAdmin || !adminItem.RecoveryCodeConfigured || !adminItem.ExternalEmailEnabled || !adminItem.ExternalEmailConfigured {
|
|
t.Fatalf("legacy administrator status was not normalized: %+v", adminItem)
|
|
}
|
|
regularItem := items[regularUser.Account]
|
|
if regularItem.Role != access.RoleUser || regularItem.RecoveryCodeConfigured || regularItem.ExternalEmailEnabled || regularItem.ExternalEmailConfigured {
|
|
t.Fatalf("regular user status was incorrect: %+v", regularItem)
|
|
}
|
|
for _, secret := range []string{"AdminPass123", "UserPass123", "ciphertext-verified", "ciphertext-pending", "active", "used"} {
|
|
if strings.Contains(recorder.Body.String(), secret) {
|
|
t.Fatalf("user list exposed sensitive value %q", secret)
|
|
}
|
|
}
|
|
}
|
|
|
|
func editUserRequest(t *testing.T, ctx *appcontext.Context, payload string) int {
|
|
t.Helper()
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/user/edit", strings.NewReader(payload))
|
|
EditUser(ctx, recorder, request)
|
|
var result struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return result.ErrorNo
|
|
}
|
|
|
|
func itoa(value int) string {
|
|
if value == 0 {
|
|
return "0"
|
|
}
|
|
digits := make([]byte, 0, 20)
|
|
for value > 0 {
|
|
digits = append(digits, byte('0'+value%10))
|
|
value /= 10
|
|
}
|
|
for left, right := 0, len(digits)-1; left < right; left, right = left+1, right-1 {
|
|
digits[left], digits[right] = digits[right], digits[left]
|
|
}
|
|
return string(digits)
|
|
}
|
|
|
|
func TestUserRequestsRejectInvalidJSON(t *testing.T) {
|
|
ctx := &appcontext.Context{IsAdmin: true, Lang: "zhCn"}
|
|
tests := []struct {
|
|
name string
|
|
handler func(*appcontext.Context, http.ResponseWriter, *http.Request)
|
|
payload string
|
|
}{
|
|
{name: "list malformed", handler: UserList, payload: `{"current_page":`},
|
|
{name: "list trailing", handler: UserList, payload: `{"current_page":1,"page_size":15} {}`},
|
|
{name: "list oversized", handler: UserList, payload: `{"current_page":1,"page_size":15,"padding":"` + strings.Repeat("a", int(maxUserRequestBytes)) + `"}`},
|
|
{name: "create trailing", handler: CreateUser, payload: `{"account":"user","username":"User","password":"Password123"} {}`},
|
|
{name: "edit trailing", handler: EditUser, payload: `{"id":1,"disabled":0} {}`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/user", strings.NewReader(test.payload))
|
|
test.handler(ctx, recorder, request)
|
|
var result struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ErrorNo == 0 {
|
|
t.Fatalf("invalid request was accepted: %s", test.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManagedRecoveryCodesAreScopedAndShownOnce(t *testing.T) {
|
|
oldConfig := config.Instance
|
|
oldDB := db.Instance
|
|
oldRuntime := currentSecurityRuntime()
|
|
testRoot := t.TempDir()
|
|
t.Cleanup(func() {
|
|
if db.Instance != nil && db.Instance != oldDB {
|
|
_ = db.Instance.Close()
|
|
}
|
|
config.Instance = oldConfig
|
|
db.Instance = oldDB
|
|
ConfigureSecurityRuntime(oldRuntime.verification, oldRuntime.external, oldRuntime.recovery, oldRuntime.credentials)
|
|
ConfigureWebAuthnRuntime(oldRuntime.webauthn)
|
|
})
|
|
|
|
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "managed-recovery.db")}
|
|
if err := db.Init(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actor := models.User{Account: "security-owner", Name: "Owner", SessionVersion: 1, SecurityVersion: 1}
|
|
actor.ApplyRole(access.RoleSuperAdmin)
|
|
target := models.User{Account: "security-target", Name: "Target", SessionVersion: 1, SecurityVersion: 1}
|
|
target.ApplyRole(access.RoleUser)
|
|
if err := auth.SetPassword(&actor, "OwnerPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := auth.SetPassword(&target, "TargetP123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Instance.Insert(&actor, &target); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credentialService, err := credentials.NewService(db.Instance)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ConfigureSecurityRuntime(nil, nil, nil, credentialService)
|
|
ctx := &appcontext.Context{
|
|
UserID: actor.ID, SessionVersion: actor.SessionVersion, SecurityVersion: actor.SecurityVersion,
|
|
UserAccount: actor.Account, Role: access.RoleSuperAdmin, RecentAuth: true,
|
|
}
|
|
|
|
generateRecorder := httptest.NewRecorder()
|
|
generateRequest := httptest.NewRequest(http.MethodPost, "/api/v1/user/security/recovery-codes/generate", strings.NewReader(`{"user_id":`+itoa(target.ID)+`,"count":5}`))
|
|
GenerateManagedRecoveryCodes(ctx, generateRecorder, generateRequest)
|
|
var generated struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
Data struct {
|
|
UserID int `json:"user_id"`
|
|
Codes []string `json:"codes"`
|
|
ShownOnce bool `json:"shown_once"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(generateRecorder.Body.Bytes(), &generated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if generated.ErrorNo != 0 || generated.Data.UserID != target.ID || len(generated.Data.Codes) != 5 || !generated.Data.ShownOnce {
|
|
t.Fatalf("unexpected managed recovery response: %s", generateRecorder.Body.String())
|
|
}
|
|
for _, code := range generated.Data.Codes {
|
|
if code == "" || !strings.Contains(code, "-") {
|
|
t.Fatalf("invalid one-time recovery code: %q", code)
|
|
}
|
|
}
|
|
|
|
statusRecorder := httptest.NewRecorder()
|
|
statusRequest := httptest.NewRequest(http.MethodGet, "/api/v1/user/security/recovery-codes?id="+itoa(target.ID), nil)
|
|
GetManagedRecoveryCodeStatus(ctx, statusRecorder, statusRequest)
|
|
if !strings.Contains(statusRecorder.Body.String(), `"configured":true`) {
|
|
t.Fatalf("managed recovery status was not enabled: %s", statusRecorder.Body.String())
|
|
}
|
|
for _, code := range generated.Data.Codes {
|
|
if strings.Contains(statusRecorder.Body.String(), code) {
|
|
t.Fatalf("status response leaked one-time code %q", code)
|
|
}
|
|
}
|
|
|
|
revokeRecorder := httptest.NewRecorder()
|
|
revokeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/user/security/recovery-codes/revoke", strings.NewReader(`{"user_id":`+itoa(target.ID)+`}`))
|
|
RevokeManagedRecoveryCodes(ctx, revokeRecorder, revokeRequest)
|
|
if !strings.Contains(revokeRecorder.Body.String(), `"revoked":true`) {
|
|
t.Fatalf("managed recovery codes were not revoked: %s", revokeRecorder.Body.String())
|
|
}
|
|
|
|
deniedRecorder := httptest.NewRecorder()
|
|
deniedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/user/security/recovery-codes?id="+itoa(actor.ID), nil)
|
|
GetManagedRecoveryCodeStatus(ctx, deniedRecorder, deniedRequest)
|
|
var denied struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
}
|
|
if err := json.Unmarshal(deniedRecorder.Body.Bytes(), &denied); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if denied.ErrorNo == 0 {
|
|
t.Fatal("web route allowed management of super-administrator recovery codes")
|
|
}
|
|
}
|
|
|
|
func TestManagedExternalEmailChangeInvalidatesPreviousChallenge(t *testing.T) {
|
|
oldConfig := config.Instance
|
|
oldDB := db.Instance
|
|
oldRuntime := currentSecurityRuntime()
|
|
testRoot := t.TempDir()
|
|
t.Cleanup(func() {
|
|
if db.Instance != nil && db.Instance != oldDB {
|
|
_ = db.Instance.Close()
|
|
}
|
|
config.Instance = oldConfig
|
|
db.Instance = oldDB
|
|
ConfigureSecurityRuntime(oldRuntime.verification, oldRuntime.external, oldRuntime.recovery, oldRuntime.credentials)
|
|
ConfigureWebAuthnRuntime(oldRuntime.webauthn)
|
|
})
|
|
|
|
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "managed-external-email.db")}
|
|
if err := db.Init(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actor := models.User{Account: "email-owner", Name: "Owner", SessionVersion: 1, SecurityVersion: 1}
|
|
actor.ApplyRole(access.RoleSuperAdmin)
|
|
target := models.User{Account: "email-target", Name: "Target", SessionVersion: 1, SecurityVersion: 1}
|
|
target.ApplyRole(access.RoleUser)
|
|
if err := auth.SetPassword(&actor, "OwnerPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := auth.SetPassword(&target, "TargetP123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Instance.Insert(&actor, &target); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
vault := externalemail.NewVaultWithKeys(
|
|
[]byte("01234567890123456789012345678901"),
|
|
[]byte("abcdefabcdefabcdefabcdefabcdefab"),
|
|
)
|
|
external, err := externalemail.NewRepository(db.Instance, vault)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := verification.NewStore(32)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
issuedCodes := make(map[string]string)
|
|
verificationService, err := verification.NewService(store, func(challenge verification.Challenge, code string) error {
|
|
issuedCodes[challenge.ID] = code
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ConfigureSecurityRuntime(verificationService, external, nil, nil)
|
|
|
|
pending, err := external.BeginReplacement(externalemail.BindRequest{
|
|
UserID: target.ID,
|
|
Email: "first@example.com",
|
|
Type: models.ExternalEmailTypePrimary,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldChallenge, err := store.Issue(verification.IssueRequest{
|
|
Purpose: verification.PurposeExternalEmailChange,
|
|
UserID: target.ID,
|
|
Account: target.Account,
|
|
Email: "first@example.com",
|
|
ResourceID: pending.ID,
|
|
}, func(challenge verification.Challenge, code string) error {
|
|
issuedCodes[challenge.ID] = code
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx := &appcontext.Context{
|
|
UserID: actor.ID,
|
|
SessionVersion: actor.SessionVersion,
|
|
SecurityVersion: actor.SecurityVersion,
|
|
UserAccount: actor.Account,
|
|
Role: access.RoleSuperAdmin,
|
|
RecentAuth: true,
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/user/security/external-email/change",
|
|
strings.NewReader(`{"user_id":`+itoa(target.ID)+`,"email":"second@example.com","type":"primary"}`),
|
|
)
|
|
ChangeManagedExternalEmail(ctx, recorder, request)
|
|
var result struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
Data struct {
|
|
BindingID int `json:"binding_id"`
|
|
ChallengeID string `json:"challenge_id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ErrorNo != 0 || result.Data.BindingID != pending.ID || result.Data.ChallengeID == "" {
|
|
t.Fatalf("unexpected managed external email response: %s", recorder.Body.String())
|
|
}
|
|
if err := store.VerifyBound(
|
|
oldChallenge.ID,
|
|
target.Account,
|
|
"first@example.com",
|
|
issuedCodes[oldChallenge.ID],
|
|
verification.PurposeExternalEmailChange,
|
|
pending.ID,
|
|
); err != verification.ErrInvalidChallenge {
|
|
t.Fatalf("previous challenge remained valid after pending email changed: %v", err)
|
|
}
|
|
if err := store.VerifyBound(
|
|
result.Data.ChallengeID,
|
|
target.Account,
|
|
"second@example.com",
|
|
issuedCodes[result.Data.ChallengeID],
|
|
verification.PurposeExternalEmailChange,
|
|
pending.ID,
|
|
); err != nil {
|
|
t.Fatalf("new challenge was not valid: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOwnExternalEmailChangeReplacesOnlyAfterSuccessfulVerification(t *testing.T) {
|
|
oldConfig := config.Instance
|
|
oldDB := db.Instance
|
|
oldRuntime := currentSecurityRuntime()
|
|
testRoot := t.TempDir()
|
|
t.Cleanup(func() {
|
|
if db.Instance != nil && db.Instance != oldDB {
|
|
_ = db.Instance.Close()
|
|
}
|
|
config.Instance = oldConfig
|
|
db.Instance = oldDB
|
|
ConfigureSecurityRuntime(oldRuntime.verification, oldRuntime.external, oldRuntime.recovery, oldRuntime.credentials)
|
|
ConfigureWebAuthnRuntime(oldRuntime.webauthn)
|
|
})
|
|
|
|
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "own-external-email.db")}
|
|
if err := db.Init(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user := models.User{Account: "email-self", Name: "Email Self", SessionVersion: 1, SecurityVersion: 1}
|
|
user.ApplyRole(access.RoleUser)
|
|
if err := auth.SetPassword(&user, "UserPass123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Instance.Insert(&user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
vault := externalemail.NewVaultWithKeys(
|
|
[]byte("01234567890123456789012345678901"),
|
|
[]byte("abcdefabcdefabcdefabcdefabcdefab"),
|
|
)
|
|
external, err := externalemail.NewRepository(db.Instance, vault)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldBinding, err := external.Bind(externalemail.BindRequest{
|
|
UserID: user.ID,
|
|
Email: "old@example.com",
|
|
Type: models.ExternalEmailTypePrimary,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := external.Verify(user.ID, oldBinding.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := verification.NewStore(32)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
issuedCodes := make(map[string]string)
|
|
verificationService, err := verification.NewService(store, func(challenge verification.Challenge, code string) error {
|
|
issuedCodes[challenge.ID] = code
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ConfigureSecurityRuntime(verificationService, external, nil, nil)
|
|
|
|
ctx := &appcontext.Context{
|
|
UserID: user.ID,
|
|
UserAccount: user.Account,
|
|
Role: access.RoleUser,
|
|
RecentAuth: true,
|
|
}
|
|
changeRecorder := httptest.NewRecorder()
|
|
changeRequest := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/security/external-email/change",
|
|
strings.NewReader(`{"email":"new@example.com","type":"primary"}`),
|
|
)
|
|
ChangeOwnExternalEmail(ctx, changeRecorder, changeRequest)
|
|
var changed struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
Data struct {
|
|
BindingID int `json:"binding_id"`
|
|
ChallengeID string `json:"challenge_id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(changeRecorder.Body.Bytes(), &changed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if changed.ErrorNo != 0 || changed.Data.BindingID <= 0 || changed.Data.ChallengeID == "" {
|
|
t.Fatalf("unexpected own external email response: %s", changeRecorder.Body.String())
|
|
}
|
|
|
|
assertExternalEmailStatus(t, external, user.ID, "old@example.com", models.ExternalEmailStatusVerified)
|
|
assertExternalEmailStatus(t, external, user.ID, "new@example.com", models.ExternalEmailStatusPending)
|
|
|
|
wrongRecorder := httptest.NewRecorder()
|
|
wrongRequest := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/security/external-email/change/verify",
|
|
strings.NewReader(`{"id":"`+changed.Data.ChallengeID+`","binding_id":`+itoa(changed.Data.BindingID)+`,"email":"new@example.com","code":"00000000"}`),
|
|
)
|
|
VerifyOwnExternalEmail(ctx, wrongRecorder, wrongRequest)
|
|
var wrongResult struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
}
|
|
if err := json.Unmarshal(wrongRecorder.Body.Bytes(), &wrongResult); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wrongResult.ErrorNo == 0 {
|
|
t.Fatalf("wrong verification code was accepted: %s", wrongRecorder.Body.String())
|
|
}
|
|
assertExternalEmailStatus(t, external, user.ID, "old@example.com", models.ExternalEmailStatusVerified)
|
|
assertExternalEmailStatus(t, external, user.ID, "new@example.com", models.ExternalEmailStatusPending)
|
|
|
|
verifyRecorder := httptest.NewRecorder()
|
|
verifyRequest := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/security/external-email/change/verify",
|
|
strings.NewReader(`{"id":"`+changed.Data.ChallengeID+`","binding_id":`+itoa(changed.Data.BindingID)+`,"email":"new@example.com","code":"`+issuedCodes[changed.Data.ChallengeID]+`"}`),
|
|
)
|
|
VerifyOwnExternalEmail(ctx, verifyRecorder, verifyRequest)
|
|
var verified struct {
|
|
ErrorNo int `json:"errorNo"`
|
|
}
|
|
if err := json.Unmarshal(verifyRecorder.Body.Bytes(), &verified); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if verified.ErrorNo != 0 {
|
|
t.Fatalf("valid verification code failed: %s", verifyRecorder.Body.String())
|
|
}
|
|
assertExternalEmailStatus(t, external, user.ID, "old@example.com", models.ExternalEmailStatusDisabled)
|
|
assertExternalEmailStatus(t, external, user.ID, "new@example.com", models.ExternalEmailStatusVerified)
|
|
}
|
|
|
|
func assertExternalEmailStatus(t *testing.T, repository *externalemail.Repository, userID int, email, status string) {
|
|
t.Helper()
|
|
items, err := repository.List(userID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, item := range items {
|
|
decrypted, decryptErr := repository.Decrypt(item)
|
|
if decryptErr != nil {
|
|
t.Fatal(decryptErr)
|
|
}
|
|
if decrypted == email {
|
|
if item.Status != status {
|
|
t.Fatalf("external email %s status = %s, want %s", email, item.Status, status)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("external email %s was not found", email)
|
|
}
|