From 2f24a72054decad998101463931af55f994b68ac Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 27 Sep 2026 17:27:04 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=89=E5=85=A8=E7=A0=81?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=90=8E=E6=9F=A5=E7=9C=8B=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BF=9D=E5=AD=98=E9=83=A8=E7=BD=B2=E5=89=8D=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充安全码加密存储及受权限约束的再次查看,增加前端验证和自动隐藏;构建已通过,云端业务验收待完成。 --- backend/controllers/security.go | 33 +++++ backend/controllers/user_security.go | 35 +++++ backend/db/migration/recovery_ciphertext.go | 19 +++ backend/db/migration/registry.go | 1 + backend/listen/http_server/api_v1_compat.go | 2 + backend/listen/http_server/http_server.go | 2 + backend/listen/http_server/https_server.go | 1 + backend/listen/http_server/operation_log.go | 2 + backend/models/security_credentials.go | 13 +- backend/services/credentials/service.go | 67 ++++++++- .../SecuritySettings/SecuritySettings.js | 104 +++++++++++++- .../SecuritySettings/SecuritySettings.vue | 16 ++- .../RecoveryCodeDialog/RecoveryCodeDialog.css | 1 + .../RecoveryCodeDialog/RecoveryCodeDialog.js | 127 ++++++++++++++++-- .../RecoveryCodeDialog/RecoveryCodeDialog.vue | 26 +++- 15 files changed, 411 insertions(+), 38 deletions(-) create mode 100644 backend/db/migration/recovery_ciphertext.go diff --git a/backend/controllers/security.go b/backend/controllers/security.go index bee4e61..df2a98a 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -352,6 +352,39 @@ func GetRecoveryCodeStatus(ctx *context.Context, w http.ResponseWriter, req *htt response.NewSuccessResponse(map[string]any{"configured": configured}).FPrint(w) } +// RevealRecoveryCodes 在服务端确认近期安全验证后返回当前用户有效安全码。 +func RevealRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if ctx == nil || ctx.UserID <= 0 { + writeSecurityError(w, response.NeedLogin, "请先登录") + return + } + if !ctx.RecentAuth { + writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先重新验证当前密码", string(access.CodeRecentAuthRequired)) + return + } + service := credentialRuntime() + if service == nil { + writeSecurityError(w, response.ServerError, "安全凭据服务不可用") + return + } + codes, err := service.RevealRecoveryCodes(ctx.UserID) + if err != nil { + if errors.Is(err, credentials.ErrNoCiphertext) { + writeSecurityError(w, response.ParamsError, "现有安全码无法重新查看,请重新生成安全码") + return + } + if errors.Is(err, credentials.ErrCodeNotFound) { + writeSecurityError(w, response.ParamsError, "当前没有有效安全码") + return + } + log.WithError(err).Error("reveal recovery codes failed") + writeSecurityError(w, response.ServerError, "读取安全码失败") + return + } + response.NewSuccessResponse(map[string]any{"codes": codes, "revealed": true}).FPrint(w) +} + // RevokeRecoveryCodes 撤销当前用户的全部有效离线恢复码。 func RevokeRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if ctx == nil || ctx.UserID <= 0 { diff --git a/backend/controllers/user_security.go b/backend/controllers/user_security.go index dcd6b33..81cc3eb 100644 --- a/backend/controllers/user_security.go +++ b/backend/controllers/user_security.go @@ -11,6 +11,7 @@ import ( "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/accountgroup" + "git.pcux.cn/xuans/Smail/services/credentials" "git.pcux.cn/xuans/Smail/services/externalemail" "git.pcux.cn/xuans/Smail/services/verification" "git.pcux.cn/xuans/Smail/utils/context" @@ -105,6 +106,40 @@ func GetManagedRecoveryCodeStatus(ctx *context.Context, w http.ResponseWriter, r }).FPrint(w) } +// RevealManagedRecoveryCodes 在管理员完成近期安全验证后返回目标用户有效安全码。 +func RevealManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + w.Header().Set("Cache-Control", "no-store") + var input managedUserSecurityRequest + if err := decodeUserRequest(w, req, &input); err != nil { + writeSecurityError(w, response.ParamsError, "请求无效") + return + } + target, ok := loadManagedSecurityUser(ctx, w, input.UserID) + if !ok { + return + } + service := credentialRuntime() + if service == nil { + writeSecurityError(w, response.ServerError, "安全凭据服务不可用") + return + } + codes, err := service.RevealRecoveryCodes(target.ID) + if err != nil { + if errors.Is(err, credentials.ErrNoCiphertext) { + writeSecurityError(w, response.ParamsError, "现有安全码无法重新查看,请重新生成安全码") + return + } + if errors.Is(err, credentials.ErrCodeNotFound) { + writeSecurityError(w, response.ParamsError, "当前没有有效安全码") + return + } + log.WithContext(ctx).WithError(err).Error("reveal managed recovery codes failed") + writeSecurityError(w, response.ServerError, "读取安全码失败") + return + } + response.NewSuccessResponse(map[string]any{"user_id": target.ID, "codes": codes, "revealed": true}).FPrint(w) +} + // GenerateManagedRecoveryCodes 为目标用户生成只显示一次的安全码。 func GenerateManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) { var input managedUserSecurityRequest diff --git a/backend/db/migration/recovery_ciphertext.go b/backend/db/migration/recovery_ciphertext.go new file mode 100644 index 0000000..bde297d --- /dev/null +++ b/backend/db/migration/recovery_ciphertext.go @@ -0,0 +1,19 @@ +package migration + +import ( + "context" + "fmt" +) + +func applyRecoveryCodeCiphertextSchema(ctx context.Context, exec executor, dialect Dialect) error { + definitions := map[Dialect]string{ + DialectSQLite: "TEXT NULL", + DialectMySQL: "VARCHAR(512) NULL", + DialectPostgres: "VARCHAR(512) NULL", + } + definition, ok := definitions[dialect] + if !ok { + return fmt.Errorf("unsupported migration dialect %q", dialect) + } + return ensureColumn(ctx, exec, dialect, "recovery_code", "code_ciphertext", definition) +} diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index 7fc5b67..72f2c4f 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -42,6 +42,7 @@ func DefaultMigrations() []Migration { DefineForDialects(25, "create_notification_grants", notificationGrantStatements()), DefineForDialects(26, "create_account_groups", accountGroupStatements()), DefineCustom(27, "expand_notification_ciphertext", "expand_notification_ciphertext_v1", applyNotificationCiphertextSchema), + DefineCustom(28, "expand_recovery_code_ciphertext", "expand_recovery_code_ciphertext_v1", applyRecoveryCodeCiphertextSchema), } } diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index d97373c..c426d6f 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -35,6 +35,7 @@ func registerV1Routes(mux *http.ServeMux) { registerV1CredentialsQuestionRoute(mux) registerV1TOTPRoute(mux) registerV1MethodRoute(mux, "/api/v1/security/credentials/recovery-codes", "/api/security/credentials/recovery-codes", http.MethodGet, 0, controllers.GetRecoveryCodeStatus) + registerV1MethodRoute(mux, "/api/v1/security/credentials/recovery-codes/reveal", "/api/security/credentials/recovery-codes/reveal", http.MethodPost, 4<<10, controllers.RevealRecoveryCodes) registerV1MethodRoute(mux, "/api/v1/security/credentials/recovery-codes/generate", "/api/security/credentials/recovery-codes/generate", http.MethodPost, 16<<10, controllers.GenerateRecoveryCodes) registerV1MethodRoute(mux, "/api/v1/security/credentials/recovery-codes/revoke", "/api/security/credentials/recovery-codes/revoke", http.MethodPost, 4<<10, controllers.RevokeRecoveryCodes) registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols", "/api/security/credentials/protocols", http.MethodGet, 0, controllers.ListProtocolCredentials) @@ -89,6 +90,7 @@ func registerV1Routes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/user/groups/save", "/api/user/groups/save", http.MethodPost, 4<<10, controllers.SaveAccountGroup) registerV1MethodRoute(mux, "/api/v1/user/groups/membership", "/api/user/groups/membership", http.MethodPost, 4<<10, controllers.SetAccountGroupMembership) registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes", "/api/user/security/recovery-codes", http.MethodGet, 0, controllers.GetManagedRecoveryCodeStatus) + registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes/reveal", "/api/user/security/recovery-codes/reveal", http.MethodPost, 4<<10, controllers.RevealManagedRecoveryCodes) registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes/generate", "/api/user/security/recovery-codes/generate", http.MethodPost, 16<<10, controllers.GenerateManagedRecoveryCodes) registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes/revoke", "/api/user/security/recovery-codes/revoke", http.MethodPost, 4<<10, controllers.RevokeManagedRecoveryCodes) registerV1MethodRoute(mux, "/api/v1/user/security/external-email/list", "/api/user/security/external-email/list", http.MethodGet, 0, controllers.ListManagedExternalEmails) diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 26b033c..baf4f2b 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -78,6 +78,7 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/api/security/credentials/question", credentialsQuestionHandler()) mux.HandleFunc("/api/security/credentials/recovery-codes", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetRecoveryCodeStatus))) mux.HandleFunc("/api/security/credentials/recovery-codes/generate", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.GenerateRecoveryCodes)))) + mux.HandleFunc("/api/security/credentials/recovery-codes/reveal", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.RevealRecoveryCodes)))) mux.HandleFunc("/api/security/credentials/recovery-codes/revoke", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.RevokeRecoveryCodes)))) mux.HandleFunc("/api/security/credentials/protocols", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListProtocolCredentials))) mux.HandleFunc("/api/security/credentials/protocols/enabled", onlyMethod(http.MethodPut, limitBody(16<<10, contextIterceptor(controllers.SetProtocolCredentialEnabled)))) @@ -140,6 +141,7 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/api/user/list", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.UserList)))) mux.HandleFunc("/api/user/security/recovery-codes", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetManagedRecoveryCodeStatus))) mux.HandleFunc("/api/user/security/recovery-codes/generate", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.GenerateManagedRecoveryCodes)))) + mux.HandleFunc("/api/user/security/recovery-codes/reveal", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevealManagedRecoveryCodes)))) mux.HandleFunc("/api/user/security/recovery-codes/revoke", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.RevokeManagedRecoveryCodes)))) mux.HandleFunc("/api/user/security/external-email/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListManagedExternalEmails))) mux.HandleFunc("/api/user/security/external-email/change", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.ChangeManagedExternalEmail)))) diff --git a/backend/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go index 4231278..4562a2d 100644 --- a/backend/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -246,6 +246,7 @@ func securitySetupAllowedPath(path, method string) bool { switch normalizedAPIPath(path) { case "/api/user/info", "/api/logout", "/api/security/reauth", "/api/security/setup/acknowledge", "/api/security/totp", "/api/security/credentials/recovery-codes/generate", + "/api/security/credentials/recovery-codes/reveal", "/api/security/external-email/bind", "/api/security/external-email/verify": return true case "/api/security/credentials/recovery-codes", "/api/security/external-email/list", "/api/security/credentials/question": diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index d2869cc..11a9eaa 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -40,6 +40,7 @@ var loggedOperations = map[string]string{ "/api/security/setup/acknowledge": "确认安全初始化引导", "/api/security/credentials/question": "设置密保问题", "/api/security/credentials/recovery-codes/generate": "生成安全码", + "/api/security/credentials/recovery-codes/reveal": "查看安全码", "/api/security/credentials/recovery-codes/revoke": "撤销安全码", "/api/security/credentials/protocols/enabled": "设置邮件客户端授权", "/api/security/credentials/protocols/generate": "生成邮件客户端安全码", @@ -61,6 +62,7 @@ var loggedOperations = map[string]string{ "/api/user/edit": "编辑用户", "/api/user/profile": "修改个人资料", "/api/user/security/recovery-codes/generate": "管理用户安全码", + "/api/user/security/recovery-codes/reveal": "查看用户安全码", "/api/user/security/recovery-codes/revoke": "撤销用户安全码", "/api/user/security/external-email/change": "申请修改用户外部邮箱", "/api/user/security/external-email/verify": "验证用户外部邮箱修改", diff --git a/backend/models/security_credentials.go b/backend/models/security_credentials.go index 18d6c07..c973f9a 100644 --- a/backend/models/security_credentials.go +++ b/backend/models/security_credentials.go @@ -17,12 +17,13 @@ func (SecurityQuestion) TableName() string { return "security_question" } // RecoveryCode 保存一次性离线恢复码的 Argon2id 哈希,UsedAt 非空表示已经消费或撤销。 type RecoveryCode struct { - ID int `xorm:"id bigint unsigned not null pk autoincr" json:"-"` - UserID int `xorm:"user_id bigint not null index" json:"-"` - CodeHash string `xorm:"code_hash varchar(255) notnull" json:"-"` - CodeVersion int `xorm:"code_version int notnull default(1)" json:"-"` - UsedAt *time.Time `xorm:"used_at datetime null index" json:"-"` - CreatedAt time.Time `xorm:"created_at datetime created notnull index" json:"created_at"` + ID int `xorm:"id bigint unsigned not null pk autoincr" json:"-"` + UserID int `xorm:"user_id bigint not null index" json:"-"` + CodeHash string `xorm:"code_hash varchar(255) notnull" json:"-"` + CodeCiphertext string `xorm:"code_ciphertext varchar(512) null" json:"-"` + CodeVersion int `xorm:"code_version int notnull default(1)" json:"-"` + UsedAt *time.Time `xorm:"used_at datetime null index" json:"-"` + CreatedAt time.Time `xorm:"created_at datetime created notnull index" json:"created_at"` } func (RecoveryCode) TableName() string { return "recovery_code" } diff --git a/backend/services/credentials/service.go b/backend/services/credentials/service.go index a8f2106..6d513f3 100644 --- a/backend/services/credentials/service.go +++ b/backend/services/credentials/service.go @@ -6,11 +6,13 @@ import ( "encoding/base32" "errors" "fmt" + "strconv" "strings" "time" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/accountgroup" + "git.pcux.cn/xuans/Smail/utils/credential" "git.pcux.cn/xuans/Smail/utils/password" log "github.com/sirupsen/logrus" "golang.org/x/text/unicode/norm" @@ -25,6 +27,7 @@ var ( ErrInvalidCode = errors.New("recovery code is invalid") ErrCodeNotFound = errors.New("recovery code is not valid") ErrCodeConsumed = errors.New("recovery code is already consumed") + ErrNoCiphertext = errors.New("recovery code ciphertext is unavailable") ) const ( @@ -177,6 +180,7 @@ func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count i }() codes = make([]string, 0, count) items := make([]models.RecoveryCode, 0, count) + vaultRoot := s.VaultRoot() for i := 0; i < count; i++ { if err := ctx.Err(); err != nil { return nil, err @@ -190,7 +194,14 @@ func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count i return nil, fmt.Errorf("hash recovery code: %w", err) } codes = append(codes, plain) - items = append(items, models.RecoveryCode{UserID: userID, CodeHash: hash, CodeVersion: 1}) + ciphertext := "" + if vaultRoot != "" { + ciphertext, err = credential.Encrypt(vaultRoot, strconv.Itoa(userID)+":"+plain) + if err != nil { + return nil, fmt.Errorf("protect recovery code: %w", err) + } + } + items = append(items, models.RecoveryCode{UserID: userID, CodeHash: hash, CodeCiphertext: ciphertext, CodeVersion: 1}) } phase = "lock" log.WithFields(log.Fields{"action": "生成离线恢复码", "phase": phase, "count": count, @@ -214,7 +225,7 @@ func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count i return nil, err } now := s.now() - if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { + if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at", "code_ciphertext").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { _ = session.Rollback() return nil, fmt.Errorf("revoke old recovery codes: %w", err) } @@ -237,6 +248,50 @@ func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count i return codes, nil } +// RevealRecoveryCodes 解密当前用户有效的安全码,仅在调用方完成安全验证后使用。 +func (s *Service) RevealRecoveryCodes(userID int) ([]string, error) { + if s == nil || s.engine == nil { + return nil, ErrUnavailable + } + if userID <= 0 { + return nil, ErrInvalidUser + } + s.mu.Lock() + defer s.mu.Unlock() + vaultRoot := s.vaultRoot + if vaultRoot == "" { + return nil, ErrNoCiphertext + } + var items []models.RecoveryCode + if err := s.engine.Where("user_id = ? AND used_at IS NULL", userID).Asc("id").Find(&items); err != nil { + return nil, fmt.Errorf("load recovery codes for reveal: %w", err) + } + if len(items) == 0 { + return nil, ErrCodeNotFound + } + result := make([]string, 0, len(items)) + for _, item := range items { + if item.CodeCiphertext == "" || item.CodeHash == "" { + return nil, ErrNoCiphertext + } + plain, err := credential.Decrypt(vaultRoot, item.CodeCiphertext) + if err != nil { + return nil, ErrNoCiphertext + } + account, code, ok := strings.Cut(plain, ":") + if !ok || account != strconv.Itoa(userID) || !verifyRecoveryCodeHash(item.CodeHash, code) { + return nil, ErrNoCiphertext + } + result = append(result, code) + } + return result, nil +} + +func verifyRecoveryCodeHash(encoded, value string) bool { + matched, err := password.Check(encoded, value) + return err == nil && matched +} + // ConsumeRecoveryCode 原子消费一个恢复码,成功后该码不可再次使用。 func (s *Service) ConsumeRecoveryCode(userID int, code string) error { if s == nil || s.engine == nil { @@ -289,7 +344,7 @@ func (s *Service) consumeRecoveryCode(session recoveryCodeSession, userID int, c continue } now := s.now() - affected, err := session.ID(item.ID).Where("used_at IS NULL").Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}) + affected, err := session.ID(item.ID).Where("used_at IS NULL").Cols("used_at", "code_ciphertext").Update(&models.RecoveryCode{UsedAt: &now}) if err != nil { return fmt.Errorf("consume recovery code: %w", err) } @@ -336,7 +391,7 @@ func (s *Service) RevokeRecoveryCodes(userID int, guards ...accountgroup.WriteGu if err := accountgroup.CheckWrite(session, guards...); err != nil { return err } - if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { + if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at", "code_ciphertext").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { return fmt.Errorf("revoke recovery codes: %w", err) } return session.Commit() @@ -364,7 +419,7 @@ func (s *Service) RevokeAllWithSession(session *xorm.Session, userID int) error return ErrInvalidUser } now := s.now() - if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { + if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at", "code_ciphertext").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { return fmt.Errorf("revoke recovery codes: %w", err) } if _, err := session.Where("user_id = ?", userID).Delete(&models.SecurityQuestion{}); err != nil { @@ -392,7 +447,7 @@ func revokeAllOnSession(session *xorm.Session, now func() time.Time, userID int) return fmt.Errorf("begin credential revocation: %w", err) } when := now() - if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &when}); err != nil { + if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at", "code_ciphertext").Update(&models.RecoveryCode{UsedAt: &when}); err != nil { _ = session.Rollback() return fmt.Errorf("revoke recovery codes: %w", err) } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 89d5f74..45d26fa 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -4,7 +4,11 @@ import { ElMessageBox, ElNotification } from 'element-plus' import lang from '../../../i18n/i18n' import { http } from '@/utils/axios' -import { requestWithRecentAuthentication } from '@/utils/recentAuthentication' +import { + confirmRecentAuthentication, + isRecentAuthenticationCancelled, + requestWithRecentAuthentication, +} from '@/utils/recentAuthentication' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' import { normalizeCreationOptions, serializeCredential, webAuthnSupported } from '@/utils/webauthn' @@ -56,6 +60,33 @@ export function usePage() { const questionForm = reactive({ question: '', answer: '' }) const recoveryConfigured = ref(false) const generatedCodes = ref([]) + const recoveryLoading = ref(false) + let recoveryCodeHideTimer = null + let recoveryViewVersion = 0 + let pageActive = true + + const clearRecoveryCodes = () => { + recoveryViewVersion += 1 + generatedCodes.value = [] + clearTimeout(recoveryCodeHideTimer) + recoveryCodeHideTimer = null + } + + const isRecoveryViewCurrent = version => ( + pageActive && selectedSection.value === 'recovery' && version === recoveryViewVersion + ) + + const handleVisibilityChange = () => { + if (document.hidden) clearRecoveryCodes() + } + + const requestRecoveryAction = (action, data, version) => ( + requestWithRecentAuthentication(() => { + if (!isRecoveryViewCurrent(version)) return null + return http.post(`/api/v1/security/credentials/recovery-codes/${action}`, data) + }) + ) + const externalEmails = ref([]) const externalListLoading = ref(false) const externalDialogVisible = ref(false) @@ -213,30 +244,79 @@ export function usePage() { } const generateRecoveryCodes = async () => { + if (recoveryLoading.value) return + clearRecoveryCodes() + const version = recoveryViewVersion + recoveryLoading.value = true try { await ElMessageBox.confirm('生成新恢复码会撤销当前仍有效的恢复码,请确认已准备好安全保存。', '生成离线恢复码', { type: 'warning' }) - const res = await requestWithRecentAuthentication(() => http.post('/api/v1/security/credentials/recovery-codes/generate', { count: 10 })) + if (!isRecoveryViewCurrent(version)) return + const res = await requestRecoveryAction('generate', { count: 10 }, version) + if (!isRecoveryViewCurrent(version)) return if (res.errorNo !== 0 || !Array.isArray(res.data?.codes)) throw new Error(res.errorMsg || '离线恢复码生成失败') generatedCodes.value = res.data.codes + scheduleRecoveryCodeHide() recoveryConfigured.value = true await globalStatus.refresh() notify('恢复码已生成,请仅保存这一次显示的内容', 'success') } catch (error) { + if (isRecentAuthenticationCancelled(error) || !isRecoveryViewCurrent(version)) return if (error !== 'cancel' && error !== 'close') notify(error?.message || '离线恢复码生成失败') + } finally { + recoveryLoading.value = false + } + } + + const scheduleRecoveryCodeHide = () => { + clearTimeout(recoveryCodeHideTimer) + recoveryCodeHideTimer = setTimeout(clearRecoveryCodes, 60000) + } + + const revealRecoveryCodes = async () => { + if (recoveryLoading.value) return + clearRecoveryCodes() + const version = recoveryViewVersion + recoveryLoading.value = true + try { + await confirmRecentAuthentication() + if (!isRecoveryViewCurrent(version)) return + const res = await requestRecoveryAction('reveal', {}, version) + if (!isRecoveryViewCurrent(version)) return + if (res.errorNo !== 0 || !Array.isArray(res.data?.codes) || !res.data.codes.length) { + throw new Error(res.errorMsg || '读取安全码失败') + } + generatedCodes.value = res.data.codes + scheduleRecoveryCodeHide() + notify('安全码已显示,60 秒后自动隐藏', 'success') + } catch (error) { + if (isRecentAuthenticationCancelled(error) || !isRecoveryViewCurrent(version)) return + if (error !== 'cancel' && error !== 'close') notify(error?.message || '读取安全码失败') + } finally { + recoveryLoading.value = false } } const revokeRecoveryCodes = async () => { + if (recoveryLoading.value) return + clearRecoveryCodes() + const version = recoveryViewVersion + recoveryLoading.value = true try { await ElMessageBox.confirm('撤销后现有离线恢复码将立即失效,是否继续?', '撤销离线恢复码', { type: 'warning' }) - const res = await requestWithRecentAuthentication(() => http.post('/api/v1/security/credentials/recovery-codes/revoke', {})) + if (!isRecoveryViewCurrent(version)) return + const res = await requestRecoveryAction('revoke', {}, version) + if (!isRecoveryViewCurrent(version)) return if (res.errorNo !== 0) throw new Error(res.errorMsg || '撤销恢复码失败') recoveryConfigured.value = false generatedCodes.value = [] + clearTimeout(recoveryCodeHideTimer) await globalStatus.refresh() notify('离线恢复码已撤销', 'success') } catch (error) { + if (isRecentAuthenticationCancelled(error) || !isRecoveryViewCurrent(version)) return if (error !== 'cancel' && error !== 'close') notify(error?.message || '撤销恢复码失败') + } finally { + recoveryLoading.value = false } } @@ -332,9 +412,21 @@ export function usePage() { passwordChallenge.value = '' ruleForm.code = '' }) - onUnmounted(() => clearInterval(passwordTimer)) + onUnmounted(() => { + pageActive = false + clearRecoveryCodes() + document.removeEventListener('visibilitychange', handleVisibilityChange) + clearInterval(passwordTimer) + }) + + watch(selectedSection, section => { + if (section !== 'recovery') { + clearRecoveryCodes() + } + }) onMounted(async () => { + document.addEventListener('visibilitychange', handleVisibilityChange) await loadSecurityData() }) @@ -346,10 +438,10 @@ export function usePage() { lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection, globalSecurityPolicyPage, passwordEmail, passwordSending, passwordSubmitting, passwordCountdown, sendPasswordCode, - registering, credentialsLoading, credentials, securityLoading, + registering, credentialsLoading, credentials, securityLoading, recoveryLoading, securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading, externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential, - saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog, + saveSecurityQuestion, generateRecoveryCodes, revealRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog, handleExternalEmailBound, disableExternalEmail, formatTime, } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 6bafc05..1d97d23 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -82,15 +82,19 @@ -
+
-

