diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 4bf63b6..2757f6f 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -2,6 +2,7 @@ package controllers import ( "crypto/rand" + "encoding/json" "encoding/hex" "errors" "net" @@ -42,6 +43,15 @@ func PublicRecoveryEnabled() bool { return hasConfiguredRecoveryHost(hosts...) } +// PublicMailRecoveryEnabled 仅判断主邮件域名下匿名恢复链接入口是否可用。 +// 旧主站恢复流程仍使用 PublicRecoveryEnabled,以保持原有兼容行为。 +func PublicMailRecoveryEnabled() bool { + if !config.IsSetupReady() || !config.CurrentEmailPolicy().ExternalRecoveryEnabled { + return false + } + return hasConfiguredRecoveryHost(config.CurrentMailDomains().Primary) +} + func hasConfiguredRecoveryHost(values ...string) bool { for _, value := range values { value = strings.TrimSpace(value) @@ -645,6 +655,64 @@ func CompletePasswordRecovery(ctx *context.Context, w http.ResponseWriter, req * response.NewSuccessResponse(map[string]any{"reset": true}).FPrint(w) } +// BeginPublicLinkPasswordRecovery 是主邮件域名 /rest/ 的无会话恢复入口。 +// 所有申请结果统一返回 accepted,避免泄露账户、邮箱绑定和角色状态。 +func BeginPublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request) { + writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"accepted": true}) + if !PublicMailRecoveryEnabled() || req == nil { + return + } + var input struct { + Identifier string `json:"identifier"` + } + decoder := json.NewDecoder(req.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + return + } + runtime := currentSecurityRuntime() + if runtime.recovery == nil { + return + } + // 投递和账户状态异常均保持同样的受理响应,且恢复服务不记录令牌。 + _ = runtime.recovery.BeginPublicLink(input.Identifier) +} + +// CompletePublicLinkPasswordRecovery 仅消费独立链接票据;不使用主站会话、CSRF 或恢复上下文。 +func CompletePublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request) { + if !PublicMailRecoveryEnabled() || req == nil { + writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false}) + return + } + var input struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` + RecoveryCode string `json:"recovery_code"` + } + decoder := json.NewDecoder(req.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false}) + return + } + runtime := currentSecurityRuntime() + if runtime.recovery == nil || runtime.recovery.CompletePublicLink(input.Token, input.NewPassword, input.RecoveryCode) != nil { + writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false}) + return + } + writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"reset": true}) +} + +func writePublicRecoveryJSON(w http.ResponseWriter, status int, value any) { + if w == nil { + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + func recoveryBrowserContext(req *http.Request, w http.ResponseWriter, create bool) (string, bool) { if req == nil || w == nil { return "", false diff --git a/backend/res_init/init.go b/backend/res_init/init.go index c8a4005..40f3fed 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -345,6 +345,7 @@ func startVerificationRuntime() error { return fmt.Errorf("start recovery service: %w", err) } recover.SetWebAuthnVerifier(webAuthnRuntimeService) + recover.SetPublicLinkDelivery(newPublicRecoveryLinkDelivery()) controllers.ConfigureSecurityRuntime(service, external, recover, credential) credentialService = credential verificationRuntime = runtime diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go index 8996a13..2324141 100644 --- a/backend/res_init/verification_delivery.go +++ b/backend/res_init/verification_delivery.go @@ -3,6 +3,7 @@ package res_init import ( "context" "fmt" + "net/url" "strings" "git.pcux.cn/xuans/Smail/config" @@ -38,13 +39,32 @@ func newVerificationDeliveryWithDependencies( return fmt.Errorf("load verification mail settings: %w", err) } if smtpRelayRequested(runtimeSettings) { - return deliverRelay(runtimeSettings, challenge, code) + relayErr := deliverRelay(runtimeSettings, challenge, code) + if relayErr == nil { + return nil + } + if !smtpRelayComplete(runtimeSettings) { + return relayErr + } + if projectErr := deliverProject(challenge, code); projectErr != nil { + return fmt.Errorf("SMTP 中继投递失败且项目自身投递失败: %w", projectErr) + } + return nil } return deliverProject(challenge, code) } } +func smtpRelayComplete(settings config.SMTPRuntimeSettings) bool { + relay := settings.Relay + if strings.TrimSpace(relay.RelayHost) == "" || relay.RelayPort < 1 || relay.RelayPort > 65535 || + strings.TrimSpace(relay.Security) == "" || strings.TrimSpace(relay.From) == "" { + return false + } + return (strings.TrimSpace(relay.Username) == "") == (strings.TrimSpace(settings.Password) == "") +} + func smtpRelayRequested(settings config.SMTPRuntimeSettings) bool { relay := settings.Relay return strings.TrimSpace(relay.RelayHost) != "" || @@ -117,3 +137,46 @@ func deliverVerificationWithProjectMail(challenge verification.Challenge, code s } return nil } + +// newPublicRecoveryLinkDelivery 固定使用项目自身邮件服务,不能走外部 SMTP 中继。 +func newPublicRecoveryLinkDelivery() recovery.PublicLinkDelivery { + return func(email, token string) error { + domain := strings.TrimSpace(config.CurrentMailDomains().Primary) + if domain == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(token) == "" { + return verification.ErrProjectMailUnavailable + } + link, err := publicRecoveryLink(domain, token) + if err != nil { + return err + } + messageID, err := parsemail.GenerateMsgID(domain) + if err != nil { + return fmt.Errorf("generate public recovery message id: %w", err) + } + sender := &parsemail.User{Name: "Smail 安全中心", EmailAddress: "no-reply@" + domain} + message := &parsemail.Email{ + From: sender, Sender: sender, To: []*parsemail.User{{EmailAddress: email}}, + Subject: "Smail 密码恢复链接", + Text: []byte("您好,您正在恢复 Smail 密码。请在五分钟内打开以下一次性链接完成重置:\r\n" + link + "\r\n\r\n如果并非本人操作,请忽略此邮件。"), + MsgID: messageID, + } + ctx := &appcontext.Context{Context: context.Background(), Values: make(map[string]any)} + if deliveryErr, _ := send.SendTriggered(ctx, message, "系统密码恢复链接"); deliveryErr != nil { + return fmt.Errorf("%w: project mail delivery failed", verification.ErrMailDeliveryFailed) + } + return nil + } +} + +func publicRecoveryLink(domain, token string) (string, error) { + parsed, err := url.Parse("https://" + strings.TrimSpace(domain)) + if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("invalid primary mail domain") + } + if port := config.CurrentHTTPConfig().HTTPSPort; port > 0 && port != 443 && parsed.Port() == "" { + parsed.Host = parsed.Hostname() + fmt.Sprintf(":%d", port) + } + parsed.Path = "/rest/" + parsed.Fragment = "/reset-password/" + token + return parsed.String(), nil +} diff --git a/backend/services/credentials/service.go b/backend/services/credentials/service.go index e014894..adeb714 100644 --- a/backend/services/credentials/service.go +++ b/backend/services/credentials/service.go @@ -33,9 +33,9 @@ const ( // Service 统一管理密保问题和离线恢复码。 type Service struct { - engine *xorm.Engine - mu sync.Mutex - now func() time.Time + engine *xorm.Engine + mu sync.Mutex + now func() time.Time vaultRoot string } @@ -209,8 +209,35 @@ func (s *Service) ConsumeRecoveryCode(userID int, code string) error { } s.mu.Lock() defer s.mu.Unlock() + return s.consumeRecoveryCode(s.engine, userID, code) +} + +// ConsumeRecoveryCodeWithSession 在调用方已开启的事务中原子核销一枚恢复码。 +// 密码重置等复合安全操作必须使用此方法,避免密码更新失败时恢复码被提前消耗。 +func (s *Service) ConsumeRecoveryCodeWithSession(session *xorm.Session, userID int, code string) error { + if s == nil || s.engine == nil || session == nil { + return ErrUnavailable + } + if userID <= 0 { + return ErrInvalidUser + } + code = strings.TrimSpace(strings.ToUpper(code)) + if len(code) < 12 || len(code) > 64 { + return ErrInvalidCode + } + s.mu.Lock() + defer s.mu.Unlock() + return s.consumeRecoveryCode(session, userID, code) +} + +type recoveryCodeSession interface { + Where(queryInterface interface{}, args ...interface{}) *xorm.Session + ID(id interface{}) *xorm.Session +} + +func (s *Service) consumeRecoveryCode(session recoveryCodeSession, userID int, code string) error { var items []models.RecoveryCode - if err := s.engine.Where("user_id = ? AND used_at IS NULL", userID).Find(&items); err != nil { + if err := session.Where("user_id = ? AND used_at IS NULL", userID).Find(&items); err != nil { return fmt.Errorf("load recovery codes: %w", err) } for _, item := range items { @@ -218,7 +245,7 @@ func (s *Service) ConsumeRecoveryCode(userID int, code string) error { continue } now := s.now() - affected, err := s.engine.ID(item.ID).Where("used_at IS NULL").Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}) + affected, err := session.ID(item.ID).Where("used_at IS NULL").Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}) if err != nil { return fmt.Errorf("consume recovery code: %w", err) } diff --git a/backend/services/recovery/public_link.go b/backend/services/recovery/public_link.go new file mode 100644 index 0000000..2ac8174 --- /dev/null +++ b/backend/services/recovery/public_link.go @@ -0,0 +1,225 @@ +package recovery + +import ( + "fmt" + "strings" + "time" + + "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/utils/password" +) + +const maxPublicRecoveryCodeAttempts = 5 + +// PublicLinkDelivery 投递匿名恢复入口的一次性链接令牌。 +// token 仅在调用期间存在,调用方不得写入日志或持久化。 +type PublicLinkDelivery func(email, token string) error + +// publicTicket 只保存在进程内存中,map key 为令牌摘要而非令牌明文。 +type publicTicket struct { + UserID int + Account string + Role access.Role + SessionVersion int + SecurityVersion int + CredentialID int + CredentialVersion int + ExpiresAt time.Time + Attempts int + Busy bool +} + +// BeginPublicLink 为非超级管理员生成独立匿名恢复链接。 +// 为避免账户枚举,调用方应将所有错误映射为同一已受理响应。 +func (s *Service) BeginPublicLink(identifier string) error { + if s == nil || s.engine == nil || s.external == nil { + return ErrUnavailable + } + account, err := auth.NormalizeIdentifier(identifier) + if err != nil || account == "" { + return ErrRecoveryDenied + } + var user models.User + has, err := s.engine.Where("account = ? AND disabled = 0", account).Get(&user) + if err != nil || !has || user.EffectiveRole() == access.RoleSuperAdmin { + return ErrRecoveryDenied + } + binding, err := s.external.FindVerifiedPrimaryByUser(user.ID) + if err != nil { + return ErrRecoveryDenied + } + email, err := s.external.Decrypt(*binding) + if err != nil || strings.TrimSpace(email) == "" { + return ErrRecoveryDenied + } + + token, err := newTicketID() + if err != nil { + return err + } + key := ticketKey(token) + s.mu.Lock() + s.cleanupLocked(s.now()) + delivery := s.publicDelivery + if delivery == nil || len(s.publicTickets) >= maxRecoveryTickets { + s.mu.Unlock() + return ErrUnavailable + } + for ticketKey, item := range s.publicTickets { + if item.UserID == user.ID { + delete(s.publicTickets, ticketKey) + } + } + s.publicTickets[key] = &publicTicket{ + UserID: user.ID, Account: user.Account, Role: user.EffectiveRole(), + SessionVersion: user.SessionVersion, SecurityVersion: user.SecurityVersion, + CredentialID: binding.ID, CredentialVersion: binding.CredentialVersion, + ExpiresAt: s.now().Add(authorizationTTL), + } + s.mu.Unlock() + + if err := delivery(email, token); err != nil { + s.mu.Lock() + delete(s.publicTickets, key) + s.mu.Unlock() + return fmt.Errorf("deliver public recovery link: %w", err) + } + return nil +} + +// CompletePublicLink 在同一数据库事务中完成管理员恢复码核销、密码更新和旧凭据撤销。 +func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) error { + defer clearString(&newPassword) + defer clearString(&recoveryCode) + if s == nil || s.engine == nil || s.external == nil || s.credentials == nil || strings.TrimSpace(token) == "" { + return ErrInvalidRequest + } + if password.ValidateUserPassword(newPassword) != nil { + return ErrInvalidNewPassword + } + key := ticketKey(token) + s.mu.Lock() + s.cleanupLocked(s.now()) + item, ok := s.publicTickets[key] + if !ok { + s.mu.Unlock() + return ErrRecoveryExpired + } + if item.Busy { + s.mu.Unlock() + return ErrRecoveryDenied + } + item.Busy = true + s.mu.Unlock() + + consume := false + defer func() { + s.mu.Lock() + defer s.mu.Unlock() + if s.publicTickets[key] != item { + return + } + if consume || !s.now().Before(item.ExpiresAt) { + delete(s.publicTickets, key) + return + } + item.Busy = false + }() + + var user models.User + has, err := s.engine.ID(item.UserID).Get(&user) + if err != nil || !has || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin || + user.EffectiveRole() != item.Role || user.SessionVersion != item.SessionVersion || user.SecurityVersion != item.SecurityVersion { + 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 user.EffectiveRole() == access.RoleAdmin { + if strings.TrimSpace(recoveryCode) == "" { + if s.recordPublicRecoveryCodeFailure(key, item) { + consume = true + } + return ErrRecoveryDenied + } + } + + hash, err := auth.HashPassword(newPassword) + if err != nil { + return err + } + defer clearString(&hash) + version := user.SessionVersion + 1 + if version <= 0 { + version = 1 + } + securityVersion := user.SecurityVersion + 1 + if securityVersion <= 0 { + securityVersion = 1 + } + now := s.now().UTC() + session := s.engine.NewSession() + if session == nil { + return ErrUnavailable + } + defer session.Close() + if err := session.Begin(); err != nil { + return err + } + committed := false + defer func() { + if !committed { + _ = session.Rollback() + } + }() + if user.EffectiveRole() == access.RoleAdmin { + if err := s.credentials.ConsumeRecoveryCodeWithSession(session, user.ID, recoveryCode); err != nil { + if s.recordPublicRecoveryCodeFailure(key, item) { + consume = true + } + return ErrRecoveryDenied + } + } + if affected, err := session.ID(item.UserID).Where("session_version = ? AND security_version = ? AND disabled = 0", item.SessionVersion, item.SecurityVersion). + Cols("password", "session_version", "security_version", "must_change_password", "password_changed_at").Update(&models.User{ + Password: hash, SessionVersion: version, SecurityVersion: securityVersion, MustChangePassword: 0, PasswordChangedAt: &now, + }); err != nil { + return err + } else if affected != 1 { + consume = true + return ErrRecoveryDenied + } + if err := s.credentials.RevokeAllWithSession(session, item.UserID); err != nil { + return err + } + if err := session.Commit(); err != nil { + return err + } + committed = true + consume = true + s.InvalidateUser(item.UserID) + if s.verification != nil && s.verification.Store != nil { + _ = s.verification.Store.InvalidateAccount(user.Account) + } + s.invalidateSecurityState(item.UserID, user.Account) + return nil +} + +func (s *Service) recordPublicRecoveryCodeFailure(key string, item *publicTicket) bool { + if s == nil || item == nil { + return true + } + s.mu.Lock() + defer s.mu.Unlock() + if s.publicTickets[key] != item { + return true + } + item.Attempts++ + return item.Attempts >= maxPublicRecoveryCodeAttempts +} diff --git a/backend/services/recovery/service.go b/backend/services/recovery/service.go index 401c577..2975747 100644 --- a/backend/services/recovery/service.go +++ b/backend/services/recovery/service.go @@ -47,17 +47,19 @@ type WebAuthnVerifier interface { } type Service struct { - engine *xorm.Engine - verification *verification.Service - external *externalemail.Repository - credentials *credentials.Service - webauthn WebAuthnVerifier - invalidate func(int, string) - mu sync.Mutex - tickets map[string]ticket - emailPending map[string]*emailContinuation - decoys map[string]decoyChallenge - now func() time.Time + engine *xorm.Engine + verification *verification.Service + external *externalemail.Repository + credentials *credentials.Service + webauthn WebAuthnVerifier + invalidate func(int, string) + mu sync.Mutex + tickets map[string]ticket + emailPending map[string]*emailContinuation + publicTickets map[string]*publicTicket + publicDelivery PublicLinkDelivery + decoys map[string]decoyChallenge + now func() time.Time } type ticket struct { @@ -93,7 +95,7 @@ func NewService(engine *xorm.Engine, verify *verification.Service, external *ext } return &Service{ engine: engine, verification: verify, external: external, credentials: credential, - tickets: make(map[string]ticket), emailPending: make(map[string]*emailContinuation), + tickets: make(map[string]ticket), emailPending: make(map[string]*emailContinuation), publicTickets: make(map[string]*publicTicket), decoys: make(map[string]decoyChallenge), now: time.Now, }, nil } @@ -116,6 +118,17 @@ func (s *Service) SetSecurityStateInvalidator(invalidator func(int, string)) { s.invalidate = invalidator } +// SetPublicLinkDelivery 设置独立公网恢复链接的系统邮件投递器。 +// 投递器只接收目的邮箱和一次性令牌,不得记录令牌或完整链接。 +func (s *Service) SetPublicLinkDelivery(delivery PublicLinkDelivery) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.publicDelivery = delivery +} + func (s *Service) currentWebAuthnVerifier() WebAuthnVerifier { if s == nil { return nil @@ -471,6 +484,11 @@ func (s *Service) cleanupLocked(now time.Time) { delete(s.emailPending, id) } } + for id, item := range s.publicTickets { + if !now.Before(item.ExpiresAt) { + delete(s.publicTickets, id) + } + } } func (s *Service) issueDecoyChallenge(account, email string) (string, error) { @@ -569,6 +587,11 @@ func (s *Service) InvalidateUser(userID int) { delete(s.emailPending, id) } } + for id, item := range s.publicTickets { + if item.UserID == userID { + delete(s.publicTickets, id) + } + } } func (s *Service) invalidateTicket(ticketID string) {