补齐安全码验证后查看功能并保存部署前检查点
Some checks are pending
Security Checks / security (push) Waiting to run

补充安全码加密存储及受权限约束的再次查看,增加前端验证和自动隐藏;构建已通过,云端业务验收待完成。
This commit is contained in:
smail 2026-09-27 17:27:04 +08:00
parent 5e4ff07b1d
commit 2f24a72054
15 changed files with 411 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "验证用户外部邮箱修改",

View File

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

View File

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

View File

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

View File

@ -82,15 +82,19 @@
</section>
<el-divider v-if="selectedSection === 'recovery'" class="settings-divider" />
<section v-if="selectedSection === 'recovery'" class="security-section">
<section
v-if="selectedSection === 'recovery'"
v-loading="recoveryLoading"
class="security-section"
>
<div class="settings-header security-key-header">
<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><h3>安全码</h3><p class="settings-desc">安全码默认隐藏,完成安全验证后可以重新查看。</p></div>
<div class="section-actions"><el-button v-if="recoveryConfigured && !generatedCodes.length" @click="revealRecoveryCodes">验证后查看</el-button><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 />
<el-alert v-else title="当前未配置有效离线恢复码,高风险操作可能无法进行。" type="warning" :closable="false" show-icon />
<div v-if="generatedCodes.length" class="recovery-code-panel">
<div class="one-time-tip">以下内容只显示本次,请立即复制并妥善保存;离开页面后不会再次读取。</div>
<div class="one-time-tip">安全码已通过安全验证显示,仅在当前页面短暂显示,请立即复制并妥善保存。</div>
<code v-for="code in generatedCodes" :key="code" class="recovery-code">{{ code }}</code>
</div>
</section>
@ -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()

View File

@ -74,6 +74,7 @@
.recovery-dialog-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}

View File

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

View File

@ -22,10 +22,14 @@
</div>
<div v-if="codes.length" class="recovery-code-result">
<div class="result-title">
<div v-if="!revealed" class="result-title">
<strong>新安全码仅显示这一次</strong>
<span>请立即交给用户妥善保存,关闭弹窗后无法再次查看。</span>
</div>
<div v-else class="result-title">
<strong>当前有效安全码</strong>
<span>60 秒后自动隐藏;关闭弹窗后,需再次验证密码才能查看。</span>
</div>
<div class="recovery-code-grid">
<code v-for="code in codes" :key="code">{{ code }}</code>
</div>
@ -42,13 +46,24 @@
<template #footer>
<div class="recovery-dialog-actions">
<el-button
v-if="configured && !codes.length"
:loading="revealing"
:disabled="loading || generating || revoking"
@click="revealCodes"
>
验证后查看
</el-button>
<el-button v-if="codes.length" :disabled="busy" @click="clearCodes">
隐藏安全码
</el-button>
<el-button :disabled="busy" @click="close">关闭</el-button>
<el-button
v-if="configured && !codes.length"
type="danger"
plain
:loading="revoking"
:disabled="generating"
:disabled="generating || revealing || loading"
@click="revokeCodes"
>
撤销安全码
@ -56,7 +71,7 @@
<el-button
type="primary"
:loading="generating"
:disabled="revoking"
:disabled="revoking || revealing || loading"
@click="generateCodes"
>
{{ configured ? '重新生成安全码' : '生成安全码' }}
@ -79,12 +94,17 @@ const {
visible,
configured,
codes,
loading,
revealing,
revealed,
clearCodes,
generating,
revoking,
busy,
errorMessage,
close,
generateCodes,
revealCodes,
revokeCodes,
copyCodes,
} = useRecoveryCodeDialog(props, emit)