This commit is contained in:
parent
1aac3fc2ec
commit
e408287dd4
85
backend/controllers/public_recovery_flow.go
Normal file
85
backend/controllers/public_recovery_flow.go
Normal file
@ -0,0 +1,85 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/services/recovery"
|
||||
"git.pcux.cn/xuans/Smail/services/verification"
|
||||
"git.pcux.cn/xuans/Smail/utils/password"
|
||||
)
|
||||
|
||||
// 新恢复入口不依赖登录 Cookie,每一步由短期一次性令牌关联。
|
||||
func PublicPasswordRecoveryFlow(w http.ResponseWriter, req *http.Request) {
|
||||
fail := func(status int, message string) {
|
||||
writePublicRecoveryJSON(w, status, map[string]string{"message": message})
|
||||
}
|
||||
if !PublicMailRecoveryEnabled() || req == nil || req.Method != http.MethodPost {
|
||||
fail(http.StatusBadRequest, "密码重置功能未启用")
|
||||
return
|
||||
}
|
||||
limiter := emailRecoveryBeginIPAttempts
|
||||
key := limiter.ipKey(req)
|
||||
if limiter.blocked(key) || emailRecoveryFailureIPAttempts.blocked(emailRecoveryFailureIPAttempts.ipKey(req)) {
|
||||
fail(http.StatusTooManyRequests, "请求过于频繁,请稍后重试")
|
||||
return
|
||||
}
|
||||
limiter.recordFailure(key)
|
||||
var input struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Token string `json:"token"`
|
||||
Method string `json:"method"`
|
||||
Code string `json:"code"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
fail(http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
service := currentSecurityRuntime().recovery
|
||||
if service == nil {
|
||||
fail(http.StatusServiceUnavailable, "重置服务暂时不可用")
|
||||
return
|
||||
}
|
||||
var result any
|
||||
var err error
|
||||
switch strings.TrimSuffix(req.URL.Path, "/") {
|
||||
case "/rest/api/recovery/begin":
|
||||
result, err = service.IdentifyPublicFlow(input.Identifier)
|
||||
case "/rest/api/recovery/send-code":
|
||||
err = service.SendPublicFlowCode(input.Token, remoteIP(req))
|
||||
result = map[string]any{"sent": true, "retry_after": 60}
|
||||
case "/rest/api/recovery/verify":
|
||||
var token string
|
||||
token, err = service.VerifyPublicFlow(input.Token, input.Method, input.Code, remoteIP(req))
|
||||
result = map[string]string{"token": token}
|
||||
case "/rest/api/recovery/reset":
|
||||
err = service.CompletePublicFlow(input.Token, input.NewPassword)
|
||||
result = map[string]bool{"reset": true}
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, password.ErrBusy) || errors.Is(err, recovery.ErrUnavailable) {
|
||||
fail(http.StatusServiceUnavailable, "重置服务暂时不可用,请稍后重试")
|
||||
} else if errors.Is(err, verification.ErrRateLimited) {
|
||||
fail(http.StatusTooManyRequests, "验证码发送过于频繁,请稍后重试")
|
||||
} else if strings.HasSuffix(req.URL.Path, "/send-code") {
|
||||
fail(http.StatusBadRequest, "验证码未发送,请检查邮箱绑定或稍后重试")
|
||||
} else if strings.HasSuffix(req.URL.Path, "/begin") {
|
||||
fail(http.StatusBadRequest, "账号不存在、已停用或无法使用此通道")
|
||||
} else if errors.Is(err, recovery.ErrInvalidNewPassword) {
|
||||
fail(http.StatusBadRequest, "新密码长度必须为 8-12 字节")
|
||||
} else {
|
||||
emailRecoveryFailureIPAttempts.recordFailure(emailRecoveryFailureIPAttempts.ipKey(req))
|
||||
fail(http.StatusBadRequest, "验证未通过或流程已失效,请核对信息或重新开始")
|
||||
}
|
||||
return
|
||||
}
|
||||
writePublicRecoveryJSON(w, http.StatusOK, result)
|
||||
}
|
||||
58
backend/controllers/public_recovery_flow_test.go
Normal file
58
backend/controllers/public_recovery_flow_test.go
Normal file
@ -0,0 +1,58 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublicRecoveryFlowHTTP(t *testing.T) {
|
||||
enablePublicRecoveryForTest(t)
|
||||
s, _, _, user, super := newPublicSecurityFixture(t)
|
||||
previous := currentSecurityRuntime()
|
||||
ConfigureSecurityRuntime(nil, nil, s, nil)
|
||||
oldBegin, oldFailure := emailRecoveryBeginIPAttempts, emailRecoveryFailureIPAttempts
|
||||
emailRecoveryBeginIPAttempts, emailRecoveryFailureIPAttempts = newLoginLimiter(100), newLoginLimiter(100)
|
||||
t.Cleanup(func() {
|
||||
ConfigureSecurityRuntime(previous.verification, previous.external, previous.recovery, previous.credentials)
|
||||
emailRecoveryBeginIPAttempts, emailRecoveryFailureIPAttempts = oldBegin, oldFailure
|
||||
})
|
||||
invoke := func(endpoint, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodPost, "https://example.com/rest/api/recovery/"+endpoint, strings.NewReader(body))
|
||||
r.RemoteAddr = "192.0.2.70:12345"
|
||||
w := httptest.NewRecorder()
|
||||
PublicPasswordRecoveryFlow(w, r)
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("recovery response cached")
|
||||
}
|
||||
return w.Code, data
|
||||
}
|
||||
status, data := invoke("begin", `{"identifier":"`+super.Account+`"}`)
|
||||
if status != 200 || data["role"] != "super_admin" || data["token"] != nil {
|
||||
t.Fatalf("super response: %v", data)
|
||||
}
|
||||
status, data = invoke("begin", `{"identifier":"`+user.Account+`"}`)
|
||||
if status != 200 || data["email"] != "a***@example.com" {
|
||||
t.Fatalf("identity response: %v", data)
|
||||
}
|
||||
token := data["token"].(string)
|
||||
if status, _ := invoke("reset", `{"token":"`+token+`","new_password":"NewPass123"}`); status == 200 {
|
||||
t.Fatal("unverified reset accepted")
|
||||
}
|
||||
if status, _ := invoke("verify", `{"token":"`+token+`","method":"email","code":"12345678"}`); status == 200 {
|
||||
t.Fatal("unsent code accepted")
|
||||
}
|
||||
if status, _ := invoke("begin", `{"identifier":"missing"}`); status == 200 {
|
||||
t.Fatal("missing user accepted")
|
||||
}
|
||||
if status, _ := invoke("begin", `{"identifier":"alice","role":"super_admin"}`); status == 200 {
|
||||
t.Fatal("client role accepted")
|
||||
}
|
||||
}
|
||||
@ -62,11 +62,13 @@ func (h *publicRecoveryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
case "/rest", "/rest/":
|
||||
h.servePage(w, r)
|
||||
case "/rest/api/recovery/begin":
|
||||
h.serveAPI(w, r, controllers.BeginPublicLinkPasswordRecovery)
|
||||
h.serveAPI(w, r, controllers.PublicPasswordRecoveryFlow)
|
||||
case "/rest/api/recovery/send-code", "/rest/api/recovery/verify", "/rest/api/recovery/reset":
|
||||
h.serveAPI(w, r, controllers.PublicPasswordRecoveryFlow)
|
||||
case "/rest/api/recovery/requirements":
|
||||
h.serveAPI(w, r, controllers.PublicLinkPasswordRecoveryRequirements)
|
||||
case "/rest/api/recovery/complete":
|
||||
h.serveAPI(w, r, controllers.CompletePublicLinkPasswordRecovery)
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
if isPublicRecoveryAssetPath(path) {
|
||||
h.serveStatic(w, r)
|
||||
|
||||
227
backend/services/recovery/public_flow.go
Normal file
227
backend/services/recovery/public_flow.go
Normal file
@ -0,0 +1,227 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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/verification"
|
||||
"git.pcux.cn/xuans/Smail/utils/password"
|
||||
)
|
||||
|
||||
type PublicFlow struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Role access.Role `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Methods []string `json:"methods"`
|
||||
}
|
||||
|
||||
// IdentifyPublicFlow 只公开脱敏邮箱,验证方式由数据库角色和已配置凭据决定。
|
||||
func (s *Service) IdentifyPublicFlow(identifier string) (*PublicFlow, error) {
|
||||
if s == nil || s.engine == nil || s.external == nil || s.credentials == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
account, err := auth.NormalizeIdentifier(identifier)
|
||||
if err != nil || account == "" {
|
||||
return nil, ErrRecoveryDenied
|
||||
}
|
||||
var user models.User
|
||||
found, err := s.engine.Where("account = ? AND disabled = 0", account).Get(&user)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrRecoveryDenied
|
||||
}
|
||||
result := &PublicFlow{Role: user.EffectiveRole(), Methods: []string{}}
|
||||
if result.Role == access.RoleSuperAdmin {
|
||||
return result, nil
|
||||
}
|
||||
item := &publicTicket{Flow: true, UserID: user.ID, Account: user.Account, Role: user.EffectiveRole(), SessionVersion: user.SessionVersion, SecurityVersion: user.SecurityVersion}
|
||||
bindings, err := s.external.List(user.ID)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
var binding *models.ExternalEmail
|
||||
for i := range bindings {
|
||||
candidate := &bindings[i]
|
||||
if candidate.Status != models.ExternalEmailStatusVerified {
|
||||
continue
|
||||
}
|
||||
if candidate.EmailType == models.ExternalEmailTypePrimary {
|
||||
binding = candidate
|
||||
break
|
||||
}
|
||||
if candidate.EmailType == models.ExternalEmailTypeBackup && binding == nil {
|
||||
binding = candidate
|
||||
}
|
||||
}
|
||||
if binding != nil {
|
||||
email, err := s.external.Decrypt(*binding)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
result.Email = string([]rune(parts[0])[0]) + "***@" + parts[1]
|
||||
item.CredentialID, item.CredentialVersion = binding.ID, binding.CredentialVersion
|
||||
if s.verification != nil {
|
||||
result.Methods = append(result.Methods, "email")
|
||||
}
|
||||
}
|
||||
if result.Role == access.RoleUser {
|
||||
configured, err := s.credentials.HasValidRecoveryCode(user.ID)
|
||||
if err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if configured {
|
||||
result.Methods = append(result.Methods, "security_code")
|
||||
}
|
||||
}
|
||||
if len(result.Methods) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
token, err := newTicketID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cleanupLocked(s.now())
|
||||
if len(s.publicTickets) >= maxRecoveryTickets {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
item.ExpiresAt = s.now().Add(decoyChallengeTTL)
|
||||
s.publicTickets[ticketKey(token)] = item
|
||||
result.Token = token
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// lockPublicFlow 占用当前步骤,防止验证码发送、核销和密码提交并发复用。
|
||||
func (s *Service) lockPublicFlow(token string) (*publicTicket, error) {
|
||||
if s == nil || strings.TrimSpace(token) == "" {
|
||||
return nil, ErrRecoveryDenied
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cleanupLocked(s.now())
|
||||
item := s.publicTickets[ticketKey(token)]
|
||||
if item == nil || !item.Flow || item.Verified || item.Busy {
|
||||
return nil, ErrRecoveryExpired
|
||||
}
|
||||
item.Busy = true
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) releasePublicFlow(token string, item *publicTicket) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.publicTickets[ticketKey(token)] == item {
|
||||
item.Busy = false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publicFlowIdentity(item *publicTicket) (string, error) {
|
||||
var user models.User
|
||||
found, err := s.engine.ID(item.UserID).Get(&user)
|
||||
if err != nil {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
if !found || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin || user.EffectiveRole() != item.Role || user.SecurityVersion != item.SecurityVersion || user.SessionVersion != item.SessionVersion {
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
if item.CredentialID == 0 {
|
||||
return "", nil
|
||||
}
|
||||
binding, err := s.external.FindVerifiedByID(user.ID, item.CredentialID)
|
||||
if err != nil || binding.CredentialVersion != item.CredentialVersion {
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
return s.external.Decrypt(*binding)
|
||||
}
|
||||
|
||||
func (s *Service) SendPublicFlowCode(token, remoteIP string) error {
|
||||
item, err := s.lockPublicFlow(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.releasePublicFlow(token, item)
|
||||
email, err := s.publicFlowIdentity(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if email == "" || s.verification == nil {
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
challenge, err := s.verification.Issue(verification.IssueRequest{
|
||||
Purpose: verification.PurposePasswordRecover, UserID: item.UserID, Account: item.Account,
|
||||
Email: email, ResourceID: item.CredentialID, CredentialVersion: item.CredentialVersion,
|
||||
BrowserTransaction: token,
|
||||
}, remoteIP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
item.ChallengeID = challenge.ID
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyPublicFlow(token, method, code, remoteIP string) (string, error) {
|
||||
item, err := s.lockPublicFlow(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer s.releasePublicFlow(token, item)
|
||||
email, err := s.publicFlowIdentity(item)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 提前生成下一步令牌,避免随机源失败导致已核销安全码无法继续。
|
||||
next, err := newTicketID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch method {
|
||||
case "email":
|
||||
if email == "" || item.ChallengeID == "" || s.verification == nil {
|
||||
err = ErrRecoveryDenied
|
||||
} else {
|
||||
err = s.verification.VerifyBoundWithContext(item.ChallengeID, item.Account, email, strings.TrimSpace(code), verification.PurposePasswordRecover, item.CredentialID, remoteIP, token)
|
||||
}
|
||||
case "security_code":
|
||||
if item.Role != access.RoleUser {
|
||||
err = ErrRecoveryDenied
|
||||
} else {
|
||||
err = s.credentials.ConsumeRecoveryCode(item.UserID, code)
|
||||
}
|
||||
default:
|
||||
err = ErrRecoveryDenied
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, password.ErrBusy) {
|
||||
return "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
item.Attempts++
|
||||
if item.Attempts >= maxPublicRecoveryCodeAttempts {
|
||||
delete(s.publicTickets, ticketKey(token))
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.publicTickets[ticketKey(token)] != item || !s.now().Before(item.ExpiresAt) {
|
||||
return "", ErrRecoveryExpired
|
||||
}
|
||||
delete(s.publicTickets, ticketKey(token))
|
||||
item.Verified, item.Method, item.Busy = true, method, false
|
||||
item.ExpiresAt = s.now().Add(authorizationTTL)
|
||||
s.publicTickets[ticketKey(next)] = item
|
||||
return next, nil
|
||||
}
|
||||
253
backend/services/recovery/public_flow_test.go
Normal file
253
backend/services/recovery/public_flow_test.go
Normal file
@ -0,0 +1,253 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/verification"
|
||||
"git.pcux.cn/xuans/Smail/utils/password"
|
||||
)
|
||||
|
||||
func TestPublicFlowEmailResetAndRoleBoundary(t *testing.T) {
|
||||
for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} {
|
||||
t.Run(string(role), func(t *testing.T) {
|
||||
s, verify, engine, user := newRecoveryFixture(t)
|
||||
user.Role = string(role)
|
||||
if _, err := engine.ID(user.ID).Cols("role").Update(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var code string
|
||||
verify.Send = func(_ verification.Challenge, value string) error { code = value; return nil }
|
||||
flow, err := s.IdentifyPublicFlow(user.Account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role == access.RoleSuperAdmin {
|
||||
if flow.Token != "" || len(flow.Methods) != 0 || flow.Email != "" {
|
||||
t.Fatal("super administrator received public recovery access")
|
||||
}
|
||||
return
|
||||
}
|
||||
if flow.Email != "a***@example.com" || flow.Role != role {
|
||||
t.Fatalf("unexpected identity: %#v", flow)
|
||||
}
|
||||
if err := s.CompletePublicFlow(flow.Token, "NewPass123"); err == nil {
|
||||
t.Fatal("reset before verification")
|
||||
}
|
||||
if err := s.CompletePublicLink(flow.Token, "NewPass123", ""); err == nil {
|
||||
t.Fatal("flow accepted by legacy completion")
|
||||
}
|
||||
if err := s.SendPublicFlowCode(flow.Token, "192.0.2.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(code) != 8 {
|
||||
t.Fatal("email code not delivered")
|
||||
}
|
||||
if _, err := s.VerifyPublicFlow(flow.Token, "email", "bad-code", "192.0.2.1"); err == nil {
|
||||
t.Fatal("invalid code accepted")
|
||||
}
|
||||
ticket, err := s.VerifyPublicFlow(flow.Token, "email", code, "192.0.2.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ticket == flow.Token {
|
||||
t.Fatal("verification token not rotated")
|
||||
}
|
||||
if _, err := s.VerifyPublicFlow(flow.Token, "email", code, "192.0.2.1"); err == nil {
|
||||
t.Fatal("verification replay accepted")
|
||||
}
|
||||
if err := s.CompletePublicFlow(ticket, "short"); err == nil {
|
||||
t.Fatal("invalid password accepted")
|
||||
}
|
||||
if err := s.CompletePublicFlow(ticket, "NewPass123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CompletePublicFlow(ticket, "OtherPass12"); err == nil {
|
||||
t.Fatal("ticket replay accepted")
|
||||
}
|
||||
var updated models.User
|
||||
if _, err := engine.ID(user.ID).Get(&updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := password.Check(updated.Password, "NewPass123"); err != nil || !ok {
|
||||
t.Fatal("new password not applied", err)
|
||||
}
|
||||
if updated.SessionVersion != user.SessionVersion+1 {
|
||||
t.Fatal("old sessions still valid")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicFlowSecurityCodeRoleAndFailureLimits(t *testing.T) {
|
||||
for _, role := range []access.Role{access.RoleAdmin, access.RoleUser} {
|
||||
t.Run(string(role), func(t *testing.T) {
|
||||
s, _, engine, user := newRecoveryFixture(t)
|
||||
user.Role = string(role)
|
||||
if _, err := engine.ID(user.ID).Cols("role").Update(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes, err := s.credentials.GenerateRecoveryCodes(user.ID, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flow, err := s.IdentifyPublicFlow(user.Account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ticket, err := s.VerifyPublicFlow(flow.Token, "security_code", codes[0], "192.0.2.2")
|
||||
if role == access.RoleAdmin {
|
||||
if err == nil || strings.Contains(strings.Join(flow.Methods, ","), "security_code") {
|
||||
t.Fatal("admin used security code instead of email")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CompletePublicFlow(ticket, "NewPass123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
flow, err = s.IdentifyPublicFlow(user.Account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < maxPublicRecoveryCodeAttempts; i++ {
|
||||
if _, err := s.VerifyPublicFlow(flow.Token, "security_code", "invalid", "192.0.2.2"); err == nil {
|
||||
t.Fatal("bad code accepted")
|
||||
}
|
||||
}
|
||||
if err := s.SendPublicFlowCode(flow.Token, "192.0.2.2"); err == nil {
|
||||
t.Fatal("exhausted flow usable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicFlowRejectsExpiredAndChangedAccount(t *testing.T) {
|
||||
for _, scenario := range []string{"expired", "disabled", "promoted", "security_version", "email_disabled"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, verify, engine, user := newRecoveryFixture(t)
|
||||
verify.Send = func(verification.Challenge, string) error { t.Fatal("sent code after invalidation"); return nil }
|
||||
flow, err := s.IdentifyPublicFlow(user.Account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch scenario {
|
||||
case "expired":
|
||||
now := s.now()
|
||||
s.now = func() time.Time { return now.Add(20 * time.Minute) }
|
||||
case "disabled":
|
||||
user.Disabled = 1
|
||||
_, err = engine.ID(user.ID).Cols("disabled").Update(user)
|
||||
case "promoted":
|
||||
user.Role = string(access.RoleSuperAdmin)
|
||||
_, err = engine.ID(user.ID).Cols("role").Update(user)
|
||||
case "security_version":
|
||||
user.SecurityVersion++
|
||||
_, err = engine.ID(user.ID).Cols("security_version").Update(user)
|
||||
case "email_disabled":
|
||||
binding, lookup := s.external.FindVerifiedPrimaryByUser(user.ID)
|
||||
if lookup != nil {
|
||||
t.Fatal(lookup)
|
||||
}
|
||||
err = s.external.Disable(user.ID, binding.ID)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SendPublicFlowCode(flow.Token, "192.0.2.3"); err == nil {
|
||||
t.Fatal("invalidated flow accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicFlowRejectsCrossFlowCodeAndConcurrentUse(t *testing.T) {
|
||||
s, verify, _, user := newRecoveryFixture(t)
|
||||
first, err := s.IdentifyPublicFlow(user.Account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := s.IdentifyPublicFlow(user.Account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
var code string
|
||||
verify.Send = func(_ verification.Challenge, value string) error {
|
||||
code = value
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.SendPublicFlowCode(first.Token, "192.0.2.4") }()
|
||||
<-started
|
||||
_, concurrentErr := s.VerifyPublicFlow(first.Token, "email", code, "192.0.2.4")
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if concurrentErr == nil {
|
||||
t.Fatal("verification accepted while delivery was in progress")
|
||||
}
|
||||
// 即使另一流程取得挑战编号,也不能跨流程使用邮件验证码。
|
||||
s.publicTickets[ticketKey(second.Token)].ChallengeID = s.publicTickets[ticketKey(first.Token)].ChallengeID
|
||||
if _, err := s.VerifyPublicFlow(second.Token, "email", code, "192.0.2.4"); err == nil {
|
||||
t.Fatal("cross-flow email code accepted")
|
||||
}
|
||||
if _, err := s.VerifyPublicFlow(first.Token, "email", code, "192.0.2.4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicFlowRechecksAccountAfterVerification(t *testing.T) {
|
||||
for _, scenario := range []string{"expired", "disabled", "promoted", "security_version", "email_disabled"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, verify, engine, user := newRecoveryFixture(t)
|
||||
var code string
|
||||
verify.Send = func(_ verification.Challenge, value string) error { code = value; return nil }
|
||||
flow, err := s.IdentifyPublicFlow(user.Account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SendPublicFlowCode(flow.Token, "192.0.2.5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := s.VerifyPublicFlow(flow.Token, "email", code, "192.0.2.5")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch scenario {
|
||||
case "expired":
|
||||
now := s.now()
|
||||
s.now = func() time.Time { return now.Add(20 * time.Minute) }
|
||||
case "disabled":
|
||||
user.Disabled = 1
|
||||
_, err = engine.ID(user.ID).Cols("disabled").Update(user)
|
||||
case "promoted":
|
||||
user.Role = string(access.RoleSuperAdmin)
|
||||
_, err = engine.ID(user.ID).Cols("role").Update(user)
|
||||
case "security_version":
|
||||
user.SecurityVersion++
|
||||
_, err = engine.ID(user.ID).Cols("security_version").Update(user)
|
||||
case "email_disabled":
|
||||
binding, lookup := s.external.FindVerifiedPrimaryByUser(user.ID)
|
||||
if lookup != nil {
|
||||
t.Fatal(lookup)
|
||||
}
|
||||
err = s.external.Disable(user.ID, binding.ID)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CompletePublicFlow(token, "NewPass123"); err == nil {
|
||||
t.Fatal("password changed after account or verification invalidation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,10 @@ type PublicLinkDelivery func(email, token string) error
|
||||
|
||||
// publicTicket 只保存在进程内存中,map key 为令牌摘要而非令牌明文。
|
||||
type publicTicket struct {
|
||||
Flow bool
|
||||
Verified bool
|
||||
Method string
|
||||
ChallengeID string
|
||||
UserID int
|
||||
Account string
|
||||
Role access.Role
|
||||
@ -115,6 +119,14 @@ func (s *Service) PublicLinkRequiresRecoveryCode(token string) (bool, error) {
|
||||
|
||||
// CompletePublicLink 在同一数据库事务中完成管理员恢复码核销、密码更新和旧凭据撤销。
|
||||
func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) error {
|
||||
return s.completePublicLink(token, newPassword, recoveryCode, false)
|
||||
}
|
||||
|
||||
func (s *Service) CompletePublicFlow(token, newPassword string) error {
|
||||
return s.completePublicLink(token, newPassword, "", true)
|
||||
}
|
||||
|
||||
func (s *Service) completePublicLink(token, newPassword, recoveryCode string, flow bool) error {
|
||||
defer clearString(&newPassword)
|
||||
defer clearString(&recoveryCode)
|
||||
if s == nil || s.engine == nil || s.external == nil || s.credentials == nil || strings.TrimSpace(token) == "" {
|
||||
@ -135,6 +147,10 @@ func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) er
|
||||
s.mu.Unlock()
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
if item.Flow != flow || (flow && (!item.Verified || (item.Role == access.RoleAdmin && item.Method != "email"))) {
|
||||
s.mu.Unlock()
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
item.Busy = true
|
||||
s.mu.Unlock()
|
||||
|
||||
@ -159,13 +175,15 @@ func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) er
|
||||
consume = true
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
binding, err := s.external.FindVerifiedByID(item.UserID, item.CredentialID)
|
||||
if err != nil || binding.CredentialVersion != item.CredentialVersion {
|
||||
consume = true
|
||||
return ErrRecoveryDenied
|
||||
if !flow || item.Method == "email" {
|
||||
binding, err := s.external.FindVerifiedByID(item.UserID, item.CredentialID)
|
||||
if err != nil || binding.CredentialVersion != item.CredentialVersion {
|
||||
consume = true
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
}
|
||||
|
||||
if user.EffectiveRole() == access.RoleAdmin {
|
||||
if !flow && user.EffectiveRole() == access.RoleAdmin {
|
||||
if strings.TrimSpace(recoveryCode) == "" {
|
||||
if s.recordPublicRecoveryCodeFailure(key, item) {
|
||||
consume = true
|
||||
@ -202,7 +220,7 @@ func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) er
|
||||
_ = session.Rollback()
|
||||
}
|
||||
}()
|
||||
if user.EffectiveRole() == access.RoleAdmin {
|
||||
if !flow && user.EffectiveRole() == access.RoleAdmin {
|
||||
if err := s.credentials.ConsumeRecoveryCodeWithSession(session, user.ID, recoveryCode); err != nil {
|
||||
if errors.Is(err, password.ErrBusy) {
|
||||
return err
|
||||
|
||||
@ -399,6 +399,9 @@ func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext stri
|
||||
if err != nil || !has || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin || user.SessionVersion != item.SessionVersion {
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
if user.EffectiveRole() == access.RoleAdmin && item.Method != "email_admin" {
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
if item.Method == "email" || item.Method == "email_admin" {
|
||||
if user.EffectiveRole() == access.RoleAdmin && item.Method != "email_admin" {
|
||||
return ErrRecoveryDenied
|
||||
|
||||
@ -48,6 +48,10 @@ const router = createRouter({
|
||||
{
|
||||
path: "/forgot-password",
|
||||
name: "forgot-password",
|
||||
beforeEnter: () => {
|
||||
window.location.assign('/public-password-recovery');
|
||||
return false;
|
||||
},
|
||||
component: () => import("../views/PasswordRecovery/PasswordRecovery.vue")
|
||||
},
|
||||
{
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
@ -65,7 +65,7 @@ h2 {
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(23px, 4vw, 28px);
|
||||
font-size: 28px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@ -74,6 +74,45 @@ h1 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.public-recovery-form strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.recovery-methods {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 12px 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.recovery-methods label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recovery-methods input {
|
||||
width: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.recovery-code-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recovery-code-row input {
|
||||
flex: 1 1 150px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recovery-code-row button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.public-recovery-copy,
|
||||
.public-recovery-complete p {
|
||||
margin: 0 0 10px;
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
</header>
|
||||
|
||||
<form v-if="page === 'request'" class="public-recovery-form" @submit.prevent="requestReset">
|
||||
<p class="public-recovery-copy">输入账号或完整邮箱。若该账号允许外部邮箱恢复,系统会向已验证的安全邮箱发送一次性恢复链接。</p>
|
||||
<label for="public-recovery-identifier">账号或完整邮箱</label>
|
||||
<input
|
||||
id="public-recovery-identifier"
|
||||
@ -21,17 +20,60 @@
|
||||
:disabled="loading"
|
||||
required
|
||||
/>
|
||||
<p v-if="submitted" class="public-recovery-status success" role="status">请求已受理。若信息匹配,请检查已验证的外部安全邮箱。</p>
|
||||
<p v-if="errorMessage" class="public-recovery-status error" role="alert">{{ errorMessage }}</p>
|
||||
<div class="public-recovery-actions">
|
||||
<button type="button" class="text-button" :disabled="loading" @click="goLogin">返回登录</button>
|
||||
<button type="submit" :disabled="loading || !form.identifier.trim()">{{ loading ? '正在提交…' : '发送恢复链接' }}</button>
|
||||
<button type="submit" :disabled="loading || !form.identifier.trim()">{{ loading ? '正在查询' : '下一步' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-else-if="page === 'reset'" class="public-recovery-form" aria-live="polite">
|
||||
<p class="public-recovery-copy">请设置新的登录密码。为保护账号安全,此链接仅可使用一次。</p>
|
||||
<p v-if="loading" class="public-recovery-status">正在验证恢复链接…</p>
|
||||
<template v-else-if="available">
|
||||
<section v-else-if="page === 'blocked'" class="public-recovery-form" aria-live="polite">
|
||||
<strong>{{ roleLabel }}</strong>
|
||||
<p class="public-recovery-status">超级管理员无法通过登录页通道重置密码。已登录时可在安全中心重置;无法登录时请使用部署端本机重置。</p>
|
||||
<div class="public-recovery-actions">
|
||||
<button type="button" class="text-button" @click="goRequest">更换账号</button>
|
||||
<button type="button" @click="goLogin">返回登录</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form v-else-if="page === 'verify'" class="public-recovery-form" @submit.prevent="verifyIdentity">
|
||||
<strong>{{ form.identifier }} · {{ roleLabel }}</strong>
|
||||
<p class="public-recovery-copy">绑定邮箱:{{ identity.email || '未绑定已验证的安全邮箱' }}</p>
|
||||
<template v-if="identity.methods.length">
|
||||
<fieldset v-if="identity.methods.length > 1" class="recovery-methods" :disabled="loading">
|
||||
<legend>验证方式</legend>
|
||||
<label><input v-model="form.method" type="radio" value="email" />邮箱验证码</label>
|
||||
<label><input v-model="form.method" type="radio" value="security_code" />安全码</label>
|
||||
</fieldset>
|
||||
<label for="public-recovery-code">{{ form.method === 'email' ? '邮箱验证码' : '安全码' }}</label>
|
||||
<div class="recovery-code-row">
|
||||
<input
|
||||
id="public-recovery-code"
|
||||
v-model="form.code"
|
||||
:type="form.method === 'email' ? 'text' : 'password'"
|
||||
:inputmode="form.method === 'email' ? 'numeric' : 'text'"
|
||||
:maxlength="form.method === 'email' ? 8 : 64"
|
||||
:placeholder="form.method === 'email' ? '输入 8 位邮件验证码' : '输入未使用的安全码'"
|
||||
autocomplete="one-time-code"
|
||||
:disabled="loading"
|
||||
required
|
||||
/>
|
||||
<button v-if="form.method === 'email'" type="button" :disabled="loading || countdown > 0" @click="sendCode">
|
||||
{{ countdown ? `${countdown} 秒后重发` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="public-recovery-status">此账号尚未配置可用的重置方式,请登录安全中心或联系管理员处理。</p>
|
||||
<p v-if="notice" class="public-recovery-status success" role="status">{{ notice }}</p>
|
||||
<p v-if="errorMessage" class="public-recovery-status error" role="alert">{{ errorMessage }}</p>
|
||||
<div class="public-recovery-actions">
|
||||
<button type="button" class="text-button" :disabled="loading" @click="goRequest">更换账号</button>
|
||||
<button v-if="identity.methods.length" type="submit" :disabled="loading || !canVerify">{{ loading ? '正在验证' : '验证并继续' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else-if="page === 'reset'" class="public-recovery-form" @submit.prevent="completeReset">
|
||||
<p class="public-recovery-status success">身份验证已通过</p>
|
||||
<label for="public-recovery-password">新密码</label>
|
||||
<input
|
||||
id="public-recovery-password"
|
||||
@ -53,30 +95,12 @@
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
<p v-if="passwordMismatch" class="public-recovery-status error">两次输入的新密码不一致。</p>
|
||||
<template v-if="requiresRecoveryCode">
|
||||
<label for="public-recovery-code">离线恢复码</label>
|
||||
<input
|
||||
id="public-recovery-code"
|
||||
v-model="form.recoveryCode"
|
||||
type="password"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="64"
|
||||
placeholder="输入未使用的离线恢复码"
|
||||
/>
|
||||
</template>
|
||||
<p v-if="errorMessage" class="public-recovery-status error">{{ errorMessage }}</p>
|
||||
<div class="public-recovery-actions">
|
||||
<button type="button" class="text-button" :disabled="loading" @click="goRequest">重新申请</button>
|
||||
<button type="button" :disabled="loading || !canSubmitReset" @click="completeReset">{{ loading ? '正在重置…' : '确认重置' }}</button>
|
||||
<button type="submit" :disabled="loading || !canSubmitReset">{{ loading ? '正在重置' : '确认重置' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="public-recovery-status error">{{ errorMessage || '恢复链接无效或已失效,请重新申请。' }}</p>
|
||||
<div class="public-recovery-actions single-action">
|
||||
<button type="button" @click="goRequest">重新申请恢复链接</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<section v-else class="public-recovery-complete" aria-live="polite">
|
||||
<div class="public-recovery-success-icon" aria-hidden="true">✓</div>
|
||||
@ -89,14 +113,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePage } from './PublicPasswordReset.js'
|
||||
import { usePage } from './useRecoveryFlow.js'
|
||||
|
||||
const {
|
||||
page,
|
||||
loading,
|
||||
submitted,
|
||||
available,
|
||||
requiresRecoveryCode,
|
||||
identity,
|
||||
roleLabel,
|
||||
notice,
|
||||
countdown,
|
||||
canVerify,
|
||||
sendCode,
|
||||
verifyIdentity,
|
||||
errorMessage,
|
||||
form,
|
||||
passwordMismatch,
|
||||
|
||||
110
frontend/src/views/PublicPasswordReset/useRecoveryFlow.js
Normal file
110
frontend/src/views/PublicPasswordReset/useRecoveryFlow.js
Normal file
@ -0,0 +1,110 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { isValidPasswordLength } from '@/utils/password'
|
||||
import { postPublicRecovery } from '@/utils/publicRecoveryHttp'
|
||||
|
||||
export function usePage() {
|
||||
const page = ref('request')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const notice = ref('')
|
||||
const identity = ref(null)
|
||||
const token = ref('')
|
||||
const countdown = ref(0)
|
||||
let timer
|
||||
let version = 0
|
||||
const form = reactive({ identifier: '', method: 'email', code: '', newPassword: '', confirmPassword: '' })
|
||||
const roleLabel = computed(() => ({ super_admin: '超级管理员', admin: '管理员', user: '普通用户' })[identity.value?.role] || '')
|
||||
const passwordMismatch = computed(() => !!form.confirmPassword && form.newPassword !== form.confirmPassword)
|
||||
const canSubmitReset = computed(() => isValidPasswordLength(form.newPassword) && form.newPassword === form.confirmPassword)
|
||||
const canVerify = computed(() => form.method === 'email' ? /^\d{8}$/.test(form.code.trim()) : !!form.code.trim())
|
||||
watch(() => form.method, () => { form.code = ''; errorMessage.value = '' })
|
||||
|
||||
const request = async (endpoint, payload) => {
|
||||
const result = await postPublicRecovery(endpoint, payload)
|
||||
if (!result.ok) throw new Error(result.data?.message || (result.status >= 500 ? '服务暂时不可用,请稍后重试' : '操作失败,请重试'))
|
||||
return result.data
|
||||
}
|
||||
|
||||
const run = async action => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
const current = version
|
||||
try { await action(() => current === version) } catch (error) {
|
||||
if (current === version) errorMessage.value = error?.message || '网络异常,请稍后重试'
|
||||
} finally { if (current === version) loading.value = false }
|
||||
}
|
||||
|
||||
const requestReset = () => {
|
||||
if (!form.identifier.trim()) return
|
||||
return run(async active => {
|
||||
const data = await request('begin', { identifier: form.identifier.trim() })
|
||||
if (!active()) return
|
||||
identity.value = data
|
||||
token.value = data.token || ''
|
||||
form.method = data.methods?.[0] || 'email'
|
||||
page.value = data.role === 'super_admin' ? 'blocked' : 'verify'
|
||||
})
|
||||
}
|
||||
|
||||
const sendCode = () => {
|
||||
if (countdown.value || !token.value) return
|
||||
return run(async active => {
|
||||
const data = await request('send-code', { token: token.value })
|
||||
if (!active() || !data.sent) return
|
||||
notice.value = '验证码已发送,请查看绑定邮箱'
|
||||
form.code = ''
|
||||
const deadline = Date.now() + 60000
|
||||
countdown.value = 60
|
||||
clearInterval(timer)
|
||||
timer = setInterval(() => {
|
||||
countdown.value = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))
|
||||
if (!countdown.value) clearInterval(timer)
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
const verifyIdentity = () => {
|
||||
if (!canVerify.value) return
|
||||
return run(async active => {
|
||||
const data = await request('verify', { token: token.value, method: form.method, code: form.code.trim() })
|
||||
if (!active()) return
|
||||
if (!data.token) throw new Error('未取得重置凭证,请重新验证')
|
||||
token.value = data.token
|
||||
form.code = ''
|
||||
notice.value = ''
|
||||
page.value = 'reset'
|
||||
})
|
||||
}
|
||||
|
||||
const completeReset = () => {
|
||||
if (!canSubmitReset.value || page.value !== 'reset') return
|
||||
return run(async active => {
|
||||
const data = await request('reset', { token: token.value, new_password: form.newPassword })
|
||||
if (!active()) return
|
||||
if (!data.reset) throw new Error('无法确认重置结果,请重试')
|
||||
token.value = ''
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
page.value = 'success'
|
||||
})
|
||||
}
|
||||
|
||||
const goRequest = () => {
|
||||
version++
|
||||
clearInterval(timer)
|
||||
countdown.value = 0
|
||||
loading.value = false
|
||||
token.value = ''
|
||||
identity.value = null
|
||||
errorMessage.value = ''
|
||||
notice.value = ''
|
||||
form.code = ''
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
page.value = 'request'
|
||||
}
|
||||
const goLogin = () => window.location.assign(document.querySelector('#app')?.dataset.loginUrl || '/#/login')
|
||||
onBeforeUnmount(goRequest)
|
||||
return { page, loading, errorMessage, notice, identity, countdown, form, roleLabel, passwordMismatch, canSubmitReset, canVerify, requestReset, sendCode, verifyIdentity, completeReset, goRequest, goLogin }
|
||||
}
|
||||
@ -2,11 +2,11 @@
|
||||
<AuthLayout
|
||||
brand-caption="超级管理员密码重置"
|
||||
intro-title="重置超级管理员密码"
|
||||
intro-description="超级管理员密码不支持通过网页恢复,只能由部署端本机安全通道完成。"
|
||||
intro-description="超级管理员无法通过登录页通道重置密码。已登录时可在安全中心重置,无法登录时请使用部署端本机重置。"
|
||||
>
|
||||
<template #intro-extra>
|
||||
<el-alert
|
||||
title="为避免远程接管风险,网页端不会收集或验证超级管理员重置凭据。"
|
||||
title="登录页找回通道不收集或验证超级管理员重置凭据。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@ -18,10 +18,10 @@
|
||||
<div class="security-notice">
|
||||
<span class="notice-icon" aria-hidden="true">!</span>
|
||||
<div>
|
||||
<h2 id="recovery-title">请使用本机部署端工具</h2>
|
||||
<h2 id="recovery-title">安全中心或本机部署重置</h2>
|
||||
<p>
|
||||
超级管理员重置必须在部署服务器本机发起,并由部署管理员进行交互式确认。
|
||||
普通登录页、忘记密码页和其他 Web API 均不能开启或完成该流程。
|
||||
已登录时,请在安全中心通过已绑定安全邮箱验证后设置新密码。
|
||||
无法登录时,请在部署服务器本机发起重置,并进行交互式确认。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,4 +69,4 @@ import { usePage } from './Recovery.js'
|
||||
const { goLogin } = usePage()
|
||||
</script>
|
||||
|
||||
<style scoped src="./Recovery.css"></style>
|
||||
<style scoped src="./Recovery.css"></style>
|
||||
|
||||
@ -7,10 +7,10 @@ import { computed, reactive, ref } from './vue.mjs'
|
||||
const script = readFileSync(new URL('../../frontend/src/views/PublicPasswordReset/PublicPasswordReset.js', import.meta.url), 'utf8')
|
||||
const template = readFileSync(new URL('../../frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('匿名恢复页仅在后端明确确认链接可用时显示密码表单', () => {
|
||||
test('旧链接模块保留兼容代码,公开页面改用分步验证入口', () => {
|
||||
assert.match(script, /!result\.ok \|\| result\.data\?\.available !== true/)
|
||||
assert.match(template, /v-else-if="available"/)
|
||||
assert.match(template, /恢复链接无效或已失效,请重新申请。/)
|
||||
assert.match(template, /useRecoveryFlow\.js/)
|
||||
assert.match(template, /page === 'verify'/)
|
||||
})
|
||||
|
||||
const createPage = compileFunction(
|
||||
|
||||
75
test/frontend/recovery-flow.test.mjs
Normal file
75
test/frontend/recovery-flow.test.mjs
Normal file
@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { compileFunction } from 'node:vm'
|
||||
import { computed, reactive, ref } from './vue.mjs'
|
||||
|
||||
const script = readFileSync(new URL('../../frontend/src/views/PublicPasswordReset/useRecoveryFlow.js', import.meta.url), 'utf8')
|
||||
const createPage = compileFunction(
|
||||
script.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()',
|
||||
['computed', 'reactive', 'ref', 'watch', 'onBeforeUnmount', 'isValidPasswordLength', 'postPublicRecovery', 'clearInterval', 'setInterval']
|
||||
)
|
||||
|
||||
function fixture(t) {
|
||||
let respond = async () => ({ ok: true, data: { role: 'user', token: 'flow', email: 'a***@example.com', methods: ['email', 'security_code'] } })
|
||||
const requests = []
|
||||
const page = createPage(computed, reactive, ref, () => {}, fn => t.after(fn), value => value.length >= 8 && value.length <= 12,
|
||||
async (endpoint, payload) => { requests.push({ endpoint, payload }); return respond(endpoint, payload) }, () => {}, () => 1)
|
||||
return { page, requests, respond: fn => { respond = fn } }
|
||||
}
|
||||
|
||||
test('识别账号不会发送邮件,邮件验证通过后才可提交密码', async t => {
|
||||
const { page, requests, respond } = fixture(t)
|
||||
page.form.identifier = 'alice'
|
||||
await page.requestReset()
|
||||
assert.equal(page.page.value, 'verify')
|
||||
assert.deepEqual(requests.map(item => item.endpoint), ['begin'])
|
||||
respond(async () => ({ ok: true, data: { sent: true } }))
|
||||
await page.sendCode()
|
||||
assert.equal(page.countdown.value, 60)
|
||||
respond(async () => ({ ok: false, status: 400, data: { message: '验证码错误' } }))
|
||||
page.form.code = '12345678'
|
||||
await page.verifyIdentity()
|
||||
assert.equal(page.page.value, 'verify')
|
||||
respond(async () => ({ ok: true, data: { token: 'verified' } }))
|
||||
await page.verifyIdentity()
|
||||
assert.equal(page.page.value, 'reset')
|
||||
assert.equal(page.form.code, '')
|
||||
Object.assign(page.form, { newPassword: 'NewPass123', confirmPassword: 'NewPass123' })
|
||||
respond(async () => ({ ok: false, status: 503, data: { message: '稍后重试' } }))
|
||||
await page.completeReset()
|
||||
assert.equal(page.form.newPassword, 'NewPass123')
|
||||
assert.equal(page.page.value, 'reset')
|
||||
respond(async () => ({ ok: true, data: { reset: true } }))
|
||||
await page.completeReset()
|
||||
assert.equal(requests.at(-1).payload.token, 'verified')
|
||||
assert.equal(page.page.value, 'success')
|
||||
assert.equal(page.form.newPassword, '')
|
||||
})
|
||||
|
||||
test('超级管理员进入禁止页面,发送失败不显示发送成功', async t => {
|
||||
const { page, respond } = fixture(t)
|
||||
page.form.identifier = 'admin'
|
||||
respond(async () => ({ ok: true, data: { role: 'super_admin', methods: [] } }))
|
||||
await page.requestReset()
|
||||
assert.equal(page.page.value, 'blocked')
|
||||
page.goRequest()
|
||||
respond(async () => ({ ok: false, status: 503, data: { message: '服务不可用' } }))
|
||||
await page.requestReset()
|
||||
assert.equal(page.page.value, 'request')
|
||||
assert.equal(page.notice.value, '')
|
||||
assert.match(page.errorMessage.value, /服务不可用/)
|
||||
})
|
||||
|
||||
test('更换账号后迟到的响应不会推进重置步骤', async t => {
|
||||
const { page, respond } = fixture(t)
|
||||
let finish
|
||||
respond(() => new Promise(resolve => { finish = resolve }))
|
||||
page.form.identifier = 'alice'
|
||||
const pending = page.requestReset()
|
||||
page.goRequest()
|
||||
finish({ ok: true, data: { role: 'user', token: 'old', methods: ['email'] } })
|
||||
await pending
|
||||
assert.equal(page.page.value, 'request')
|
||||
assert.equal(page.identity.value, null)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user