From 998b68079a6921f285f8a18f28355c513623516a Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 12:39:03 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E7=94=A8=E6=88=B7=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BF=A1=E6=81=AF=E5=BC=B9=E7=AA=97=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security_authentication_test.go | 24 +++ backend/controllers/user_security_test.go | 151 ++++++++++++++++++ .../UserExternalEmailDialog.vue | 2 +- test/frontend/user-management.test.mjs | 60 ++++++- 4 files changed, 235 insertions(+), 2 deletions(-) diff --git a/backend/controllers/security_authentication_test.go b/backend/controllers/security_authentication_test.go index f3c18e8..77eea2b 100644 --- a/backend/controllers/security_authentication_test.go +++ b/backend/controllers/security_authentication_test.go @@ -93,6 +93,30 @@ func TestBindExternalEmailReturnsStableRecentAuthenticationCode(t *testing.T) { } } +func TestChangeOwnExternalEmailRequiresRecentAuthentication(t *testing.T) { + previous := securityRuntime + securityRuntime.external = &externalemail.Repository{} + securityRuntime.verification = &verification.Service{} + t.Cleanup(func() { securityRuntime = previous }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/security/external-email/change", + strings.NewReader(`{"email":"backup@example.com","type":"primary"}`), + ) + ChangeOwnExternalEmail(&appcontext.Context{UserID: 1, UserAccount: "alice"}, recorder, request) + + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + data, ok := result.Data.(map[string]any) + if result.ErrorNo != response.NoAccessPrivileges || !ok || data["code"] != string(access.CodeRecentAuthRequired) { + t.Fatalf("recent authentication response = %+v", result) + } +} + func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) { _, _, engine, user, _ := newPublicSecurityFixture(t) credentialService, err := credentials.NewService(engine) diff --git a/backend/controllers/user_security_test.go b/backend/controllers/user_security_test.go index c5e0fe8..003b1e8 100644 --- a/backend/controllers/user_security_test.go +++ b/backend/controllers/user_security_test.go @@ -451,3 +451,154 @@ func TestManagedExternalEmailChangeInvalidatesPreviousChallenge(t *testing.T) { 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) +} diff --git a/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.vue b/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.vue index 09908a4..2ccb6c5 100644 --- a/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.vue +++ b/frontend/src/views/components/UserManagement/UserExternalEmailDialog/UserExternalEmailDialog.vue @@ -1,7 +1,7 @@