完善登录页分角色密码重置流程并保留安全中心入口
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 18:20:53 +08:00
parent 1aac3fc2ec
commit e408287dd4
14 changed files with 952 additions and 50 deletions

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

View 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")
}
}

View File

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

View 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
}

View 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")
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 }
}

View File

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

View File

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

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