diff --git a/backend/controllers/public_recovery_flow.go b/backend/controllers/public_recovery_flow.go new file mode 100644 index 0000000..8d238d9 --- /dev/null +++ b/backend/controllers/public_recovery_flow.go @@ -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) +} diff --git a/backend/controllers/public_recovery_flow_test.go b/backend/controllers/public_recovery_flow_test.go new file mode 100644 index 0000000..66c45f8 --- /dev/null +++ b/backend/controllers/public_recovery_flow_test.go @@ -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") + } +} diff --git a/backend/listen/http_server/public_recovery.go b/backend/listen/http_server/public_recovery.go index 1baa492..42b0d57 100644 --- a/backend/listen/http_server/public_recovery.go +++ b/backend/listen/http_server/public_recovery.go @@ -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) diff --git a/backend/services/recovery/public_flow.go b/backend/services/recovery/public_flow.go new file mode 100644 index 0000000..c49852a --- /dev/null +++ b/backend/services/recovery/public_flow.go @@ -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 +} diff --git a/backend/services/recovery/public_flow_test.go b/backend/services/recovery/public_flow_test.go new file mode 100644 index 0000000..5e631f4 --- /dev/null +++ b/backend/services/recovery/public_flow_test.go @@ -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") + } + }) + } +} diff --git a/backend/services/recovery/public_link.go b/backend/services/recovery/public_link.go index 27ca4d5..946aa1d 100644 --- a/backend/services/recovery/public_link.go +++ b/backend/services/recovery/public_link.go @@ -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 diff --git a/backend/services/recovery/service.go b/backend/services/recovery/service.go index 9706e92..2484e89 100644 --- a/backend/services/recovery/service.go +++ b/backend/services/recovery/service.go @@ -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 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 48fc69b..4f6d1cc 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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") }, { diff --git a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.css b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.css index f0aaba1..9b1613d 100644 --- a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.css +++ b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.css @@ -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; diff --git a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue index 9a63fc5..b6533d9 100644 --- a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue +++ b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue @@ -10,7 +10,6 @@
-

输入账号或完整邮箱。若该账号允许外部邮箱恢复,系统会向已验证的安全邮箱发送一次性恢复链接。

-

请求已受理。若信息匹配,请检查已验证的外部安全邮箱。

+
- +
-
-

请设置新的登录密码。为保护账号安全,此链接仅可使用一次。

-

正在验证恢复链接…

- - -
+
@@ -89,14 +113,18 @@ - \ No newline at end of file + diff --git a/test/frontend/public-password-reset.test.mjs b/test/frontend/public-password-reset.test.mjs index 52abd0c..b1880fb 100644 --- a/test/frontend/public-password-reset.test.mjs +++ b/test/frontend/public-password-reset.test.mjs @@ -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( diff --git a/test/frontend/recovery-flow.test.mjs b/test/frontend/recovery-flow.test.mjs new file mode 100644 index 0000000..35b9e37 --- /dev/null +++ b/test/frontend/recovery-flow.test.mjs @@ -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) +})