diff --git a/backend/controllers/login.go b/backend/controllers/login.go index fb91526..9172576 100644 --- a/backend/controllers/login.go +++ b/backend/controllers/login.go @@ -9,6 +9,7 @@ import ( "time" "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/i18n" "git.pcux.cn/xuans/Smail/models" @@ -104,19 +105,28 @@ func loginSecurityFields(user *models.User) map[string]any { fields := map[string]any{ "recoveryCodesConfigured": false, "showRecoverySetupPrompt": false, - "showSecuritySetupPrompt": user != nil && user.SecuritySetupAcknowledged == 0, + "showSecuritySetupPrompt": user != nil, + "externalEmailConfigured": false, "webAuthnEnabled": webAuthnRuntime() != nil, } - if user == nil || user.EffectiveRole() == access.RoleSuperAdmin { + if user == nil { return fields } - - configured := false - if service := credentialRuntime(); service != nil { - configured, _ = service.HasValidRecoveryCode(user.ID) + if db.Instance != nil && user.ID > 0 { + emailConfigured, err := db.Instance.Where("user_id = ? AND status = ?", user.ID, models.ExternalEmailStatusVerified).Exist(&models.ExternalEmail{}) + if err == nil { + fields["externalEmailConfigured"] = emailConfigured + } } - fields["recoveryCodesConfigured"] = configured - fields["showRecoverySetupPrompt"] = !configured + configured := user.EffectiveRole() == access.RoleSuperAdmin + if !configured { + if service := credentialRuntime(); service != nil { + configured, _ = service.HasValidRecoveryCode(user.ID) + } + fields["recoveryCodesConfigured"] = configured + fields["showRecoverySetupPrompt"] = !configured + } + fields["showSecuritySetupPrompt"] = !configured || fields["externalEmailConfigured"] != true return fields } diff --git a/backend/controllers/security_setup.go b/backend/controllers/security_setup.go index 488ccf2..a0cc3fa 100644 --- a/backend/controllers/security_setup.go +++ b/backend/controllers/security_setup.go @@ -6,10 +6,11 @@ import ( "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/session" "git.pcux.cn/xuans/Smail/utils/context" ) -// AcknowledgeSecuritySetup 仅确认当前账号已经查看引导,不代表完成安全设置。 +// AcknowledgeSecuritySetup 仅关闭当前登录会话的提醒,下次登录重新检查设置。 func AcknowledgeSecuritySetup(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil || ctx.UserID <= 0 { writeSecurityError(w, response.NeedLogin, "请先登录") @@ -25,12 +26,10 @@ func AcknowledgeSecuritySetup(ctx *context.Context, w http.ResponseWriter, req * writeSecurityError(w, response.NeedLogin, "请重新登录") return } - if user.SecuritySetupAcknowledged == 0 { - user.SecuritySetupAcknowledged = 1 - if _, err := db.Instance.ID(ctx.UserID).Cols("security_setup_acknowledged").Update(&user); err != nil { - writeSecurityError(w, response.ServerError, "保存安全引导状态失败,请重试") - return - } + if session.Instance == nil || req == nil { + writeSecurityError(w, response.ServerError, "保存提醒状态失败,请重试") + return } + session.Instance.Put(req.Context(), session.SecuritySetupDismissedKey, true) response.NewSuccessResponse(map[string]any{"showSecuritySetupPrompt": false}).FPrint(w) } diff --git a/backend/controllers/security_setup_test.go b/backend/controllers/security_setup_test.go index 01ea7af..8402244 100644 --- a/backend/controllers/security_setup_test.go +++ b/backend/controllers/security_setup_test.go @@ -1,12 +1,14 @@ package controllers import ( + "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" + "time" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/db" @@ -51,7 +53,8 @@ func TestLogoutDestroysSessionAndCSRFCookies(t *testing.T) { } } -func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T) { +func setupSecurityPromptTest(t *testing.T) *xorm.Engine { + t.Helper() engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "onboarding.db")) if err != nil { t.Fatal(err) @@ -59,9 +62,25 @@ func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T) previous := db.Instance db.Instance = engine t.Cleanup(func() { db.Instance = previous; _ = engine.Close() }) - if err := engine.Sync2(&models.User{}); err != nil { + if err := engine.Sync2(&models.User{}, &models.ExternalEmail{}, &models.RecoveryCode{}); err != nil { t.Fatal(err) } + previousRuntime := securityRuntime + securityRuntime.credentials = nil + t.Cleanup(func() { securityRuntime = previousRuntime }) + return engine +} + +func TestSecuritySetupAcknowledgementIsSessionScoped(t *testing.T) { + engine := setupSecurityPromptTest(t) + previousSession, previousConfig, previousManager := session.Instance, config.Instance, config.CurrentManager() + session.Instance = scs.New() + config.SetRuntimeManager(nil) + config.Instance = &config.Config{Domain: "example.com"} + t.Cleanup(func() { + session.Instance, config.Instance = previousSession, previousConfig + config.SetRuntimeManager(previousManager) + }) first := models.User{Account: "root", Name: "Root", Role: string(access.RoleSuperAdmin)} second := models.User{Account: "alice", Name: "Alice", Role: string(access.RoleUser)} if _, err := engine.Insert(&first, &second); err != nil { @@ -70,10 +89,18 @@ func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T) if loginSecurityFields(&first)["showSecuritySetupPrompt"] != true { t.Fatal("first super administrator login must show security setup") } + request := httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", strings.NewReader(`{"user_id":2}`)) + loadedSession, err := session.Instance.Load(request.Context(), "") + if err != nil { + t.Fatal(err) + } + request = request.WithContext(loadedSession) + if err := session.PutUser(loadedSession, &first); err != nil { + t.Fatal(err) + } for i := 0; i < 2; i++ { recorder := httptest.NewRecorder() - AcknowledgeSecuritySetup(&appcontext.Context{UserID: first.ID}, recorder, - httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", strings.NewReader(`{"user_id":2}`))) + AcknowledgeSecuritySetup(&appcontext.Context{UserID: first.ID}, recorder, request) var payload struct { ErrorNo int `json:"errorNo"` } @@ -88,16 +115,87 @@ func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T) if _, err := engine.ID(second.ID).Get(&untouched); err != nil { t.Fatal(err) } - if loaded.SecuritySetupAcknowledged != 1 || untouched.SecuritySetupAcknowledged != 0 { - t.Fatal("acknowledgement changed the wrong account") + if loaded.SecuritySetupAcknowledged != 0 || untouched.SecuritySetupAcknowledged != 0 { + t.Fatal("dismissal must not persist on either account") } fields := loginSecurityFields(&loaded) - if fields["showSecuritySetupPrompt"] != false || fields["showRecoverySetupPrompt"] != false { - t.Fatal("acknowledged super administrator must not receive either prompt") + if fields["showSecuritySetupPrompt"] != true || fields["showRecoverySetupPrompt"] != false { + t.Fatal("incomplete setup must be reported again on login") } if untouched.Password != second.Password || loaded.SecurityVersion != first.SecurityVersion { t.Fatal("acknowledgement changed security credentials") } + otherSession, err := session.Instance.Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if session.Instance.GetBool(otherSession, session.SecuritySetupDismissedKey) { + t.Fatal("dismissal leaked to another session") + } + token, _, err := session.Instance.Commit(loadedSession) + if err != nil { + t.Fatal(err) + } + refreshed, err := session.Instance.Load(context.Background(), token) + if err != nil { + t.Fatal(err) + } + assertInfoPrompt := func(want bool) { + t.Helper() + recorder := httptest.NewRecorder() + Info(&appcontext.Context{UserID: first.ID, Role: access.RoleSuperAdmin}, recorder, request.WithContext(refreshed)) + var payload struct { + ErrorNo int `json:"errorNo"` + Data struct { + Show bool `json:"showSecuritySetupPrompt"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload.ErrorNo != 0 || payload.Data.Show != want { + t.Fatalf("unexpected refreshed prompt: %s", recorder.Body.String()) + } + } + assertInfoPrompt(false) + if err := session.PutUser(refreshed, &first); err != nil { + t.Fatal(err) + } + assertInfoPrompt(true) +} + +func TestSecuritySetupRequiresUsableCredentials(t *testing.T) { + for _, role := range []access.Role{access.RoleUser, access.RoleAdmin, access.RoleSuperAdmin} { + for _, emailStatus := range []string{"", models.ExternalEmailStatusPending, models.ExternalEmailStatusVerified, models.ExternalEmailStatusDisabled} { + for _, codeStatus := range []string{"missing", "valid", "used"} { + t.Run(string(role)+"/"+emailStatus+"/"+codeStatus, func(t *testing.T) { + engine := setupSecurityPromptTest(t) + user := models.User{Account: "alice", Role: string(role), SecuritySetupAcknowledged: 1} + if _, err := engine.Insert(&user); err != nil { + t.Fatal(err) + } + if emailStatus != "" { + if _, err := engine.Insert(&models.ExternalEmail{UserID: user.ID, Status: emailStatus, EmailFingerprint: "fixture"}); err != nil { + t.Fatal(err) + } + } + if codeStatus != "missing" { + code := models.RecoveryCode{UserID: user.ID, CodeHash: "fixture"} + if codeStatus == "used" { + now := time.Now() + code.UsedAt = &now + } + if _, err := engine.Insert(&code); err != nil { + t.Fatal(err) + } + } + fields := loginSecurityFields(&user) + emailReady := emailStatus == models.ExternalEmailStatusVerified + codeReady := codeStatus == "valid" || role == access.RoleSuperAdmin + if fields["externalEmailConfigured"] != emailReady || fields["showSecuritySetupPrompt"] != (!emailReady || !codeReady) { + t.Fatalf("unexpected security state: %#v", fields) + } + }) + } + } + } } func TestSecuritySetupRejectsAnonymous(t *testing.T) { diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 4b9a6e4..7f29136 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -9,6 +9,7 @@ import ( "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/session" "git.pcux.cn/xuans/Smail/utils/array" "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/csrf" @@ -226,22 +227,20 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) { domains := array.Difference(mailDomains.Accepted, []string{mailDomains.Primary}) domains = append([]string{mailDomains.Primary}, domains...) result := map[string]any{ - "id": ctx.UserID, - "account": ctx.UserAccount, - "name": ctx.UserName, - "role": ctx.EffectiveRole(), - "capabilities": access.Capabilities(ctx.EffectiveRole()), - "is_admin": ctx.IsAdmin, - "must_change_password": ctx.MustChangePassword, - "showSecuritySetupPrompt": ctx.SecuritySetupAcknowledged == 0, - "webAuthnEnabled": webAuthnRuntime() != nil, - "domains": domains, + "id": ctx.UserID, + "account": ctx.UserAccount, + "name": ctx.UserName, + "role": ctx.EffectiveRole(), + "capabilities": access.Capabilities(ctx.EffectiveRole()), + "is_admin": ctx.IsAdmin, + "must_change_password": ctx.MustChangePassword, + "domains": domains, } - if ctx.EffectiveRole() != access.RoleSuperAdmin { - result["recoveryCodesConfigured"] = ctx.RecoveryCodeConfigured - result["showRecoverySetupPrompt"] = !ctx.RecoveryCodeConfigured - } else { - result["recoveryCodesConfigured"] = false + for key, value := range loginSecurityFields(&models.User{ID: ctx.UserID, Role: string(ctx.EffectiveRole())}) { + result[key] = value + } + if session.Instance != nil && session.Instance.GetBool(req.Context(), session.SecuritySetupDismissedKey) { + result["showSecuritySetupPrompt"] = false result["showRecoverySetupPrompt"] = false } response.NewSuccessResponse(result).FPrint(w) diff --git a/backend/session/user.go b/backend/session/user.go index 88aa52c..eeffb0a 100644 --- a/backend/session/user.go +++ b/backend/session/user.go @@ -11,6 +11,7 @@ import ( ) const userSessionKey = "user" +const SecuritySetupDismissedKey = "security_setup_dismissed" const recentAuthenticationLifetime = 10 * time.Minute type UserIdentity struct { @@ -55,6 +56,7 @@ func PutUser(ctx context.Context, user *models.User) error { return err } Instance.Put(ctx, userSessionKey, string(encoded)) + Instance.Remove(ctx, SecuritySetupDismissedKey) return nil } diff --git a/backend/session/user_test.go b/backend/session/user_test.go index 9a81a0f..a42269f 100644 --- a/backend/session/user_test.go +++ b/backend/session/user_test.go @@ -19,9 +19,20 @@ func TestUserIdentityRoundTrip(t *testing.T) { t.Fatal(err) } user := &models.User{ID: 7, Account: "admin", Name: "Admin", IsAdmin: 1, SessionVersion: 4, SecurityVersion: 3, MustChangePassword: 1} + Instance.Put(loaded, SecuritySetupDismissedKey, true) if err := PutUser(loaded, user); err != nil { t.Fatal(err) } + if Instance.GetBool(loaded, SecuritySetupDismissedKey) { + t.Fatal("new login retained the previous dismissal") + } + Instance.Put(loaded, SecuritySetupDismissedKey, true) + if err := RefreshRecentAuthentication(loaded, user); err != nil { + t.Fatal(err) + } + if !Instance.GetBool(loaded, SecuritySetupDismissedKey) { + t.Fatal("recent authentication cleared the current dismissal") + } identity, err := GetUser(loaded) if err != nil { t.Fatal(err) diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 0f383da..a7ee3ab 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -22,7 +22,7 @@ export function usePage() { options.push({ value: 'password', label: lang.modify_pwd }) options.push({ value: 'question', label: '密保问题' }) if (!isSuperAdmin.value) { - options.push({ value: 'recovery', label: '离线恢复码' }) + options.push({ value: 'recovery', label: '安全码' }) } options.push({ value: 'external-email', label: '外部安全邮箱' }) options.push({ value: 'totp', label: '手机动态码' }) diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 3227ed6..bfaed04 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -84,7 +84,7 @@
-

离线恢复码

恢复码只在生成时显示一次,请保存到安全位置。

+

安全码

安全码只在生成时显示一次,请妥善保存。

生成新恢复码撤销
diff --git a/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js b/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js index 94d300e..030caa1 100644 --- a/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js +++ b/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js @@ -10,16 +10,16 @@ export function usePage() { && globalStatus.userInfos.showSecuritySetupPrompt === true && !globalStatus.userInfos.must_change_password) const isSuperAdmin = computed(() => globalStatus.role === 'super_admin') - const options = computed(() => [ - { key: 'question', title: '密保问题', description: '设置只有自己知道的答案,避免使用容易猜到的个人信息。' }, - { key: 'external-email', title: '外部安全邮箱', description: '绑定可正常接收验证码的外部邮箱,并完成验证。' }, - { - key: 'webauthn', title: '安全密钥', - description: globalStatus.userInfos.webAuthnEnabled === true - ? '使用支持 WebAuthn 的设备或浏览器注册安全密钥。' - : '服务器尚未启用,需由部署管理员配置 WebAuthn 后才能注册。' + const options = computed(() => { + const items = [] + if (!isSuperAdmin.value && !globalStatus.userInfos.recoveryCodesConfigured) { + items.push({ key: 'recovery', title: '安全码', description: '生成安全码并妥善保存。' }) } - ]) + if (!globalStatus.userInfos.externalEmailConfigured) { + items.push({ key: 'external-email', title: '外部邮箱', description: '绑定邮箱并完成验证。' }) + } + return items + }) const acknowledge = async section => { if (saving.value) return diff --git a/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.vue b/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.vue index f7206b2..4ad4df5 100644 --- a/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.vue +++ b/frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.vue @@ -8,7 +8,7 @@ :show-close="false" class="security-setup-dialog" > -

欢迎使用 Smail!首次登录请了解并设置以下安全选项,确认后不再自动弹出此引导。

+

{{ isSuperAdmin ? '请绑定外部邮箱。未完成前,每次登录都会提醒。' : '请设置安全码并绑定外部邮箱。未完成前,每次登录都会提醒。' }}

@@ -18,24 +18,10 @@ 前往设置
- - diff --git a/test/frontend/security-setup.test.mjs b/test/frontend/security-setup.test.mjs index 412e08f..926f655 100644 --- a/test/frontend/security-setup.test.mjs +++ b/test/frontend/security-setup.test.mjs @@ -21,17 +21,17 @@ function fixture(post = async () => ({ errorNo: 0 })) { return { store, page: createPage(computed, ref, { post }, () => store) } } -test('初始化超级管理员首次登录显示三项引导,强制改密时延后', () => { +test('超级管理员只提醒邮箱,强制改密时延后', () => { const { store, page } = fixture() assert.equal(page.visible.value, true) - assert.deepEqual(Array.from(page.options.value, item => item.key), ['question', 'external-email', 'webauthn']) + assert.deepEqual(Array.from(page.options.value, item => item.key), ['external-email']) store.userInfos.must_change_password = 1 assert.equal(page.visible.value, false) store.userInfos.must_change_password = 0 assert.equal(page.visible.value, true) }) -test('确认成功后关闭并打开目标设置,刷新后的持久化状态不再弹出', async () => { +test('确认仅关闭本次提醒,下次登录未完成设置仍提醒', async () => { const requests = [] const { store, page } = fixture(async path => { requests.push(path); return { errorNo: 0 } }) await page.acknowledge('external-email') @@ -42,6 +42,38 @@ test('确认成功后关闭并打开目标设置,刷新后的持久化状态 assert.deepEqual(requests, ['/api/v1/security/setup/acknowledge']) store.userInfos = { account: 'root', showSecuritySetupPrompt: false } assert.equal(page.visible.value, false) + store.settingsDrawerVisible = false + store.userInfos = { account: 'root', showSecuritySetupPrompt: true } + assert.equal(page.visible.value, true) +}) + +test('管理员和普通用户只显示尚未完成的安全码与邮箱', () => { + for (const role of ['admin', 'user']) { + const { store, page } = fixture() + store.role = role + assert.deepEqual(Array.from(page.options.value, item => item.key), ['recovery', 'external-email']) + store.userInfos.recoveryCodesConfigured = true + assert.deepEqual(Array.from(page.options.value, item => item.key), ['external-email']) + store.userInfos.externalEmailConfigured = true + store.userInfos.recoveryCodesConfigured = false + assert.deepEqual(Array.from(page.options.value, item => item.key), ['recovery']) + store.userInfos.recoveryCodesConfigured = true + store.userInfos.showSecuritySetupPrompt = false + assert.deepEqual(Array.from(page.options.value, item => item.key), []) + assert.equal(page.visible.value, false) + } +}) + +test('我知道了只关闭弹窗,去设置打开第一个未完成项', async () => { + const { store, page } = fixture() + store.role = 'user' + await page.acknowledge() + assert.equal(page.visible.value, false) + assert.equal(store.settingsDrawerVisible, false) + store.userInfos.showSecuritySetupPrompt = true + await page.acknowledge(page.options.value[0].key) + assert.equal(store.securityActiveSection, 'recovery') + assert.equal(store.settingsDrawerVisible, true) }) test('保存失败保留引导,可重试且不将查看状态误记为设置完成', async () => { @@ -58,8 +90,8 @@ test('并发点击只提交一次,切换账号后不修改新账号状态', as let finish let calls = 0 const { store, page } = fixture(() => { calls++; return new Promise(resolve => { finish = resolve }) }) - const pending = page.acknowledge('question') - await page.acknowledge('webauthn') + const pending = page.acknowledge('recovery') + await page.acknowledge('external-email') assert.equal(calls, 1) store.userInfos = { account: 'alice', showSecuritySetupPrompt: true } finish({ errorNo: 0 })