完善用户安全信息管理
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 11:53:47 +08:00
parent 3456b9d481
commit 31ee54942a
22 changed files with 1728 additions and 5 deletions

View File

@ -0,0 +1,299 @@
package controllers
import (
"errors"
"net/http"
"strconv"
"strings"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/verification"
"git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
)
type managedUserSecurityRequest struct {
UserID int `json:"user_id"`
Count int `json:"count"`
Email string `json:"email"`
Type string `json:"type"`
ChallengeID string `json:"challenge_id"`
BindingID int `json:"binding_id"`
Code string `json:"code"`
}
func loadManagedSecurityUser(ctx *context.Context, w http.ResponseWriter, userID int) (*models.User, bool) {
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")
return nil, false
}
if db.Instance == nil || userID <= 0 {
writeSecurityError(w, response.ParamsError, "目标用户无效")
return nil, false
}
var target models.User
found, err := db.Instance.ID(userID).Get(&target)
if err != nil {
log.WithContext(ctx).WithError(err).Error("load managed security user failed")
writeSecurityError(w, response.ServerError, "读取目标用户失败")
return nil, false
}
if !found || target.ID <= 0 {
writeSecurityError(w, response.ParamsError, "目标用户不存在")
return nil, false
}
if err := authorizeCapability(ctx, access.CapabilityUserSecurityManage, target.EffectiveRole(), false); err != nil {
if access.IsDenied(err, access.CodeRecentAuthRequired) {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired))
return nil, false
}
if access.IsDenied(err, access.CodeRecoveryCodeRequired) {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码", string(access.CodeRecoveryCodeRequired))
return nil, false
}
writeSecurityError(w, response.NoAccessPrivileges, "无权管理该用户的安全信息")
return nil, false
}
return &target, true
}
func managedUserIDFromQuery(req *http.Request) int {
if req == nil || req.URL == nil {
return 0
}
userID, _ := strconv.Atoi(strings.TrimSpace(req.URL.Query().Get("id")))
return userID
}
// GetManagedRecoveryCodeStatus 返回目标用户安全码状态,不返回任何明文或哈希。
func GetManagedRecoveryCodeStatus(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
target, ok := loadManagedSecurityUser(ctx, w, managedUserIDFromQuery(req))
if !ok {
return
}
service := credentialRuntime()
if service == nil {
writeSecurityError(w, response.ServerError, "安全凭据服务不可用")
return
}
configured, err := service.HasValidRecoveryCode(target.ID)
if err != nil {
log.WithContext(ctx).WithError(err).Error("load managed recovery code status failed")
writeSecurityError(w, response.ServerError, "读取安全码状态失败")
return
}
response.NewSuccessResponse(map[string]any{
"user_id": target.ID,
"account": target.Account,
"configured": configured,
}).FPrint(w)
}
// GenerateManagedRecoveryCodes 为目标用户生成只显示一次的安全码。
func GenerateManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
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.GenerateRecoveryCodes(target.ID, input.Count)
if err != nil {
log.WithContext(ctx).WithError(err).Error("generate managed recovery codes failed")
writeSecurityError(w, response.ParamsError, "安全码生成失败")
return
}
InvalidateSecurityRuntimeUser(target.ID, target.Account)
response.NewSuccessResponse(map[string]any{
"user_id": target.ID,
"codes": codes,
"shown_once": true,
}).FPrint(w)
}
// RevokeManagedRecoveryCodes 撤销目标用户现有的全部安全码。
func RevokeManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
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
}
if err := service.RevokeRecoveryCodes(target.ID); err != nil {
log.WithContext(ctx).WithError(err).Error("revoke managed recovery codes failed")
writeSecurityError(w, response.ServerError, "撤销安全码失败")
return
}
InvalidateSecurityRuntimeUser(target.ID, target.Account)
response.NewSuccessResponse(map[string]any{"user_id": target.ID, "revoked": true}).FPrint(w)
}
// ListManagedExternalEmails 返回目标用户已验证的外部邮箱掩码。
func ListManagedExternalEmails(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
target, ok := loadManagedSecurityUser(ctx, w, managedUserIDFromQuery(req))
if !ok {
return
}
runtime := currentSecurityRuntime()
if runtime.external == nil {
writeSecurityError(w, response.ServerError, "外部邮箱服务不可用")
return
}
items, err := runtime.external.List(target.ID)
if err != nil {
log.WithContext(ctx).WithError(err).Error("list managed external emails failed")
writeSecurityError(w, response.ServerError, "读取外部邮箱失败")
return
}
result := make([]map[string]any, 0, len(items))
for _, item := range items {
if item.Status != models.ExternalEmailStatusVerified {
continue
}
email, decryptErr := runtime.external.Decrypt(item)
if decryptErr != nil {
continue
}
result = append(result, map[string]any{
"id": item.ID, "type": item.EmailType, "status": item.Status, "email": maskEmail(email),
})
}
response.NewSuccessResponse(map[string]any{
"user_id": target.ID,
"account": target.Account,
"items": result,
}).FPrint(w)
}
// ChangeManagedExternalEmail 发送目标用户外部邮箱变更验证码。
func ChangeManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
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
}
runtime := currentSecurityRuntime()
if runtime.external == nil || runtime.verification == nil {
writeSecurityError(w, response.ServerError, "外部邮箱验证服务不可用")
return
}
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
if err != nil {
writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址")
return
}
owned, err := currentUserPrimaryEmail(target.ID, normalizedEmail)
if err != nil {
log.WithContext(ctx).WithError(err).Error("check managed user primary email failed")
writeSecurityError(w, response.ServerError, "外部邮箱变更失败")
return
}
if owned {
writeSecurityError(w, response.ParamsError, "不能将该用户自己的邮箱绑定为外部安全邮箱")
return
}
emailType := strings.TrimSpace(input.Type)
if emailType == "" {
emailType = models.ExternalEmailTypePrimary
}
item, err := runtime.external.BeginReplacement(externalemail.BindRequest{
UserID: target.ID,
Email: normalizedEmail,
Type: emailType,
})
if err != nil {
if errors.Is(err, externalemail.ErrBindingExists) {
writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用")
return
}
log.WithContext(ctx).WithError(err).Error("begin managed external email replacement failed")
writeSecurityError(w, response.ParamsError, "外部邮箱变更失败")
return
}
if runtime.verification.Store != nil {
runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailChange)
runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailBind)
}
challenge, err := runtime.verification.Issue(verification.IssueRequest{
Purpose: verification.PurposeExternalEmailChange,
UserID: target.ID,
Account: target.Account,
Email: normalizedEmail,
ResourceID: item.ID,
}, remoteIP(req))
if err != nil {
log.WithContext(ctx).WithError(err).Warn("issue managed external email verification failed")
writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err))
return
}
response.NewSuccessResponse(map[string]any{
"user_id": target.ID, "binding_id": item.ID, "challenge_id": challenge.ID,
"email": maskEmail(normalizedEmail), "expires_at": challenge.ExpiresAt.UTC(),
}).FPrint(w)
}
// VerifyManagedExternalEmail 验证成功后原子替换目标用户同用途的旧邮箱。
func VerifyManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var input managedUserSecurityRequest
if err := decodeUserRequest(w, req, &input); err != nil || input.BindingID <= 0 {
writeSecurityError(w, response.ParamsError, "验证请求无效")
return
}
target, ok := loadManagedSecurityUser(ctx, w, input.UserID)
if !ok {
return
}
runtime := currentSecurityRuntime()
if runtime.external == nil || runtime.verification == nil {
writeSecurityError(w, response.ServerError, "外部邮箱验证服务不可用")
return
}
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
if err != nil {
writeSecurityError(w, response.ParamsError, "验证失败")
return
}
if err := runtime.verification.VerifyBound(
input.ChallengeID,
target.Account,
normalizedEmail,
input.Code,
verification.PurposeExternalEmailChange,
input.BindingID,
remoteIP(req),
); err != nil {
writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err))
return
}
if err := runtime.external.VerifyReplacement(target.ID, input.BindingID); err != nil {
log.WithContext(ctx).WithError(err).Error("verify managed external email replacement failed")
writeSecurityError(w, response.ParamsError, "外部邮箱替换失败")
return
}
InvalidateSecurityRuntimeUser(target.ID, target.Account)
response.NewSuccessResponse(map[string]any{"user_id": target.ID, "verified": true}).FPrint(w)
}

