补充用户安全信息弹窗验证
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 12:39:03 +08:00
parent 306af4b7b0
commit 998b68079a
4 changed files with 235 additions and 2 deletions

View File

@ -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)

View File

@ -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)
}

View File

@ -1,7 +1,7 @@
<template>
<el-dialog
:model-value="visible"
:title="selfManaged ? '编辑我的外部邮箱' : '编辑外部邮箱'"
:title="props.selfManaged ? '编辑我的外部邮箱' : '编辑外部邮箱'"
width="min(520px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!busy"

View File

@ -14,7 +14,14 @@ function loadStatusHelpers() {
.replace(/^import .*$/gm, '')
.replace(/export \{[\s\S]*?\}\s*$/, '')
.replaceAll('export ', '')
return compileFunction(`${source}\nreturn { roleMeta, recoveryCodeMeta, externalEmailMeta, accountStatusMeta }`)()
return compileFunction(`${source}\nreturn { roleMeta, recoveryCodeMeta, externalEmailMeta, accountStatusMeta, isSameUser, canManageRecoveryCode }`)()
}
function loadExternalEmailHelpers() {
const source = read('UserExternalEmailDialog/UserExternalEmailDialog.js')
const helper = source.match(/export function externalEmailEndpoints\(selfManaged\) \{[\s\S]*?\n\}/)?.[0]
assert.ok(helper)
return compileFunction(`${helper.replace('export ', '')}\nreturn { externalEmailEndpoints }`)()
}
test('用户管理状态映射使用明确的中文文案', () => {
@ -68,6 +75,57 @@ test('用户安全信息使用独立弹窗并调用固定管理接口', () => {
assert.doesNotMatch(externalSource, /\/api\/v1\/security\/external-email\/bind/)
})
test('当前用户判断优先使用用户 ID 并保留账号兼容回退', () => {
const { isSameUser } = loadStatusHelpers()
assert.equal(isSameUser({ ID: 7, Account: 'same' }, { id: 7, account: 'other' }), true)
assert.equal(isSameUser({ ID: 7, Account: 'same' }, { id: 8, account: 'same' }), false)
assert.equal(isSameUser({ Account: 'same' }, { account: 'same' }), true)
assert.equal(isSameUser({ Account: 'same' }, { account: 'other' }), false)
})
test('超级管理员安全码只能在部署端管理,普通用户仍使用独立弹窗', () => {
const { canManageRecoveryCode } = loadStatusHelpers()
assert.equal(canManageRecoveryCode({ ID: 1, Role: 'super_admin' }), false)
assert.equal(canManageRecoveryCode({ ID: 2, Role: 'admin' }), true)
assert.equal(canManageRecoveryCode({ ID: 3, Role: 'user' }), true)
assert.equal(canManageRecoveryCode(null), false)
const source = read('UserManagement.vue')
assert.match(source, /部署端管理/)
assert.match(source, /仅支持本机管理/)
assert.match(source, /:disabled="!recoveryCodeWebManaged"/)
assert.match(source, /:self-managed="editingCurrentUser"/)
})
test('编辑外部邮箱按本人和代管场景使用不同的验证码接口', () => {
const { externalEmailEndpoints } = loadExternalEmailHelpers()
assert.deepEqual(externalEmailEndpoints(true), {
list: '/api/v1/security/external-email/list',
change: '/api/v1/security/external-email/change',
verify: '/api/v1/security/external-email/change/verify',
})
assert.deepEqual(externalEmailEndpoints(false), {
list: '/api/v1/user/security/external-email/list',
change: '/api/v1/user/security/external-email/change',
verify: '/api/v1/user/security/external-email/verify',
})
const dialogSource = read('UserExternalEmailDialog/UserExternalEmailDialog.vue')
assert.match(dialogSource, /编辑我的外部邮箱/)
assert.match(dialogSource, /props\.selfManaged/)
assert.match(dialogSource, /append-to-body/)
assert.match(dialogSource, /destroy-on-close/)
})
test('普通用户编辑请求不直接携带安全码或外部邮箱', () => {
const source = read('UserManagement.js')
const submitStart = source.indexOf('const submit = function')
const editRequestEnd = source.indexOf(".then(res =>", source.indexOf("http.post('/api/v1/user/edit', newData)", submitStart))
const submitSource = source.slice(submitStart, editRequestEnd)
assert.match(submitSource, /\/api\/v1\/user\/edit/)
assert.doesNotMatch(submitSource, /recovery_code|recoveryCode|external_email|externalEmail/)
})
test('用户管理样式可编译并保持紧凑表格布局', () => {
const { code, errors } = compileStyle({
source: read('UserManagement.css'),