补充安全码加密存储及受权限约束的再次查看,增加前端验证和自动隐藏;构建已通过,云端业务验收待完成。
This commit is contained in:
parent
5e4ff07b1d
commit
2f24a72054
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
19
backend/db/migration/recovery_ciphertext.go
Normal file
19
backend/db/migration/recovery_ciphertext.go
Normal 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)
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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": "验证用户外部邮箱修改",
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -74,6 +74,7 @@
|
||||
|
||||
.recovery-dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user