保存匿名恢复服务开发检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-16 11:51:19 +08:00
parent b6886efc6f
commit 5604b81ff8
6 changed files with 425 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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