This commit is contained in:
parent
3456b9d481
commit
31ee54942a
299
backend/controllers/user_security.go
Normal file
299
backend/controllers/user_security.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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只保留数据库、域名、端口等基础配置
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('用户管理样式可编译并保持紧凑表格布局', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user