View File

@ -14,6 +14,9 @@ import (
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/verification"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/password"
)
@ -226,3 +229,225 @@ func TestUserRequestsRejectInvalidJSON(t *testing.T) {
})
}
}
func TestManagedRecoveryCodesAreScopedAndShownOnce(t *testing.T) {
oldConfig := config.Instance
oldDB := db.Instance
oldRuntime := currentSecurityRuntime()
testRoot := t.TempDir()
t.Cleanup(func() {
if db.Instance != nil && db.Instance != oldDB {
_ = db.Instance.Close()
}
config.Instance = oldConfig
db.Instance = oldDB
ConfigureSecurityRuntime(oldRuntime.verification, oldRuntime.external, oldRuntime.recovery, oldRuntime.credentials)
ConfigureWebAuthnRuntime(oldRuntime.webauthn)
})
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "managed-recovery.db")}
if err := db.Init(""); err != nil {
t.Fatal(err)
}
actor := models.User{Account: "security-owner", Name: "Owner", SessionVersion: 1, SecurityVersion: 1}
actor.ApplyRole(access.RoleSuperAdmin)
target := models.User{Account: "security-target", Name: "Target", SessionVersion: 1, SecurityVersion: 1}
target.ApplyRole(access.RoleUser)
if err := auth.SetPassword(&actor, "OwnerPass123"); err != nil {
t.Fatal(err)
}
if err := auth.SetPassword(&target, "TargetP123"); err != nil {
t.Fatal(err)
}
if _, err := db.Instance.Insert(&actor, &target); err != nil {
t.Fatal(err)
}
credentialService, err := credentials.NewService(db.Instance)
if err != nil {
t.Fatal(err)
}
ConfigureSecurityRuntime(nil, nil, nil, credentialService)
ctx := &appcontext.Context{
UserID: actor.ID, UserAccount: actor.Account, Role: access.RoleSuperAdmin, RecentAuth: true,
}
generateRecorder := httptest.NewRecorder()
generateRequest := httptest.NewRequest(http.MethodPost, "/api/v1/user/security/recovery-codes/generate", strings.NewReader(`{"user_id":`+itoa(target.ID)+`,"count":5}`))
GenerateManagedRecoveryCodes(ctx, generateRecorder, generateRequest)
var generated struct {
ErrorNo int `json:"errorNo"`
Data struct {
UserID int `json:"user_id"`
Codes []string `json:"codes"`
ShownOnce bool `json:"shown_once"`
} `json:"data"`
}
if err := json.Unmarshal(generateRecorder.Body.Bytes(), &generated); err != nil {
t.Fatal(err)
}
if generated.ErrorNo != 0 || generated.Data.UserID != target.ID || len(generated.Data.Codes) != 5 || !generated.Data.ShownOnce {
t.Fatalf("unexpected managed recovery response: %s", generateRecorder.Body.String())
}
for _, code := range generated.Data.Codes {
if code == "" || !strings.Contains(code, "-") {
t.Fatalf("invalid one-time recovery code: %q", code)
}
}
statusRecorder := httptest.NewRecorder()
statusRequest := httptest.NewRequest(http.MethodGet, "/api/v1/user/security/recovery-codes?id="+itoa(target.ID), nil)
GetManagedRecoveryCodeStatus(ctx, statusRecorder, statusRequest)
if !strings.Contains(statusRecorder.Body.String(), `"configured":true`) {
t.Fatalf("managed recovery status was not enabled: %s", statusRecorder.Body.String())
}
for _, code := range generated.Data.Codes {
if strings.Contains(statusRecorder.Body.String(), code) {
t.Fatalf("status response leaked one-time code %q", code)
}
}
revokeRecorder := httptest.NewRecorder()
revokeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/user/security/recovery-codes/revoke", strings.NewReader(`{"user_id":`+itoa(target.ID)+`}`))
RevokeManagedRecoveryCodes(ctx, revokeRecorder, revokeRequest)
if !strings.Contains(revokeRecorder.Body.String(), `"revoked":true`) {
t.Fatalf("managed recovery codes were not revoked: %s", revokeRecorder.Body.String())
}
deniedRecorder := httptest.NewRecorder()
deniedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/user/security/recovery-codes?id="+itoa(actor.ID), nil)
GetManagedRecoveryCodeStatus(ctx, deniedRecorder, deniedRequest)
var denied struct {
ErrorNo int `json:"errorNo"`
}
if err := json.Unmarshal(deniedRecorder.Body.Bytes(), &denied); err != nil {
t.Fatal(err)
}
if denied.ErrorNo == 0 {
t.Fatal("web route allowed management of super-administrator recovery codes")
}
}
func TestManagedExternalEmailChangeInvalidatesPreviousChallenge(t *testing.T) {
oldConfig := config.Instance
oldDB := db.Instance
oldRuntime := currentSecurityRuntime()
testRoot := t.TempDir()
t.Cleanup(func() {
if db.Instance != nil && db.Instance != oldDB {
_ = db.Instance.Close()
}
config.Instance = oldConfig
db.Instance = oldDB
ConfigureSecurityRuntime(oldRuntime.verification, oldRuntime.external, oldRuntime.recovery, oldRuntime.credentials)
ConfigureWebAuthnRuntime(oldRuntime.webauthn)
})
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "managed-external-email.db")}
if err := db.Init(""); err != nil {
t.Fatal(err)
}
actor := models.User{Account: "email-owner", Name: "Owner", SessionVersion: 1, SecurityVersion: 1}
actor.ApplyRole(access.RoleSuperAdmin)
target := models.User{Account: "email-target", Name: "Target", SessionVersion: 1, SecurityVersion: 1}
target.ApplyRole(access.RoleUser)
if err := auth.SetPassword(&actor, "OwnerPass123"); err != nil {
t.Fatal(err)
}
if err := auth.SetPassword(&target, "TargetP123"); err != nil {
t.Fatal(err)
}
if _, err := db.Instance.Insert(&actor, &target); err != nil {
t.Fatal(err)
}
vault := externalemail.NewVaultWithKeys(
[]byte("01234567890123456789012345678901"),
[]byte("abcdefabcdefabcdefabcdefabcdefab"),
)
external, err := externalemail.NewRepository(db.Instance, vault)
if err != nil {
t.Fatal(err)
}
store, err := verification.NewStore(32)
if err != nil {
t.Fatal(err)
}
issuedCodes := make(map[string]string)
verificationService, err := verification.NewService(store, func(challenge verification.Challenge, code string) error {
issuedCodes[challenge.ID] = code
return nil
}, nil)
if err != nil {
t.Fatal(err)
}
ConfigureSecurityRuntime(verificationService, external, nil, nil)
pending, err := external.BeginReplacement(externalemail.BindRequest{
UserID: target.ID,
Email: "first@example.com",
Type: models.ExternalEmailTypePrimary,
})
if err != nil {
t.Fatal(err)
}
oldChallenge, err := store.Issue(verification.IssueRequest{
Purpose: verification.PurposeExternalEmailChange,
UserID: target.ID,
Account: target.Account,
Email: "first@example.com",
ResourceID: pending.ID,
}, func(challenge verification.Challenge, code string) error {
issuedCodes[challenge.ID] = code
return nil
})
if err != nil {
t.Fatal(err)
}
ctx := &appcontext.Context{
UserID: actor.ID,
UserAccount: actor.Account,
Role: access.RoleSuperAdmin,
RecentAuth: true,
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/user/security/external-email/change",
strings.NewReader(`{"user_id":`+itoa(target.ID)+`,"email":"second@example.com","type":"primary"}`),
)
ChangeManagedExternalEmail(ctx, recorder, request)
var result struct {
ErrorNo int `json:"errorNo"`
Data struct {
BindingID int `json:"binding_id"`
ChallengeID string `json:"challenge_id"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.ErrorNo != 0 || result.Data.BindingID != pending.ID || result.Data.ChallengeID == "" {
t.Fatalf("unexpected managed external email response: %s", recorder.Body.String())
}
if err := store.VerifyBound(
oldChallenge.ID,
target.Account,
"first@example.com",
issuedCodes[oldChallenge.ID],
verification.PurposeExternalEmailChange,
pending.ID,
); err != verification.ErrInvalidChallenge {
t.Fatalf("previous challenge remained valid after pending email changed: %v", err)
}
if err := store.VerifyBound(
result.Data.ChallengeID,
target.Account,
"second@example.com",
issuedCodes[result.Data.ChallengeID],
verification.PurposeExternalEmailChange,
pending.ID,
); err != nil {
t.Fatalf("new challenge was not valid: %v", err)
}
}

View File

@ -65,6 +65,12 @@ func registerV1Routes(mux *http.ServeMux) {
registerV1MethodRoute(mux, "/api/v1/user/edit", "/api/user/edit", http.MethodPost, 64<<10, controllers.EditUser)
registerV1MethodRoute(mux, "/api/v1/user/info", "/api/user/info", http.MethodPost, 64<<10, controllers.Info)
registerV1MethodRoute(mux, "/api/v1/user/list", "/api/user/list", http.MethodPost, 64<<10, controllers.UserList)
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/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)
registerV1MethodRoute(mux, "/api/v1/user/security/external-email/change", "/api/user/security/external-email/change", http.MethodPost, 16<<10, controllers.ChangeManagedExternalEmail)
registerV1MethodRoute(mux, "/api/v1/user/security/external-email/verify", "/api/user/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyManagedExternalEmail)
registerV1PluginRoutes(mux)
}

View File

@ -217,6 +217,12 @@ func TestFrontendV1RoutesAreRegistered(t *testing.T) {
"/api/v1/user/edit",
"/api/v1/user/info",
"/api/v1/user/list",
"/api/v1/user/security/recovery-codes",
"/api/v1/user/security/recovery-codes/generate",
"/api/v1/user/security/recovery-codes/revoke",
"/api/v1/user/security/external-email/list",
"/api/v1/user/security/external-email/change",
"/api/v1/user/security/external-email/verify",
}
for _, path := range paths {
handler, pattern := mux.Handler(httptest.NewRequest(http.MethodGet, path, nil))

View File

@ -97,6 +97,12 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/api/user/edit", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.EditUser))))
mux.HandleFunc("/api/user/info", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(controllers.Info))))
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/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))))
mux.HandleFunc("/api/user/security/external-email/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyManagedExternalEmail))))
mux.HandleFunc("/api/plugin/settings/", contextIterceptor(controllers.SettingsHtml))
mux.HandleFunc("/api/plugin/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetPluginList)))
mux.HandleFunc("/api/v1/config/", limitBody(4<<20, contextIterceptor(configRoute)))

View File

@ -32,6 +32,10 @@ func TestRoleBoundaries(t *testing.T) {
{name: "admin resets user", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPasswordReset, TargetRole: RoleUser, RecentAuth: true, RecoveryCodeConfigured: true}},
{name: "admin cannot reset admin", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPasswordReset, TargetRole: RoleAdmin, RecentAuth: true, RecoveryCodeConfigured: true}, code: CodeTargetRoleDenied},
{name: "user cannot reset another user", req: Request{ActorRole: RoleUser, Capability: CapabilityPasswordReset, TargetRole: RoleUser, RecentAuth: true, RecoveryCodeConfigured: true}, code: CodeAccessDenied},
{name: "super admin manages user security", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityUserSecurityManage, TargetRole: RoleUser, RecentAuth: true}},
{name: "admin manages user security", req: Request{ActorRole: RoleAdmin, Capability: CapabilityUserSecurityManage, TargetRole: RoleUser, RecentAuth: true, RecoveryCodeConfigured: true}},
{name: "admin cannot manage admin security", req: Request{ActorRole: RoleAdmin, Capability: CapabilityUserSecurityManage, TargetRole: RoleAdmin, RecentAuth: true, RecoveryCodeConfigured: true}, code: CodeTargetRoleDenied},
{name: "web cannot manage super admin security", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityUserSecurityManage, TargetRole: RoleSuperAdmin, RecentAuth: true}, code: CodeTargetRoleDenied},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@ -90,7 +94,7 @@ func TestAdminPluginRestartScope(t *testing.T) {
func TestManagementEndpointCatalog(t *testing.T) {
endpoints := ManagementEndpoints()
if len(endpoints) != 32 {
if len(endpoints) != 34 {
t.Fatalf("unexpected endpoint count: %d", len(endpoints))
}
seen := map[string]struct{}{}
@ -128,6 +132,10 @@ func TestManagementEndpointCatalog(t *testing.T) {
if !ok || endpoint.Capability != CapabilityConfigRollback {
t.Fatal("configuration backup endpoint did not require rollback capability")
}
endpoint, ok = LookupEndpoint("POST", "/api/v1/user/security/external-email/change")
if !ok || endpoint.Capability != CapabilityUserSecurityManage {
t.Fatal("managed user security endpoint was not matched")
}
}
func TestEveryManagementEndpointHasAllowAndDeny(t *testing.T) {

View File

@ -78,7 +78,7 @@ func authorizeResource(request Request) error {
if request.ActorRole == RoleAdmin && !request.OwnResource && !request.AuditScopeAuthorized {
return deny(CodeAccessDenied)
}
case CapabilityUserManage, CapabilityPasswordReset:
case CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordReset:
if !canManageAccountTarget(request.ActorRole, request.TargetRole) {
return deny(CodeTargetRoleDenied)
}

View File

@ -16,6 +16,7 @@ const (
CapabilityPluginWriteOwn Capability = "plugin.write_own"
CapabilityUserRead Capability = "user.read"
CapabilityUserManage Capability = "user.manage"
CapabilityUserSecurityManage Capability = "user.security_manage"
CapabilityRoleManage Capability = "role.manage"
CapabilityPasswordChangeOwn Capability = "password.change_own"
CapabilityPasswordReset Capability = "password.reset"
@ -45,6 +46,7 @@ var policies = map[Capability]Policy{
CapabilityPluginWriteOwn: {Capability: CapabilityPluginWriteOwn},
CapabilityUserRead: {Capability: CapabilityUserRead},
CapabilityUserManage: {Capability: CapabilityUserManage, RequiresTarget: true},
CapabilityUserSecurityManage: {Capability: CapabilityUserSecurityManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true},
CapabilityRoleManage: {Capability: CapabilityRoleManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true},
CapabilityPasswordChangeOwn: {Capability: CapabilityPasswordChangeOwn},
CapabilityPasswordReset: {Capability: CapabilityPasswordReset, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true},
@ -58,13 +60,13 @@ var grants = map[Role]map[Capability]struct{}{
CapabilityDatabaseMigrate, CapabilityPluginRead, CapabilityPluginControl,
CapabilityPluginRestart, CapabilityServiceRestart, CapabilityPluginWriteGlobal, CapabilityPluginWriteAnyUser,
CapabilityPluginWriteOwn, CapabilityUserRead, CapabilityUserManage,
CapabilityRoleManage, CapabilityPasswordChangeOwn, CapabilityPasswordReset,
CapabilityUserSecurityManage, CapabilityRoleManage, CapabilityPasswordChangeOwn, CapabilityPasswordReset,
CapabilityAuditRead, CapabilityAuditExport,
),
RoleAdmin: grant(
CapabilityConfigRead, CapabilityPluginRead, CapabilityPluginRestart,
CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead,
CapabilityUserManage, CapabilityPasswordChangeOwn, CapabilityPasswordReset,
CapabilityUserManage, CapabilityUserSecurityManage, CapabilityPasswordChangeOwn, CapabilityPasswordReset,
CapabilityAuditRead,
),
RoleUser: grant(

View File

@ -12,6 +12,7 @@ var managementEndpoints = []Endpoint{
{Method: "POST", Path: "/api/settings/modify_password", Capability: CapabilityPasswordChangeOwn},
{Method: "POST", Path: "/api/user/create", Capability: CapabilityUserManage},
{Method: "POST", Path: "/api/user/edit", Capability: CapabilityUserManage},
{Method: "*", Path: "/api/user/security/", Capability: CapabilityUserSecurityManage},
{Method: "POST", Path: "/api/user/info", Capability: CapabilityUserRead},
{Method: "POST", Path: "/api/user/list", Capability: CapabilityUserRead},
{Method: "GET", Path: "/api/plugin/list", Capability: CapabilityPluginRead},
@ -37,6 +38,7 @@ var managementEndpoints = []Endpoint{
{Method: "GET", Path: "/api/v1/database/migrations/:id", Capability: CapabilityDatabaseMigrate},
{Method: "POST", Path: "/api/v1/user/create", Capability: CapabilityUserManage},
{Method: "POST", Path: "/api/v1/user/edit", Capability: CapabilityUserManage},
{Method: "*", Path: "/api/v1/user/security/", Capability: CapabilityUserSecurityManage},
{Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead},
{Method: "POST", Path: "/api/v1/user/list", Capability: CapabilityUserRead},
{Method: "GET", Path: "/api/v1/plugin/list", Capability: CapabilityPluginRead},

View File

@ -31,6 +31,93 @@ type BindRequest struct {
Type string
}
// BeginReplacement 创建或更新待验证邮箱。旧邮箱会一直保留到新邮箱验证成功。
func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmail, error) {
if r == nil || r.engine == nil || r.vault == nil || request.UserID <= 0 {
return nil, ErrInvalidBinding
}
if request.Type != models.ExternalEmailTypePrimary && request.Type != models.ExternalEmailTypeBackup {
return nil, ErrInvalidBinding
}
normalized, err := NormalizeEmail(request.Email)
if err != nil {
return nil, ErrInvalidBinding
}
fingerprint, err := r.vault.Fingerprint(normalized)
if err != nil {
return nil, err
}
ciphertext, err := r.vault.Encrypt(normalized)
if err != nil {
return nil, err
}
var sameAddress models.ExternalEmail
hasSameAddress, err := r.engine.Where("email_fingerprint = ?", fingerprint).Get(&sameAddress)
if err != nil {
return nil, fmt.Errorf("check replacement external email: %w", err)
}
if hasSameAddress {
if sameAddress.UserID != request.UserID || sameAddress.Status == models.ExternalEmailStatusVerified {
return nil, ErrBindingExists
}
if sameAddress.EmailType != request.Type {
return nil, ErrBindingExists
}
if sameAddress.Status == models.ExternalEmailStatusPending {
return &sameAddress, nil
}
}
now := r.now().UTC()
if hasSameAddress && sameAddress.Status == models.ExternalEmailStatusDisabled {
sameAddress.Status = models.ExternalEmailStatusPending
sameAddress.VerifiedAt = nil
sameAddress.CredentialVersion++
sameAddress.UpdatedAt = now
if _, err := r.engine.ID(sameAddress.ID).Cols("status", "verified_at", "credential_version", "updated_at").Update(&sameAddress); err != nil {
return nil, fmt.Errorf("reuse disabled replacement external email: %w", err)
}
return &sameAddress, nil
}
var pending models.ExternalEmail
hasPending, err := r.engine.Where(
"user_id = ? AND email_type = ? AND status = ?",
request.UserID,
request.Type,
models.ExternalEmailStatusPending,
).Get(&pending)
if err != nil {
return nil, fmt.Errorf("load pending replacement external email: %w", err)
}
if hasPending {
pending.EmailCiphertext = ciphertext
pending.EmailFingerprint = fingerprint
pending.CredentialVersion++
pending.UpdatedAt = now
if _, err := r.engine.ID(pending.ID).Cols("email_ciphertext", "email_fingerprint", "credential_version", "updated_at").Update(&pending); err != nil {
return nil, fmt.Errorf("update pending replacement external email: %w", err)
}
return &pending, nil
}
item := &models.ExternalEmail{
UserID: request.UserID,
EmailCiphertext: ciphertext,
EmailFingerprint: fingerprint,
EmailType: request.Type,
Status: models.ExternalEmailStatusPending,
CredentialVersion: 1,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := r.engine.Insert(item); err != nil {
return nil, fmt.Errorf("create replacement external email: %w", err)
}
return item, nil
}
func NewRepository(engine *xorm.Engine, vault *Vault) (*Repository, error) {
if engine == nil || vault == nil {
return nil, ErrVaultUnavailable
@ -200,6 +287,65 @@ func (r *Repository) Verify(userID, id int) error {
return nil
}
// VerifyReplacement 验证新邮箱后原子停用同用途旧邮箱,并启用新邮箱。
func (r *Repository) VerifyReplacement(userID, id int) error {
if r == nil || r.engine == nil || userID <= 0 || id <= 0 {
return ErrInvalidBinding
}
session := r.engine.NewSession()
if session == nil {
return ErrInvalidBinding
}
defer session.Close()
if err := session.Begin(); err != nil {
return fmt.Errorf("begin external email replacement: %w", err)
}
committed := false
defer func() {
if !committed {
_ = session.Rollback()
}
}()
item := &models.ExternalEmail{}
has, err := session.Where("id = ? AND user_id = ? AND status = ?", id, userID, models.ExternalEmailStatusPending).Get(item)
if err != nil {
return fmt.Errorf("load replacement external email: %w", err)
}
if !has {
return ErrBindingOwnership
}
now := r.now().UTC()
if _, err := session.Where(
"user_id = ? AND email_type = ? AND id != ? AND status != ?",
userID,
item.EmailType,
id,
models.ExternalEmailStatusDisabled,
).Cols("status", "updated_at").Update(&models.ExternalEmail{
Status: models.ExternalEmailStatusDisabled,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("disable old external email: %w", err)
}
item.Status = models.ExternalEmailStatusVerified
item.VerifiedAt = timePtr(now)
item.UpdatedAt = now
updated, err := session.ID(id).Where("user_id = ? AND status = ?", userID, models.ExternalEmailStatusPending).
Cols("status", "verified_at", "updated_at").Update(item)
if err != nil {
return fmt.Errorf("verify replacement external email: %w", err)
}
if updated != 1 {
return ErrBindingOwnership
}
if err := session.Commit(); err != nil {
return fmt.Errorf("commit external email replacement: %w", err)
}
committed = true
return nil
}
// AbandonPending 删除当前请求刚创建且尚未验证的绑定,供投递失败时回滚。
func (r *Repository) AbandonPending(userID, id int) error {
if r == nil || r.engine == nil || userID <= 0 || id <= 0 {

View File

@ -113,3 +113,55 @@ func TestRepositoryAbandonsOnlyOwnedPendingBinding(t *testing.T) {
t.Fatalf("verified binding was abandoned: %v", err)
}
}
func TestRepositoryReplacesExternalEmailOnlyAfterVerification(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "external.db"))
if err != nil {
t.Fatal(err)
}
defer engine.Close()
if err := engine.Sync2(&models.ExternalEmail{}); err != nil {
t.Fatal(err)
}
vault := NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
repo, err := NewRepository(engine, vault)
if err != nil {
t.Fatal(err)
}
oldBinding, err := repo.Bind(BindRequest{UserID: 1, Email: "old@example.com", Type: models.ExternalEmailTypePrimary})
if err != nil {
t.Fatal(err)
}
if err := repo.Verify(1, oldBinding.ID); err != nil {
t.Fatal(err)
}
pending, err := repo.BeginReplacement(BindRequest{UserID: 1, Email: "new@example.com", Type: models.ExternalEmailTypePrimary})
if err != nil {
t.Fatal(err)
}
before, err := repo.FindVerifiedPrimaryByUser(1)
if err != nil || before.ID != oldBinding.ID {
t.Fatalf("old binding was not retained before verification: %+v, %v", before, err)
}
if err := repo.VerifyReplacement(2, pending.ID); err != ErrBindingOwnership {
t.Fatalf("cross-user replacement error = %v", err)
}
if err := repo.VerifyReplacement(1, pending.ID); err != nil {
t.Fatal(err)
}
after, err := repo.FindVerifiedPrimaryByUser(1)
if err != nil || after.ID != pending.ID {
t.Fatalf("new binding was not activated: %+v, %v", after, err)
}
items, err := repo.List(1)
if err != nil {
t.Fatal(err)
}
statuses := map[int]string{}
for _, item := range items {
statuses[item.ID] = item.Status
}
if statuses[oldBinding.ID] != models.ExternalEmailStatusDisabled || statuses[pending.ID] != models.ExternalEmailStatusVerified {
t.Fatalf("unexpected replacement statuses: %+v", statuses)
}
}

View File

@ -40,4 +40,4 @@
"emailCodeTokenExpireMinutes": 5,
"emailCodeHashSecret": "OKLbGcd11c1slSdAh8YgHnSTC1e3gi9drVj5W/a/eEE="
},这些配置可以在管理员的设置页面进行修改
2.将config.json进行拆分,smtp、email、ssl、plugins做成单独的文件,config.json只保留数据库、域名、端口等基础配置
2.将config.json进行拆分,smtp、email、ssl、plugins做成单独的文件,config.json只保留数据库、域名、端口等基础配置

View File

@ -0,0 +1,94 @@
.recovery-code-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
}
.recovery-code-summary > div {
display: grid;
gap: 3px;
}
.summary-label {
color: var(--pm-text-secondary);
font-size: 12px;
}
.recovery-code-description {
margin: 16px 0 0;
color: var(--pm-text-secondary);
font-size: 13px;
line-height: 1.6;
}
.recovery-code-result {
display: grid;
gap: 12px;
margin-top: 16px;
}
.result-title {
display: grid;
gap: 4px;
color: var(--el-color-warning-dark-2);
font-size: 13px;
}
.result-title span {
color: var(--pm-text-secondary);
}
.recovery-code-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.recovery-code-grid code {
padding: 9px 10px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
color: var(--pm-text-primary);
text-align: center;
user-select: all;
}
.copy-button {
justify-self: end;
}
.dialog-feedback.error {
margin: 12px 0 0;
padding: 9px 12px;
border-radius: var(--pm-radius-sm);
color: var(--el-color-danger-dark-2);
background: var(--el-color-danger-light-9);
font-size: 13px;
}
.recovery-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.recovery-dialog-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
@media (max-width: 520px) {
.recovery-code-grid {
grid-template-columns: 1fr;
}
.recovery-dialog-actions {
display: grid;
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,164 @@
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { http } from '@/utils/axios'
import {
isRecentAuthenticationCancelled,
requestWithRecentAuthentication,
} from '@/utils/recentAuthentication'
export const recoveryCodeDialogProps = {
modelValue: {
type: Boolean,
default: false,
},
user: {
type: Object,
default: null,
},
}
function assertSuccess(response, fallback) {
if (!response || response.errorNo !== 0) {
const error = new Error(response?.errorMsg || fallback)
if (typeof response?.data?.code === 'string') {
error.apiErrorCode = response.data.code
}
throw error
}
return response
}
export function useRecoveryCodeDialog(props, emit) {
const configured = ref(false)
const codes = ref([])
const generating = ref(false)
const revoking = ref(false)
const errorMessage = ref('')
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const busy = computed(() => generating.value || revoking.value)
const loadStatus = async () => {
if (!props.user?.ID) return false
errorMessage.value = ''
try {
const response = await requestWithRecentAuthentication(async () => {
const result = await http.get('/api/v1/user/security/recovery-codes', {
params: { id: props.user.ID },
})
return assertSuccess(result, '读取安全码状态失败')
})
configured.value = Boolean(response.data?.configured)
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '读取安全码状态失败'
}
return false
}
}
const generateCodes = async () => {
if (busy.value || !props.user?.ID) return false
generating.value = true
errorMessage.value = ''
try {
const response = await requestWithRecentAuthentication(async () => {
const result = await http.post('/api/v1/user/security/recovery-codes/generate', {
user_id: props.user.ID,
count: 10,
})
return assertSuccess(result, '安全码生成失败')
})
codes.value = Array.isArray(response.data?.codes) ? response.data.codes : []
if (!codes.value.length) {
throw new Error('服务器未返回安全码,请稍后重试')
}
configured.value = true
emit('changed')
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '安全码生成失败'
}
return false
} finally {
generating.value = false
}
}
const revokeCodes = async () => {
if (busy.value || !props.user?.ID) return false
revoking.value = true
errorMessage.value = ''
try {
const response = await requestWithRecentAuthentication(async () => {
const result = await http.post('/api/v1/user/security/recovery-codes/revoke', {
user_id: props.user.ID,
})
return assertSuccess(result, '安全码撤销失败')
})
configured.value = false
codes.value = []
emit('changed')
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '安全码撤销失败'
}
return false
} finally {
revoking.value = false
}
}
const copyCodes = async () => {
if (!codes.value.length) return
try {
await navigator.clipboard.writeText(codes.value.join('\n'))
ElMessage.success('安全码已复制')
} catch {
errorMessage.value = '复制失败,请手动选择安全码复制'
}
}
const reset = () => {
configured.value = Boolean(props.user?.RecoveryCodeConfigured)
codes.value = []
errorMessage.value = ''
}
const close = () => {
if (busy.value) return
visible.value = false
reset()
}
watch(
() => props.modelValue,
value => {
if (value) {
reset()
void loadStatus()
}
},
)
return {
visible,
configured,
codes,
generating,
revoking,
busy,
errorMessage,
close,
generateCodes,
revokeCodes,
copyCodes,
}
}

View File

@ -0,0 +1,91 @@
<template>
<el-dialog
:model-value="visible"
title="管理安全码"
width="min(560px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!busy"
:show-close="!busy"
class="recovery-code-dialog"
@update:model-value="value => { if (!value) close() }"
>
<div class="recovery-code-summary">
<div>
<span class="summary-label">账号</span>
<strong>{{ user?.Account || '-' }}</strong>
</div>
<el-tag :type="configured ? 'success' : 'info'" effect="plain">
{{ configured ? '已设置安全码' : '未设置安全码' }}
</el-tag>
</div>
<div v-if="codes.length" class="recovery-code-result">
<div class="result-title">
<strong>新安全码仅显示这一次</strong>
<span>请立即交给用户妥善保存,关闭弹窗后无法再次查看。</span>
</div>
<div class="recovery-code-grid">
<code v-for="code in codes" :key="code">{{ code }}</code>
</div>
<el-button class="copy-button" @click="copyCodes">复制全部安全码</el-button>
</div>
<p v-else class="recovery-code-description">
重新生成会立即撤销该用户原有的全部安全码。安全码明文不会写入用户列表或普通编辑请求。
</p>
<p v-if="errorMessage" class="dialog-feedback error" role="alert">
{{ errorMessage }}
</p>
<template #footer>
<div class="recovery-dialog-actions">
<el-button :disabled="busy" @click="close">关闭</el-button>
<el-button
v-if="configured && !codes.length"
type="danger"
plain
:loading="revoking"
:disabled="generating"
@click="revokeCodes"
>
撤销安全码
</el-button>
<el-button
type="primary"
:loading="generating"
:disabled="revoking"
@click="generateCodes"
>
{{ configured ? '重新生成安全码' : '生成安全码' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import {
recoveryCodeDialogProps,
useRecoveryCodeDialog,
} from './RecoveryCodeDialog.js'
const props = defineProps(recoveryCodeDialogProps)
const emit = defineEmits(['update:modelValue', 'changed'])
const {
visible,
configured,
codes,
generating,
revoking,
busy,
errorMessage,
close,
generateCodes,
revokeCodes,
copyCodes,
} = useRecoveryCodeDialog(props, emit)
</script>
<style scoped src="./RecoveryCodeDialog.css"></style>

View File

@ -0,0 +1,100 @@
.external-email-account {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 4px 12px;
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
font-size: 13px;
}
.external-email-account span {
color: var(--pm-text-secondary);
}
.external-email-account span:last-child {
grid-column: 1 / -1;
}
.external-email-form,
.external-email-verification {
display: grid;
gap: 4px;
}
.external-email-form :deep(.el-select) {
width: 100%;
}
.verification-target {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
color: var(--pm-text-secondary);
font-size: 13px;
}
.verification-target strong {
min-width: 0;
color: var(--pm-text-primary);
overflow-wrap: anywhere;
}
.verification-target .expired {
color: var(--el-color-danger);
}
.replacement-note {
margin: 10px 0 0;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.dialog-feedback {
margin: 10px 0 0;
padding: 9px 12px;
border-radius: var(--pm-radius-sm);
font-size: 13px;
line-height: 1.5;
}
.dialog-feedback.success {
color: var(--el-color-success-dark-2);
background: var(--el-color-success-light-9);
}
.dialog-feedback.error {
color: var(--el-color-danger-dark-2);
background: var(--el-color-danger-light-9);
}
.external-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.external-dialog-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
@media (max-width: 520px) {
.verification-target {
grid-template-columns: 1fr;
gap: 4px;
}
.external-dialog-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,242 @@
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { http } from '@/utils/axios'
import {
isRecentAuthenticationCancelled,
requestWithRecentAuthentication,
} from '@/utils/recentAuthentication'
export const userExternalEmailDialogProps = {
modelValue: {
type: Boolean,
default: false,
},
user: {
type: Object,
default: null,
},
}
const verificationCodeLength = 8
function assertSuccess(response, fallback) {
if (!response || response.errorNo !== 0) {
const error = new Error(response?.errorMsg || fallback)
if (typeof response?.data?.code === 'string') {
error.apiErrorCode = response.data.code
}
throw error
}
return response
}
export function useUserExternalEmailDialog(props, emit) {
const form = reactive({ email: '', type: 'primary', code: '' })
const challenge = reactive({ id: '', bindingId: 0, maskedEmail: '', expiresAt: '' })
const currentEmail = ref('')
const step = ref('details')
const sending = ref(false)
const verifying = ref(false)
const errorMessage = ref('')
const statusMessage = ref('')
const currentTime = ref(Date.now())
let timer = null
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const busy = computed(() => sending.value || verifying.value)
const hasChallenge = computed(() => Boolean(challenge.id && challenge.bindingId))
const remainingSeconds = computed(() => {
if (!challenge.expiresAt) return 0
const expiresAt = new Date(challenge.expiresAt).getTime()
if (!Number.isFinite(expiresAt)) return 0
return Math.max(0, Math.ceil((expiresAt - currentTime.value) / 1000))
})
const expirationText = computed(() => {
if (!hasChallenge.value) return ''
if (remainingSeconds.value <= 0) return '验证码已过期,请重新发送'
const minutes = Math.floor(remainingSeconds.value / 60)
const seconds = remainingSeconds.value % 60
return `${minutes}:${String(seconds).padStart(2, '0')} 后过期`
})
const startTimer = () => {
if (timer) return
currentTime.value = Date.now()
timer = window.setInterval(() => {
currentTime.value = Date.now()
}, 1000)
}
const stopTimer = () => {
if (!timer) return
window.clearInterval(timer)
timer = null
}
const clearMessages = () => {
errorMessage.value = ''
statusMessage.value = ''
}
const reset = () => {
Object.assign(form, { email: '', type: 'primary', code: '' })
Object.assign(challenge, { id: '', bindingId: 0, maskedEmail: '', expiresAt: '' })
currentEmail.value = ''
step.value = 'details'
clearMessages()
}
const loadCurrentEmail = async () => {
if (!props.user?.ID) return false
try {
const response = await requestWithRecentAuthentication(async () => {
const result = await http.get('/api/v1/user/security/external-email/list', {
params: { id: props.user.ID },
})
return assertSuccess(result, '读取外部邮箱失败')
})
const items = Array.isArray(response.data?.items) ? response.data.items : []
const primary = items.find(item => item.type === 'primary') || items[0]
currentEmail.value = primary?.email || ''
if (primary?.type) form.type = primary.type
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '读取外部邮箱失败'
}
return false
}
}
const sendVerificationCode = async () => {
if (busy.value || !props.user?.ID) return false
const email = form.email.trim().toLowerCase()
if (!email) {
errorMessage.value = '请输入外部邮箱地址'
return false
}
sending.value = true
clearMessages()
try {
const response = await requestWithRecentAuthentication(async () => {
const result = await http.post('/api/v1/user/security/external-email/change', {
user_id: props.user.ID,
email,
type: form.type,
})
return assertSuccess(result, '验证码发送失败')
})
if (!response.data?.challenge_id || !response.data?.binding_id) {
throw new Error('服务器返回的验证信息不完整,请稍后重试')
}
form.email = email
form.code = ''
Object.assign(challenge, {
id: response.data.challenge_id,
bindingId: response.data.binding_id,
maskedEmail: response.data.email || email,
expiresAt: response.data.expires_at || '',
})
currentTime.value = Date.now()
step.value = 'verification'
statusMessage.value = `验证码已发送到 ${challenge.maskedEmail}`
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '验证码发送失败,请稍后重试'
}
return false
} finally {
sending.value = false
}
}
const verifyAndReplace = async () => {
if (busy.value || !hasChallenge.value || !props.user?.ID) return false
const code = form.code.trim()
if (!code) {
errorMessage.value = '请输入邮箱验证码'
return false
}
if (remainingSeconds.value <= 0) {
errorMessage.value = '验证码已过期,请重新发送'
return false
}
verifying.value = true
clearMessages()
try {
const response = await requestWithRecentAuthentication(async () => {
const result = await http.post('/api/v1/user/security/external-email/verify', {
user_id: props.user.ID,
challenge_id: challenge.id,
binding_id: challenge.bindingId,
email: form.email,
code,
})
return assertSuccess(result, '外部邮箱验证失败')
})
emit('changed')
visible.value = false
reset()
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '外部邮箱验证失败,请检查验证码后重试'
}
form.code = ''
return false
} finally {
verifying.value = false
}
}
const handleCodeInput = value => {
form.code = String(value || '').replace(/\s+/g, '').slice(0, verificationCodeLength)
errorMessage.value = ''
}
const close = () => {
if (busy.value) return
visible.value = false
reset()
}
watch(
() => props.modelValue,
value => {
if (value) {
reset()
startTimer()
void loadCurrentEmail()
} else {
stopTimer()
}
},
{ immediate: true },
)
onBeforeUnmount(stopTimer)
return {
visible,
form,
step,
currentEmail,
sending,
verifying,
busy,
challenge,
errorMessage,
statusMessage,
remainingSeconds,
expirationText,
verificationCodeLength,
close,
sendVerificationCode,
verifyAndReplace,
handleCodeInput,
}
}

View File

@ -0,0 +1,142 @@
<template>
<el-dialog
:model-value="visible"
title="编辑外部邮箱"
width="min(520px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!busy"
:show-close="!busy"
class="user-external-email-dialog"
@update:model-value="value => { if (!value) close() }"
>
<div class="external-email-account">
<span>目标账号</span>
<strong>{{ user?.Account || '-' }}</strong>
<span v-if="currentEmail">当前邮箱:{{ currentEmail }}</span>
<span v-else>当前未绑定外部邮箱</span>
</div>
<el-form
v-if="step === 'details'"
:model="form"
label-position="top"
class="external-email-form"
@submit.prevent="sendVerificationCode"
>
<el-form-item label="新的外部邮箱地址" required>
<el-input
v-model="form.email"
type="email"
autocomplete="email"
placeholder="请输入可接收验证码的外部邮箱"
clearable
:disabled="sending"
@input="errorMessage = ''"
@keyup.enter="sendVerificationCode"
/>
</el-form-item>
<el-form-item label="邮箱用途" required>
<el-select v-model="form.type" aria-label="邮箱用途" :disabled="sending">
<el-option label="主要邮箱" value="primary" />
<el-option label="备用邮箱" value="backup" />
</el-select>
</el-form-item>
</el-form>
<div v-else class="external-email-verification">
<div class="verification-target">
<span>验证码已发送至</span>
<strong>{{ challenge.maskedEmail }}</strong>
<span :class="{ expired: remainingSeconds <= 0 }">{{ expirationText }}</span>
</div>
<el-form label-position="top" @submit.prevent="verifyAndReplace">
<el-form-item label="邮箱验证码" required>
<el-input
:model-value="form.code"
inputmode="numeric"
autocomplete="one-time-code"
:maxlength="verificationCodeLength"
placeholder="请输入邮箱中的验证码"
clearable
:disabled="verifying"
@update:model-value="handleCodeInput"
@keyup.enter="verifyAndReplace"
/>
</el-form-item>
</el-form>
</div>
<p class="replacement-note">
新邮箱验证成功后才会替换同用途旧邮箱,验证失败不会影响现有绑定。
</p>
<p v-if="statusMessage" class="dialog-feedback success" role="status">
{{ statusMessage }}
</p>
<p v-if="errorMessage" class="dialog-feedback error" role="alert">
{{ errorMessage }}
</p>
<template #footer>
<div class="external-dialog-actions">
<el-button :disabled="busy" @click="close">取消</el-button>
<el-button
v-if="step === 'verification'"
:loading="sending"
:disabled="verifying"
@click="sendVerificationCode"
>
重新发送
</el-button>
<el-button
v-if="step === 'details'"
type="primary"
:loading="sending"
@click="sendVerificationCode"
>
发送验证码
</el-button>
<el-button
v-else
type="primary"
:loading="verifying"
:disabled="sending"
@click="verifyAndReplace"
>
验证并保存
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import {
userExternalEmailDialogProps,
useUserExternalEmailDialog,
} from './UserExternalEmailDialog.js'
const props = defineProps(userExternalEmailDialogProps)
const emit = defineEmits(['update:modelValue', 'changed'])
const {
visible,
form,
step,
currentEmail,
sending,
verifying,
busy,
challenge,
errorMessage,
statusMessage,
remainingSeconds,
expirationText,
verificationCodeLength,
close,
sendVerificationCode,
verifyAndReplace,
handleCodeInput,
} = useUserExternalEmailDialog(props, emit)
</script>
<style scoped src="./UserExternalEmailDialog.css"></style>

View File

@ -100,6 +100,44 @@
padding: 0 8px;
}
.security-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 18px;
}
.security-edit-item {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
}
.security-edit-item > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.security-edit-item strong {
color: var(--pm-text-primary);
font-size: 13px;
}
.security-edit-item span {
color: var(--pm-text-secondary);
font-size: 12px;
}
.security-edit-item :deep(.el-button) {
width: 100%;
margin: 0;
}
.status-switch {
display: flex;
align-items: center;
@ -116,3 +154,9 @@
font-weight: 500;
color: var(--pm-text-primary);
}
@media (max-width: 520px) {
.security-edit-grid {
grid-template-columns: 1fr;
}
}

View File

@ -49,6 +49,12 @@ export function usePage() {
const userInfoDialog = ref(false)
const editModel = ref("edit")
const selectedUser = ref(null)
const recoveryCodeDialogVisible = ref(false)
const externalEmailDialogVisible = ref(false)
const editUserInfo = reactive({
"account": "",
@ -65,11 +71,15 @@ export function usePage() {
totalPage.value = res.data.total_page
if (res.data["list"]) {
userList.push(...res.data["list"])
if (selectedUser.value?.ID) {
selectedUser.value = userList.find(item => item.ID === selectedUser.value.ID) || selectedUser.value
}
}
})
}
const handleEdit = function (idx, row) {
selectedUser.value = row
editUserInfo.account = row.Account
editUserInfo.name = row.Name
editUserInfo.disabled = row.Disabled === 1
@ -80,6 +90,7 @@ export function usePage() {
}
const createUser = function () {
selectedUser.value = null
editUserInfo.account = ""
editUserInfo.name = ""
editUserInfo.disabled = false
@ -88,6 +99,20 @@ export function usePage() {
title.value = lang.newUser
userInfoDialog.value = true
}
const openRecoveryCodeDialog = function () {
if (!selectedUser.value?.ID) return
recoveryCodeDialogVisible.value = true
}
const openExternalEmailDialog = function () {
if (!selectedUser.value?.ID) return
externalEmailDialogVisible.value = true
}
const handleSecurityChanged = function () {
reflushList()
}
const submit = function () {
if (editModel.value === 'edit') {
@ -148,6 +173,12 @@ export function usePage() {
userInfoDialog,
editModel,
editUserInfo,
selectedUser,
recoveryCodeDialogVisible,
externalEmailDialogVisible,
openRecoveryCodeDialog,
openExternalEmailDialog,
handleSecurityChanged,
title,
reflushList,
handleEdit,

View File

@ -111,6 +111,28 @@
/>
</el-form-item>
<div v-if="editModel === 'edit'" class="security-edit-grid">
<div class="security-edit-item">
<div>
<strong>安全码</strong>
<span>{{ recoveryCodeMeta(selectedUser?.RecoveryCodeConfigured).label }}</span>
</div>
<el-button @click="openRecoveryCodeDialog">管理安全码</el-button>
</div>
<div class="security-edit-item">
<div>
<strong>外部邮箱</strong>
<span>
{{ externalEmailMeta(
selectedUser?.ExternalEmailEnabled,
selectedUser?.ExternalEmailConfigured
).label }}
</span>
</div>
<el-button @click="openExternalEmailDialog">编辑外部邮箱</el-button>
</div>
</div>
<el-form-item>
<div class="status-switch">
<span class="switch-label">{{ lang.status }}</span>
@ -133,11 +155,24 @@
</span>
</template>
</el-dialog>
<RecoveryCodeDialog
v-model="recoveryCodeDialogVisible"
:user="selectedUser"
@changed="handleSecurityChanged"
/>
<UserExternalEmailDialog
v-model="externalEmailDialogVisible"
:user="selectedUser"
@changed="handleSecurityChanged"
/>
</div>
</template>
<script setup>
import { usePage, Plus } from "./UserManagement.js";
import RecoveryCodeDialog from "./RecoveryCodeDialog/RecoveryCodeDialog.vue";
import UserExternalEmailDialog from "./UserExternalEmailDialog/UserExternalEmailDialog.vue";
const {
lang,
@ -152,6 +187,12 @@ const {
submit,
editModel,
editUserInfo,
selectedUser,
recoveryCodeDialogVisible,
externalEmailDialogVisible,
openRecoveryCodeDialog,
openExternalEmailDialog,
handleSecurityChanged,
roleMeta,
recoveryCodeMeta,
externalEmailMeta,

View File

@ -44,6 +44,28 @@ test('用户管理页面可编译并展示权限与安全状态列', () => {
assert.match(source, /scope\.row\.RecoveryCodeConfigured/)
assert.match(source, /scope\.row\.ExternalEmailEnabled/)
assert.match(source, /scope\.row\.ExternalEmailConfigured/)
assert.match(source, /管理安全码/)
assert.match(source, /编辑外部邮箱/)
assert.match(source, /RecoveryCodeDialog/)
assert.match(source, /UserExternalEmailDialog/)
})
test('用户安全信息使用独立弹窗并调用固定管理接口', () => {
for (const component of [
'RecoveryCodeDialog/RecoveryCodeDialog.vue',
'UserExternalEmailDialog/UserExternalEmailDialog.vue',
]) {
const { descriptor, errors } = parse(read(component))
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: component, inlineTemplate: true }))
}
const recoverySource = read('RecoveryCodeDialog/RecoveryCodeDialog.js')
assert.match(recoverySource, /\/api\/v1\/user\/security\/recovery-codes\/generate/)
assert.match(recoverySource, /shown_once|codes/)
const externalSource = read('UserExternalEmailDialog/UserExternalEmailDialog.js')
assert.match(externalSource, /\/api\/v1\/user\/security\/external-email\/change/)
assert.match(externalSource, /\/api\/v1\/user\/security\/external-email\/verify/)
assert.doesNotMatch(externalSource, /\/api\/v1\/security\/external-email\/bind/)
})
test('用户管理样式可编译并保持紧凑表格布局', () => {