安全码

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

-
生成新恢复码撤销
+

安全码

安全码默认隐藏,完成安全验证后可以重新查看。

+
验证后查看生成新恢复码撤销
-
以下内容只显示本次,请立即复制并妥善保存;离开页面后不会再次读取。
+
安全码已通过安全验证显示,仅在当前页面短暂显示,请立即复制并妥善保存。
{{ code }}
@@ -148,10 +152,10 @@ const { lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection, globalSecurityPolicyPage, passwordEmail, passwordSending, passwordSubmitting, passwordCountdown, sendPasswordCode, - registering, credentialsLoading, credentials, securityLoading, + registering, credentialsLoading, credentials, securityLoading, recoveryLoading, securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading, externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential, - saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog, + saveSecurityQuestion, generateRecoveryCodes, revealRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog, handleExternalEmailBound, disableExternalEmail, formatTime, } = usePage() diff --git a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.css b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.css index cc783de..02e33c9 100644 --- a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.css +++ b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.css @@ -74,6 +74,7 @@ .recovery-dialog-actions { display: flex; + flex-wrap: wrap; justify-content: flex-end; gap: 10px; } diff --git a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js index afec442..70eef06 100644 --- a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js +++ b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.js @@ -1,8 +1,9 @@ -import { computed, ref, watch } from 'vue' +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { http } from '@/utils/axios' import { + confirmRecentAuthentication, isRecentAuthenticationCancelled, requestWithRecentAuthentication, } from '@/utils/recentAuthentication' @@ -32,87 +33,165 @@ function assertSuccess(response, fallback) { export function useRecoveryCodeDialog(props, emit) { const configured = ref(false) const codes = ref([]) + const loading = ref(false) + const revealing = ref(false) + const revealed = ref(false) const generating = ref(false) const revoking = ref(false) const errorMessage = ref('') + let hideTimer = null + let viewVersion = 0 + let active = true + + const clearCodes = () => { + codes.value = [] + revealed.value = false + clearTimeout(hideTimer) + hideTimer = null + } + + const scheduleHide = () => { + clearTimeout(hideTimer) + hideTimer = setTimeout(clearCodes, 60000) + } + + const isCurrentView = (version, userID) => ( + active && props.modelValue && version === viewVersion && props.user?.ID === userID + ) const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value), }) - const busy = computed(() => generating.value || revoking.value) + const busy = computed(() => loading.value || revealing.value || generating.value || revoking.value) const loadStatus = async () => { if (!props.user?.ID) return false + const userID = props.user.ID + const version = viewVersion + loading.value = true errorMessage.value = '' try { const response = await requestWithRecentAuthentication(async () => { + if (!isCurrentView(version, userID)) return null const result = await http.get('/api/v1/user/security/recovery-codes', { - params: { id: props.user.ID }, + params: { id: userID }, }) return assertSuccess(result, '读取安全码状态失败') }) + if (!isCurrentView(version, userID)) return false configured.value = Boolean(response.data?.configured) return true } catch (error) { + if (!isCurrentView(version, userID)) return false if (!isRecentAuthenticationCancelled(error)) { errorMessage.value = error?.message || '读取安全码状态失败' } return false + } finally { + if (version === viewVersion) loading.value = false } } const generateCodes = async () => { if (busy.value || !props.user?.ID) return false + const userID = props.user.ID + const version = viewVersion + clearCodes() generating.value = true errorMessage.value = '' try { const response = await requestWithRecentAuthentication(async () => { + if (!isCurrentView(version, userID)) return null const result = await http.post('/api/v1/user/security/recovery-codes/generate', { - user_id: props.user.ID, + user_id: userID, count: 10, }) return assertSuccess(result, '安全码生成失败') }) + if (!isCurrentView(version, userID)) return false codes.value = Array.isArray(response.data?.codes) ? response.data.codes : [] if (!codes.value.length) { throw new Error('服务器未返回安全码,请稍后重试') } configured.value = true + scheduleHide() emit('changed') return true } catch (error) { + if (!isCurrentView(version, userID)) return false if (!isRecentAuthenticationCancelled(error)) { errorMessage.value = error?.message || '安全码生成失败' } return false } finally { - generating.value = false + if (version === viewVersion) generating.value = false } } const revokeCodes = async () => { if (busy.value || !props.user?.ID) return false + const userID = props.user.ID + const version = viewVersion + clearCodes() revoking.value = true errorMessage.value = '' try { await requestWithRecentAuthentication(async () => { + if (!isCurrentView(version, userID)) return null const result = await http.post('/api/v1/user/security/recovery-codes/revoke', { - user_id: props.user.ID, + user_id: userID, }) return assertSuccess(result, '安全码撤销失败') }) + if (!isCurrentView(version, userID)) return false configured.value = false codes.value = [] emit('changed') return true } catch (error) { + if (!isCurrentView(version, userID)) return false if (!isRecentAuthenticationCancelled(error)) { errorMessage.value = error?.message || '安全码撤销失败' } return false } finally { - revoking.value = false + if (version === viewVersion) revoking.value = false + } + } + + const revealCodes = async () => { + if (busy.value || !configured.value || !props.user?.ID) return false + const userID = props.user.ID + const version = viewVersion + clearCodes() + revealing.value = true + errorMessage.value = '' + try { + await confirmRecentAuthentication() + if (!isCurrentView(version, userID)) return false + const response = await requestWithRecentAuthentication(async () => { + if (!isCurrentView(version, userID)) return null + const result = await http.post('/api/v1/user/security/recovery-codes/reveal', { + user_id: userID, + }) + return assertSuccess(result, '读取安全码失败') + }) + if (!isCurrentView(version, userID)) return false + if (!Array.isArray(response.data?.codes) || !response.data.codes.length) { + throw new Error('当前没有可查看的有效安全码') + } + codes.value = response.data.codes + revealed.value = true + scheduleHide() + return true + } catch (error) { + if (isCurrentView(version, userID) && !isRecentAuthenticationCancelled(error)) { + errorMessage.value = error?.message || '读取安全码失败' + } + return false + } finally { + if (version === viewVersion) revealing.value = false } } @@ -127,8 +206,13 @@ export function useRecoveryCodeDialog(props, emit) { } const reset = () => { + viewVersion += 1 + clearCodes() + loading.value = false + revealing.value = false + generating.value = false + revoking.value = false configured.value = Boolean(props.user?.RecoveryCodeConfigured) - codes.value = [] errorMessage.value = '' } @@ -139,25 +223,46 @@ export function useRecoveryCodeDialog(props, emit) { } watch( - () => props.modelValue, - value => { + [() => props.modelValue, () => props.user?.ID], + ([value]) => { + reset() if (value) { - reset() void loadStatus() } }, + { immediate: true, flush: 'sync' }, ) + const handleVisibilityChange = () => { + if (document.hidden) reset() + } + + onMounted(() => { + document.addEventListener('visibilitychange', handleVisibilityChange) + }) + + onUnmounted(() => { + active = false + viewVersion += 1 + clearCodes() + document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + return { visible, configured, codes, + loading, + revealing, + revealed, + clearCodes, generating, revoking, busy, errorMessage, close, generateCodes, + revealCodes, revokeCodes, copyCodes, } diff --git a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.vue b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.vue index b6498bc..3a4e09e 100644 --- a/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.vue +++ b/frontend/src/views/components/UserManagement/RecoveryCodeDialog/RecoveryCodeDialog.vue @@ -22,10 +22,14 @@
-
+
新安全码仅显示这一次 请立即交给用户妥善保存,关闭弹窗后无法再次查看。
+
+ 当前有效安全码 + 60 秒后自动隐藏;关闭弹窗后,需再次验证密码才能查看。 +
{{ code }}
@@ -42,13 +46,24 @@