按实际安全设置状态重复提醒并仅关闭当前登录提示
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 23:32:06 +08:00
parent 4e2f69ae60
commit 914bdc67e2
11 changed files with 208 additions and 71 deletions

View File

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

View File

@ -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, "保存安全引导状态失败,请重试")
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)
}

View File

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

View File

@ -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"
@ -233,15 +234,13 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
"capabilities": access.Capabilities(ctx.EffectiveRole()),
"is_admin": ctx.IsAdmin,
"must_change_password": ctx.MustChangePassword,
"showSecuritySetupPrompt": ctx.SecuritySetupAcknowledged == 0,
"webAuthnEnabled": webAuthnRuntime() != nil,
"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)

View File

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

View File

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

View File

@ -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: '手机动态码' })

View File

@ -84,7 +84,7 @@
<el-divider v-if="selectedSection === 'recovery'" class="settings-divider" />
<section v-if="selectedSection === 'recovery' && !isSuperAdmin" class="security-section">
<div class="settings-header security-key-header">
<div><h3>离线恢复码</h3><p class="settings-desc">恢复码只在生成时显示一次,请保存到安全位置。</p></div>
<div><h3>安全码</h3><p class="settings-desc">安全码只在生成时显示一次,请妥善保存。</p></div>
<div class="section-actions"><el-button type="primary" @click="generateRecoveryCodes">生成新恢复码</el-button><el-button v-if="recoveryConfigured" type="danger" plain @click="revokeRecoveryCodes">撤销</el-button></div>
</div>
<el-alert v-if="recoveryConfigured" title="当前已配置有效离线恢复码" type="success" :closable="false" show-icon />

View File

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

View File

@ -8,7 +8,7 @@
:show-close="false"
class="security-setup-dialog"
>
<p class="setup-description">欢迎使用 Smail!首次登录请了解并设置以下安全选项,确认后不再自动弹出此引导。</p>
<p class="setup-description">{{ isSuperAdmin ? '请绑定外部邮箱。未完成前,每次登录都会提醒。' : '请设置安全码并绑定外部邮箱。未完成前,每次登录都会提醒。' }}</p>
<div class="setup-options">
<section v-for="item in options" :key="item.key" class="setup-option">
<div>
@ -18,24 +18,10 @@
<el-button :disabled="saving" @click="acknowledge(item.key)">前往设置</el-button>
</section>
</div>
<el-alert
v-if="isSuperAdmin"
title="超级管理员密码仍只能通过部署端受保护的本机通道重置,上述选项不会开放网页密码恢复。"
type="info"
:closable="false"
show-icon
/>
<el-alert
v-else
title="普通账号还可在安全设置中生成离线恢复码。恢复码只显示一次,请妥善保存。"
type="info"
:closable="false"
show-icon
/>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<template #footer>
<el-button :loading="saving" @click="acknowledge()">我已了解,稍后设置</el-button>
<el-button type="primary" :loading="saving" @click="acknowledge('question')">开始设置</el-button>
<el-button :loading="saving" @click="acknowledge()">我知道了</el-button>
<el-button type="primary" :loading="saving" @click="acknowledge(options[0]?.key)">去设置</el-button>
</template>
</el-dialog>
</template>

View File

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