保存认证容量保护整改检查点待远程复审
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-17 12:00:45 +08:00
parent 0177de8a9f
commit b4f5f51c0e
27 changed files with 721 additions and 53 deletions

View File

@ -202,6 +202,9 @@ func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
user, err := auth.AuthenticateIdentifier(identifier, reqData.Password)
if err != nil {
if writePasswordBusy(w, err) {
return
}
loginPairAttempts.recordFailure(pairKey)
loginAccountAttempts.recordFailure(accountKey)
loginIPAttempts.recordFailure(ipKey)

View File

@ -0,0 +1,22 @@
package controllers
import (
"errors"
"net/http"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/utils/password"
)
// 计算繁忙不是凭据错误,不计入失败次数,也不触发会话失效。
func writePasswordBusy(w http.ResponseWriter, err error) bool {
if !errors.Is(err, password.ErrBusy) {
return false
}
w.Header().Set("Retry-After", "1")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
response.NewErrorResponse(http.StatusServiceUnavailable, "认证服务繁忙,请稍后重试", response.ConfigErrorData{Code: "AUTH_BUSY"}).FPrint(w)
return true
}

View File

@ -0,0 +1,86 @@
package controllers
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/internal/passwordtest"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/session"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/password"
"github.com/alexedwards/scs/v2"
)
func TestPasswordBusyHTTPDoesNotLockAccounts(t *testing.T) {
oldConfig, oldDB, oldSession := config.Instance, db.Instance, session.Instance
oldPair, oldAccount, oldIP := loginPairAttempts, loginAccountAttempts, loginIPAttempts
oldReauthUser, oldReauthIP := reauthUserAttempts, reauthIPAttempts
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "capacity.db")}
if err := db.Init(""); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Instance.Close()
config.Instance, db.Instance, session.Instance = oldConfig, oldDB, oldSession
loginPairAttempts, loginAccountAttempts, loginIPAttempts = oldPair, oldAccount, oldIP
reauthUserAttempts, reauthIPAttempts = oldReauthUser, oldReauthIP
})
loginPairAttempts, loginAccountAttempts, loginIPAttempts = newLoginLimiter(1), newLoginLimiter(1), newLoginLimiter(1)
reauthUserAttempts, reauthIPAttempts = newLoginLimiter(1), newLoginLimiter(1)
session.Instance = scs.New()
user := models.User{Account: "capacity-user", SessionVersion: 1}
if err := auth.SetPassword(&user, "CorrectP12"); err != nil {
t.Fatal(err)
}
if _, err := db.Instance.Insert(&user); err != nil {
t.Fatal(err)
}
release := passwordtest.Hold(t)
cases := []struct {
handler func(*appcontext.Context, http.ResponseWriter, *http.Request)
body string
}{
{Login, `{"account":"capacity-user","password":"CorrectP12"}`},
{Login, `{"account":"missing-user","password":"CorrectP12"}`},
{ReauthenticateCurrentUser, `{"password":"CorrectP12"}`},
{ModifyPassword, `{"current_password":"CorrectP12","password":"NewPass123"}`},
}
for index := 0; index < 12; index++ {
for _, item := range cases {
request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/api/test", strings.NewReader(item.body))
request.RemoteAddr = "192.0.2.71:1234"
recorder := httptest.NewRecorder()
ctx := &appcontext.Context{Context: request.Context(), UserID: user.ID, UserAccount: user.Account}
item.handler(ctx, recorder, request)
if recorder.Code != http.StatusServiceUnavailable || recorder.Header().Get("Retry-After") != "1" || !strings.Contains(recorder.Body.String(), "AUTH_BUSY") {
t.Fatalf("认证繁忙响应不正确:%d %s", recorder.Code, recorder.Body.String())
}
}
}
for _, limiter := range []*loginLimiter{loginPairAttempts, loginAccountAttempts, loginIPAttempts, reauthUserAttempts, reauthIPAttempts} {
if len(limiter.attempts) != 0 {
t.Fatal("认证繁忙增加了登录失败计数")
}
}
release()
if _, err := auth.Authenticate(user.Account, "CorrectP12"); err != nil {
t.Fatal(err)
}
}
func TestPasswordBusyResponsePreservesOtherErrors(t *testing.T) {
if writePasswordBusy(httptest.NewRecorder(), fmt.Errorf("wrapped: %w", password.ErrInvalidLength)) {
t.Fatal("普通错误被错误标记为资源繁忙")
}
if !writePasswordBusy(httptest.NewRecorder(), fmt.Errorf("wrapped: %w", password.ErrBusy)) {
t.Fatal("包装后的繁忙错误未被识别")
}
}

View File

@ -49,6 +49,9 @@ func ReauthenticateCurrentUser(ctx *appcontext.Context, w http.ResponseWriter, r
user, err := auth.AuthenticateUserByID(ctx.UserID, input.Password)
if err != nil {
if writePasswordBusy(w, err) {
return
}
reauthUserAttempts.recordFailure(userKey)
reauthIPAttempts.recordFailure(ipKey)
log.WithContext(ctx).Info("reauthentication failed")

View File

@ -24,6 +24,7 @@ import (
"git.pcux.cn/xuans/Smail/services/verification"
webauthnservice "git.pcux.cn/xuans/Smail/services/webauthn"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/password"
log "github.com/sirupsen/logrus"
)
@ -279,6 +280,9 @@ func SetSecurityQuestion(ctx *context.Context, w http.ResponseWriter, req *http.
return
}
if err := service.SetSecurityQuestion(ctx.UserID, input.Question, input.Answer); err != nil {
if writePasswordBusy(w, err) {
return
}
writeSecurityError(w, response.ParamsError, "密保问题设置失败")
return
}
@ -308,6 +312,9 @@ func GenerateRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *htt
}
codes, err := service.GenerateRecoveryCodes(ctx.UserID, input.Count)
if err != nil {
if writePasswordBusy(w, err) {
return
}
writeSecurityError(w, response.ParamsError, "离线恢复码生成失败")
return
}
@ -424,6 +431,9 @@ func GenerateProtocolCredential(ctx *context.Context, w http.ResponseWriter, req
}
code, err := service.GenerateProtocolCode(ctx.UserID, input.Protocol)
if err != nil {
if writePasswordBusy(w, err) {
return
}
writeSecurityError(w, response.ParamsError, "请先开启协议后再生成授权码")
return
}
@ -464,6 +474,9 @@ func RevealProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req
return
}
if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil {
if writePasswordBusy(w, err) {
return
}
writeSecurityError(w, response.ParamsError, "密码验证失败")
return
}
@ -617,6 +630,9 @@ func VerifyPasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *ht
}
}
if err != nil {
if writePasswordBusy(w, err) {
return
}
emailRecoveryFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "验证失败")
return
@ -647,6 +663,9 @@ func CompletePasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *
return
}
if err := runtime.recovery.CompleteWithContext(input.Ticket, input.NewPassword, browserContext); err != nil {
if writePasswordBusy(w, err) {
return
}
emailRecoveryFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "重置失败")
return
@ -696,7 +715,16 @@ func CompletePublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request
return
}
runtime := currentSecurityRuntime()
if runtime.recovery == nil || runtime.recovery.CompletePublicLink(input.Token, input.NewPassword, input.RecoveryCode) != nil {
if runtime.recovery == nil {
writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false})
return
}
if err := runtime.recovery.CompletePublicLink(input.Token, input.NewPassword, input.RecoveryCode); err != nil {
if errors.Is(err, password.ErrBusy) {
w.Header().Set("Retry-After", "1")
writePublicRecoveryJSON(w, http.StatusServiceUnavailable, map[string]bool{"reset": false})
return
}
writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false})
return
}

View File

@ -1,4 +1,4 @@
package controllers
package controllers
import (
"net/http"
@ -38,6 +38,9 @@ func CreateClientCredential(ctx *context.Context, w http.ResponseWriter, req *ht
}
code, item, err := service.CreateClientCredential(ctx.UserID, input.ClientName)
if err != nil {
if writePasswordBusy(w, err) {
return
}
writeSecurityError(w, response.ParamsError, err.Error())
return
}
@ -59,6 +62,9 @@ func RegenerateClientCredential(ctx *context.Context, w http.ResponseWriter, req
}
code, err := service.RegenerateClientCredential(ctx.UserID, input.ClientID)
if err != nil {
if writePasswordBusy(w, err) {
return
}
writeSecurityError(w, response.ParamsError, err.Error())
return
}
@ -99,6 +105,9 @@ func RevealClientCredentials(ctx *context.Context, w http.ResponseWriter, req *h
return
}
if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil {
if writePasswordBusy(w, err) {
return
}
writeSecurityError(w, response.ParamsError, "密码验证失败")
return
}

View File

@ -62,6 +62,9 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque
currentUser, err = auth.Authenticate(ctx.UserAccount, retData.CurrentPassword)
}
if err != nil || currentUser == nil || currentUser.ID != ctx.UserID {
if writePasswordBusy(w, err) {
return
}
loginPairAttempts.recordFailure(pairKey)
loginAccountAttempts.recordFailure(accountKey)
loginIPAttempts.recordFailure(ipKey)
@ -89,6 +92,9 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque
PasswordChangedAt: &now,
}
if err := auth.SetPassword(&user, retData.Password); err != nil {
if writePasswordBusy(w, err) {
return
}
if errors.Is(err, password.ErrInvalidLength) {
response.NewErrorResponse(response.ParamsError, "密码长度必须为 8-12 字节", "").FPrint(w)
return

View File

@ -86,6 +86,9 @@ func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request)
user := models.User{Name: username, Account: account, Disabled: reqData.Disabled, SessionVersion: 1, SecurityVersion: 1}
user.ApplyRole(targetRole)
if err := auth.SetPassword(&user, reqData.Password); err != nil {
if writePasswordBusy(w, err) {
return
}
if errors.Is(err, password.ErrInvalidLength) {
response.NewErrorResponse(response.ParamsError, "密码长度必须为 8-12 字节", "").FPrint(w)
return
@ -337,6 +340,9 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
user.Disabled = reqData.Disabled
if reqData.Password != "" {
if err := auth.SetPassword(&user, reqData.Password); err != nil {
if writePasswordBusy(w, err) {
return
}
if errors.Is(err, password.ErrInvalidLength) {
response.NewErrorResponse(response.ParamsError, "密码长度必须为 8-12 字节", "").FPrint(w)
return

View File

@ -0,0 +1,63 @@
// Package passwordtest 为串行安全测试占用真实密码计算名额,不执行额外的昂贵计算。
package passwordtest
import (
"crypto/rand"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"git.pcux.cn/xuans/Smail/utils/password"
)
type heldReader struct {
original io.Reader
entered chan struct{}
resume chan struct{}
enteredOnce atomic.Bool
}
func (r *heldReader) Read(p []byte) (int, error) {
if r.enteredOnce.CompareAndSwap(false, true) {
close(r.entered)
<-r.resume
panic(r)
}
return r.original.Read(p)
}
// Hold 仅供非并行测试使用,释放时同时验证异常路径没有泄漏计算名额。
func Hold(t *testing.T) func() {
t.Helper()
r := &heldReader{original: rand.Reader, entered: make(chan struct{}), resume: make(chan struct{})}
rand.Reader = r
done := make(chan any, 1)
go func() {
defer func() { done <- recover() }()
_, _ = password.Hash("HeldPass123")
}()
var once sync.Once
release := func() {
once.Do(func() {
close(r.resume)
defer func() { rand.Reader = r.original }()
select {
case result := <-done:
if result != r {
t.Errorf("密码测试占位未经过预期的异常释放路径")
}
case <-time.After(5 * time.Second):
t.Error("密码测试占位释放超时")
}
})
}
t.Cleanup(release)
select {
case <-r.entered:
case <-time.After(5 * time.Second):
t.Fatal("密码计算名额未被测试请求占用")
}
return release
}

View File

@ -1,8 +1,11 @@
package imap_server
import (
"errors"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/utils/password"
"github.com/emersion/go-imap/v2"
log "github.com/sirupsen/logrus"
)
@ -10,6 +13,9 @@ import (
func (s *serverSession) Login(username, pwd string) error {
user, err := auth.AuthenticateProtocol("imap", s.remoteAddress, username, pwd)
if err != nil {
if errors.Is(err, password.ErrBusy) {
return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeUnavailable, Text: "Authentication temporarily unavailable"}
}
log.WithContext(s.ctx).WithFields(log.Fields{
"action": "IMAP 客户端登录",
"result": "failure",
@ -34,4 +40,4 @@ func (s *serverSession) Login(username, pwd string) error {
"object": s.remoteAddress,
}).Debug("IMAP 客户端认证成功")
return nil
}
}

View File

@ -16,6 +16,7 @@ import (
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/errors"
"git.pcux.cn/xuans/Smail/utils/id"
"git.pcux.cn/xuans/Smail/utils/password"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"strconv"
@ -105,6 +106,9 @@ func (a action) Pass(session *gopop.Session, pwd string) error {
}
user, err := auth.AuthenticateProtocol("pop3", remoteAddress, session.User, pwd)
if err != nil {
if errors2.Is(err, password.ErrBusy) {
return errors2.New("[SYS/TEMP] Authentication temporarily unavailable")
}
log.WithContext(session.Ctx).WithFields(log.Fields{
"action": "POP3 客户端登录",
"result": "failure",

View File

@ -6,6 +6,7 @@ import (
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/id"
"git.pcux.cn/xuans/Smail/utils/password"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
log "github.com/sirupsen/logrus"
@ -64,6 +65,9 @@ func (s *Session) Auth(mech string) (sasl.Server, error) {
func (s *Session) AuthPlain(username, pwd string) error {
user, err := auth.AuthenticateProtocol("smtp", s.RemoteAddress.String(), username, pwd)
if err != nil {
if errors.Is(err, password.ErrBusy) {
return &smtp.SMTPError{Code: 454, EnhancedCode: smtp.EnhancedCode{4, 7, 0}, Message: "Temporary authentication failure"}
}
log.WithContext(s.Ctx).WithFields(log.Fields{
"action": "SMTP 客户端认证",
"result": "failure",

View File

@ -28,7 +28,9 @@ func AuthenticateIdentifier(identifier, plain string) (*models.User, error) {
identity, err := ResolveIdentifier(identifier)
if err != nil {
if plain != "" {
password.VerifyDummy(plain)
if err := password.CheckDummy(plain); err != nil {
return nil, err
}
}
return nil, errors.New("invalid credentials")
}
@ -57,7 +59,9 @@ func AuthenticateUserByID(userID int, plain string) (*models.User, error) {
func authenticateUserByID(userID int, plain string) (*models.User, error) {
if userID <= 0 {
if plain != "" {
password.VerifyDummy(plain)
if err := password.CheckDummy(plain); err != nil {
return nil, err
}
}
return nil, errors.New("invalid credentials")
}
@ -72,16 +76,24 @@ func authenticateUserByID(userID int, plain string) (*models.User, error) {
func authenticateLoadedUser(user *models.User, has bool, err error, plain string) (*models.User, error) {
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
password.VerifyDummy(plain)
if err := password.CheckDummy(plain); err != nil {
return nil, err
}
return nil, errors.New("invalid credentials")
}
return nil, fmt.Errorf("load user: %w", err)
}
if !has || user.ID == 0 {
password.VerifyDummy(plain)
if err := password.CheckDummy(plain); err != nil {
return nil, err
}
return nil, errors.New("invalid credentials")
}
if !password.Verify(user.Password, plain) {
matched, err := password.Check(user.Password, plain)
if err != nil {
return nil, err
}
if !matched {
return nil, errors.New("invalid credentials")
}
return user, nil

View File

@ -0,0 +1,73 @@
package auth
import (
"errors"
"path/filepath"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/internal/passwordtest"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/utils/password"
)
func TestProtocolBusyDoesNotCountAsCredentialFailure(t *testing.T) {
oldConfig, oldDB := config.Instance, db.Instance
oldPair, oldAccount, oldRemote := protocolPairAttempts, protocolAccountAttempts, protocolRemoteAttempts
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "capacity.db"), Domain: "example.com"}
if err := db.Init(""); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Instance.Close()
config.Instance, db.Instance = oldConfig, oldDB
protocolPairAttempts, protocolAccountAttempts, protocolRemoteAttempts = oldPair, oldAccount, oldRemote
})
protocolPairAttempts = newProtocolLoginLimiter(1)
protocolAccountAttempts = newProtocolLoginLimiter(1)
protocolRemoteAttempts = newProtocolLoginLimiter(1)
user := models.User{Account: "capacity-user"}
if err := SetPassword(&user, "CorrectP12"); err != nil {
t.Fatal(err)
}
if _, err := db.Instance.Insert(&user); err != nil {
t.Fatal(err)
}
address, now := "capacity-user@example.com", time.Now().UTC()
if _, err := db.Instance.Insert(&models.UserEmail{UserID: user.ID, Address: address, AddressCanonical: &address, VerifiedAt: &now}); err != nil {
t.Fatal(err)
}
service, err := credentials.NewService(db.Instance)
if err != nil {
t.Fatal(err)
}
code, _, err := service.CreateClientCredential(user.ID, "容量验收")
if err != nil {
t.Fatal(err)
}
release := passwordtest.Hold(t)
for index := 0; index < 12; index++ {
for _, protocol := range []string{"smtp", "imap", "pop3"} {
if got, err := AuthenticateProtocol(protocol, "192.0.2.70:1234", address, code); got != nil || !errors.Is(err, password.ErrBusy) {
t.Fatalf("%s 繁忙被当成凭据失败:%v", protocol, err)
}
}
}
for _, account := range []string{user.Account, "missing-user"} {
if _, err := Authenticate(account, "CorrectP12"); !errors.Is(err, password.ErrBusy) {
t.Fatalf("真实或模拟密码校验未传递繁忙:%v", err)
}
}
if len(protocolPairAttempts.attempts)+len(protocolAccountAttempts.attempts)+len(protocolRemoteAttempts.attempts) != 0 {
t.Fatal("资源繁忙增加了协议失败计数")
}
release()
for _, protocol := range []string{"smtp", "imap", "pop3"} {
if got, err := AuthenticateProtocol(protocol, "192.0.2.70:1234", address, code); err != nil || got.ID != user.ID {
t.Fatalf("%s 繁忙结束后无法正常登录:%v", protocol, err)
}
}
}

View File

@ -12,6 +12,7 @@ import (
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/utils/password"
)
const (
@ -81,7 +82,14 @@ func AuthenticateProtocol(protocol string, remoteAddress string, account string,
}
service, err := credentials.NewService(db.Instance)
if err != nil || !service.VerifyClientOrProtocolCode(userID, normalizedProtocol, plain) {
if err != nil {
return fail()
}
matched, err := service.CheckClientOrProtocolCode(userID, normalizedProtocol, plain)
if errors.Is(err, password.ErrBusy) {
return nil, err
}
if err != nil || !matched {
return fail()
}
var user models.User

View File

@ -0,0 +1,54 @@
package credentials
import (
"errors"
"testing"
"git.pcux.cn/xuans/Smail/internal/passwordtest"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/password"
)
func TestCredentialBusyDoesNotConsumeOrReplaceCodes(t *testing.T) {
service := newFixture(t)
if err := service.engine.Sync2(&models.ClientCredential{}); err != nil {
t.Fatal(err)
}
if err := service.SetSecurityQuestion(1, "问题", "answer"); err != nil {
t.Fatal(err)
}
if err := service.SetProtocolEnabled(1, ProtocolIMAP, true); err != nil {
t.Fatal(err)
}
protocolCode, err := service.GenerateProtocolCode(1, ProtocolIMAP)
if err != nil {
t.Fatal(err)
}
codes, err := service.GenerateRecoveryCodes(1, 5)
if err != nil {
t.Fatal(err)
}
release := passwordtest.Hold(t)
if _, err := service.VerifySecurityQuestion(1, "answer"); !errors.Is(err, password.ErrBusy) {
t.Fatal(err)
}
if _, err := service.CheckProtocolCode(1, ProtocolIMAP, protocolCode); !errors.Is(err, password.ErrBusy) {
t.Fatal(err)
}
if _, err := service.CheckClientOrProtocolCode(1, ProtocolIMAP, protocolCode); !errors.Is(err, password.ErrBusy) {
t.Fatal(err)
}
if err := service.ConsumeRecoveryCode(1, codes[0]); !errors.Is(err, password.ErrBusy) {
t.Fatal(err)
}
if _, err := service.GenerateRecoveryCodes(1, 5); !errors.Is(err, password.ErrBusy) {
t.Fatal(err)
}
release()
if err := service.ConsumeRecoveryCode(1, codes[0]); err != nil {
t.Fatalf("繁忙后恢复码已被错误消费或撤销:%v", err)
}
if _, err := service.CheckProtocolCode(1, ProtocolIMAP, protocolCode); err != nil {
t.Fatal(err)
}
}

View File

@ -1,4 +1,4 @@
package credentials
package credentials
import (
"errors"
@ -286,14 +286,18 @@ func (s *Service) RevealClientCredentials(userID int) (map[int]string, error) {
// VerifyClientOrProtocolCode 同时支持客户端专属授权码以及遗留的协议授权码
func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPlain string) bool {
matched, _ := s.CheckClientOrProtocolCode(userID, protocol, rawPlain)
return matched
}
// CheckClientOrProtocolCode 将计算过载向上返回,避免被当成密码错误累计封禁。
func (s *Service) CheckClientOrProtocolCode(userID int, protocol string, rawPlain string) (bool, error) {
if s == nil || s.engine == nil || userID <= 0 || rawPlain == "" || len([]byte(rawPlain)) > 256 {
password.VerifyDummy(rawPlain)
return false
return false, password.CheckDummy(rawPlain)
}
cleanedPlain := CleanAuthCode(rawPlain)
if cleanedPlain == "" {
password.VerifyDummy(rawPlain)
return false
return false, password.CheckDummy(rawPlain)
}
// 1. 优先校验客户端专属授权码
@ -302,12 +306,22 @@ func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPla
if err == nil && len(clients) > 0 {
for _, client := range clients {
// 比对清洗后的统一大写无横线授权码
if password.Verify(client.CodeHash, cleanedPlain) {
matched, err := password.Check(client.CodeHash, cleanedPlain)
if err != nil {
return false, err
}
if matched {
now := s.now()
_, _ = s.engine.ID(client.ID).
affected, err := s.engine.ID(client.ID).
Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ClientCredential{LastUsedAt: &now})
return true
if err != nil || affected == 1 {
return err == nil, err
}
valid, err := s.engine.ID(client.ID).
Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash).
Exist(&models.ClientCredential{})
return valid, err
}
}
}
@ -315,20 +329,25 @@ func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPla
// 2. 回退校验旧版按协议划分的授权码(支持 cleanedPlain 或原樣 rawPlain)
protocol, err = NormalizeProtocol(protocol)
if err != nil {
password.VerifyDummy(rawPlain)
return false
return false, password.CheckDummy(rawPlain)
}
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item)
if err != nil || !has || item.CodeHash == "" {
password.VerifyDummy(rawPlain)
return false
return false, password.CheckDummy(rawPlain)
}
// 兼容旧哈希直接比对原始输入,或者比对清洗后的输入
matched := password.Verify(item.CodeHash, strings.TrimSpace(rawPlain)) || password.Verify(item.CodeHash, cleanedPlain)
if !matched {
return false
trimmedPlain := strings.TrimSpace(rawPlain)
matched, err := password.Check(item.CodeHash, trimmedPlain)
if err != nil {
return false, err
}
if !matched && trimmedPlain != cleanedPlain {
matched, err = password.Check(item.CodeHash, cleanedPlain)
}
if err != nil || !matched {
return false, err
}
now := s.now()
@ -336,11 +355,14 @@ func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPla
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
if err == nil && affected == 1 {
return true
return true, nil
}
if err != nil {
return false, err
}
valid, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Exist(&models.ProtocolCredential{})
return err == nil && valid
return valid, err
}

View File

@ -9,8 +9,8 @@ import (
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/password"
"git.pcux.cn/xuans/Smail/utils/credential"
"git.pcux.cn/xuans/Smail/utils/password"
)
const (
@ -189,30 +189,34 @@ func (s *Service) RevokeProtocolCode(userID int, protocol string) error {
if _, err := s.engine.Where("user_id = ? AND protocol = ? AND revoked_at IS NULL", userID, protocol).
Cols("revoked_at", "code_hash", "code_ciphertext", "updated_at").
Update(&models.ProtocolCredential{RevokedAt: &now, CodeHash: "", CodeCiphertext: ""}); err != nil {
return fmt.Errorf("revoke protocol authorization code: %w", err)
return fmt.Errorf("revoke protocol authorization code: %w", err)
}
return nil
}
// VerifyProtocolCode 校验协议开关和授权码,不接受网页登录密码。
func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string) bool {
matched, _ := s.CheckProtocolCode(userID, protocol, plain)
return matched
}
// CheckProtocolCode 保留计算过载错误,供需要区分重试与认证失败的调用方使用。
func (s *Service) CheckProtocolCode(userID int, protocol string, plain string) (bool, error) {
if s == nil || s.engine == nil || userID <= 0 || plain == "" || len([]byte(plain)) > 256 {
password.VerifyDummy(plain)
return false
return false, password.CheckDummy(plain)
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
password.VerifyDummy(plain)
return false
return false, password.CheckDummy(plain)
}
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item)
if err != nil || !has || item.CodeHash == "" {
password.VerifyDummy(plain)
return false
return false, password.CheckDummy(plain)
}
if !password.Verify(item.CodeHash, strings.TrimSpace(plain)) {
return false
matched, err := password.Check(item.CodeHash, strings.TrimSpace(plain))
if err != nil || !matched {
return false, err
}
now := s.now()
// 哈希计算期间可能发生关闭、撤销或换码,返回成功前必须再次原子确认凭据状态。
@ -220,16 +224,16 @@ func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string)
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
if err != nil {
return false
return false, err
}
if affected == 1 {
return true
return true, nil
}
// MySQL 在时间字段未变化时可能报告零影响行,必须再次查询有效状态而不是误拒绝或直接放行。
valid, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Exist(&models.ProtocolCredential{})
return err == nil && valid
return valid, err
}
func newProtocolCode() (string, error) {

View File

@ -133,10 +133,9 @@ func (s *Service) VerifySecurityQuestion(userID int, answer string) (bool, error
return false, fmt.Errorf("load security question: %w", err)
}
if !has {
password.VerifyDummy(answer)
return false, nil
return false, password.CheckDummy(answer)
}
return password.Verify(item.AnswerHash, answer), nil
return password.Check(item.AnswerHash, answer)
}
// GenerateRecoveryCodes 撤销旧码并生成一组只返回一次的离线恢复码。
@ -241,7 +240,11 @@ func (s *Service) consumeRecoveryCode(session recoveryCodeSession, userID int, c
return fmt.Errorf("load recovery codes: %w", err)
}
for _, item := range items {
if !password.Verify(item.CodeHash, code) {
matched, err := password.Check(item.CodeHash, code)
if err != nil {
return err
}
if !matched {
continue
}
now := s.now()

View File

@ -0,0 +1,47 @@
package recovery
import (
"errors"
"testing"
"git.pcux.cn/xuans/Smail/internal/passwordtest"
"git.pcux.cn/xuans/Smail/utils/password"
)
func TestRecoveryBusyPreservesTicket(t *testing.T) {
service, _, _, user := newRecoveryFixture(t)
ticketID, err := service.issueTicket(user.ID, user.SessionVersion)
if err != nil {
t.Fatal(err)
}
release := passwordtest.Hold(t)
for index := 0; index < 12; index++ {
if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, password.ErrBusy) {
t.Fatalf("繁忙期间票据失效或返回错误:%v", err)
}
}
release()
if err := service.Complete(ticketID, "NewPass123"); err != nil {
t.Fatalf("繁忙恢复后不能使用原票据:%v", err)
}
if err := service.Complete(ticketID, "OtherPass12"); !errors.Is(err, ErrRecoveryExpired) {
t.Fatalf("成功后票据未保持一次性:%v", err)
}
}
func TestRecoveryBusyDoesNotRestoreRevokedTicket(t *testing.T) {
service, _, _, user := newRecoveryFixture(t)
ticketID, err := service.issueTicket(user.ID, user.SessionVersion)
if err != nil {
t.Fatal(err)
}
release := passwordtest.Hold(t)
if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, password.ErrBusy) {
t.Fatal(err)
}
service.InvalidateUser(user.ID)
release()
if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, ErrRecoveryExpired) {
t.Fatalf("撤销的票据被恢复:%v", err)
}
}

View File

@ -3,10 +3,12 @@ package recovery
import (
"crypto/sha256"
"crypto/subtle"
"errors"
"strings"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/utils/password"
)
const maxEmailContinuationAttempts = 5
@ -93,10 +95,14 @@ func (s *Service) ContinueEmailWithContext(continuation, identifier, email, reco
// 数据库和恢复码哈希验证不占用全局锁;同一凭证同时只能有一个请求验证。
invalidate := false
busy := false
defer func() {
s.mu.Lock()
defer s.mu.Unlock()
if s.emailPending[key] == pending {
if busy {
pending.Attempts--
}
if invalidate || pending.Attempts >= maxEmailContinuationAttempts {
delete(s.emailPending, key)
} else {
@ -115,6 +121,10 @@ func (s *Service) ContinueEmailWithContext(continuation, identifier, email, reco
return "", ErrRecoveryDenied
}
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
if errors.Is(err, password.ErrBusy) {
busy = true
return "", err
}
return "", ErrRecoveryDenied
}
invalidate = true

View File

@ -1,6 +1,7 @@
package recovery
import (
"errors"
"fmt"
"strings"
"time"
@ -203,6 +204,9 @@ func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) er
}()
if user.EffectiveRole() == access.RoleAdmin {
if err := s.credentials.ConsumeRecoveryCodeWithSession(session, user.ID, recoveryCode); err != nil {
if errors.Is(err, password.ErrBusy) {
return err
}
if s.recordPublicRecoveryCodeFailure(key, item) {
consume = true
}

View File

@ -63,6 +63,7 @@ type Service struct {
}
type ticket struct {
Busy bool
UserID int
SessionVersion int
Method string
@ -249,6 +250,9 @@ func (s *Service) VerifyWithContext(challengeID, account, email, code, recoveryC
return "", ErrRecoveryDenied
}
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
if errors.Is(err, password.ErrBusy) {
return "", err
}
return "", ErrRecoveryDenied
}
method = "email_admin"
@ -297,17 +301,26 @@ func (s *Service) VerifyOfflineWithContext(identifier, question, answer, recover
}
user, err := s.loadRecoverableUser(identifier)
if err != nil {
password.VerifyDummy(answer)
password.VerifyDummy(recoveryCode)
if err := password.CheckDummy(answer); err != nil {
return "", err
}
if err := password.CheckDummy(recoveryCode); err != nil {
return "", err
}
return "", ErrRecoveryDenied
}
storedQuestion, configured, questionErr := s.credentials.GetSecurityQuestion(user.ID)
answerVerified, answerErr := s.credentials.VerifySecurityQuestion(user.ID, answer)
if errors.Is(answerErr, password.ErrBusy) {
return "", answerErr
}
providedQuestionDigest := sha256.Sum256([]byte(strings.TrimSpace(question)))
storedQuestionDigest := sha256.Sum256([]byte(strings.TrimSpace(storedQuestion)))
questionVerified := configured && subtle.ConstantTimeCompare(providedQuestionDigest[:], storedQuestionDigest[:]) == 1
if questionErr != nil || answerErr != nil || !questionVerified || !answerVerified {
password.VerifyDummy(recoveryCode)
if err := password.CheckDummy(recoveryCode); err != nil {
return "", err
}
return "", ErrRecoveryDenied
}
ticketID, err := s.issueTicketFor(user.ID, user.SessionVersion, "offline", 0, 0, browserContext)
@ -316,6 +329,9 @@ func (s *Service) VerifyOfflineWithContext(identifier, question, answer, recover
}
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
s.invalidateTicket(ticketID)
if errors.Is(err, password.ErrBusy) {
return "", err
}
return "", ErrRecoveryDenied
}
return ticketID, nil
@ -342,6 +358,10 @@ func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext stri
s.mu.Unlock()
return ErrRecoveryExpired
}
if item.Busy {
s.mu.Unlock()
return ErrRecoveryDenied
}
if item.HasBrowserContextDigest {
providedDigest := sha256.Sum256([]byte(strings.TrimSpace(browserContext)))
if strings.TrimSpace(browserContext) == "" || subtle.ConstantTimeCompare(providedDigest[:], item.BrowserContextDigest[:]) != 1 {
@ -349,11 +369,28 @@ func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext stri
return ErrRecoveryDenied
}
}
delete(s.tickets, ticketDigest)
item.Busy = true
s.tickets[ticketDigest] = item
s.mu.Unlock()
// 过载时保留同一张有效票据,已失效或已被撤销的票据不得恢复。
retry := false
defer func() {
s.mu.Lock()
defer s.mu.Unlock()
if current, exists := s.tickets[ticketDigest]; exists && current == item {
if retry && s.now().Before(item.ExpiresAt) {
item.Busy = false
s.tickets[ticketDigest] = item
} else {
delete(s.tickets, ticketDigest)
}
}
}()
hash, err := auth.HashPassword(newPassword)
if err != nil {
retry = errors.Is(err, password.ErrBusy)
return err
}
defer clearString(&hash)

View File

@ -0,0 +1,17 @@
package password
import "errors"
var ErrBusy = errors.New("password computation is busy")
// 所有协议和恢复流程共享一个计算名额;满载立即拒绝,不积压等待请求。
var computationSlot = make(chan struct{}, 1)
func acquireComputation() (func(), error) {
select {
case computationSlot <- struct{}{}:
return func() { <-computationSlot }, nil
default:
return nil, ErrBusy
}
}

View File

@ -0,0 +1,105 @@
package password_test
import (
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"git.pcux.cn/xuans/Smail/internal/passwordtest"
"git.pcux.cn/xuans/Smail/utils/password"
)
func TestCapacityRejectsBurstAndReleasesAfterPanic(t *testing.T) {
encoded, err := password.Hash("StrongPass12")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(encoded, "$argon2id$v=19$m=65536,t=3,p=1$") {
t.Fatal("密码算法强度发生变化")
}
release := passwordtest.Hold(t)
var wg sync.WaitGroup
for index := 0; index < 64; index++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := password.Hash("StrongPass12"); !errors.Is(err, password.ErrBusy) {
t.Errorf("并发哈希未返回繁忙:%v", err)
}
if matched, err := password.Check(encoded, "StrongPass12"); matched || !errors.Is(err, password.ErrBusy) {
t.Errorf("并发校验未返回繁忙:%v %v", matched, err)
}
if err := password.CheckDummy("StrongPass12"); !errors.Is(err, password.ErrBusy) {
t.Errorf("模拟校验绕过计算名额:%v", err)
}
}()
}
wg.Wait()
release()
if matched, err := password.Check(encoded, "StrongPass12"); !matched || err != nil {
t.Fatalf("异常释放后不能继续认证:%v %v", matched, err)
}
if matched, err := password.Check(encoded, "WrongPass12"); matched || err != nil {
t.Fatalf("密码错误与服务繁忙未区分:%v %v", matched, err)
}
}
func TestCapacityValidationDoesNotLeakSlot(t *testing.T) {
if _, err := password.Hash(""); !errors.Is(err, password.ErrInvalidLength) {
t.Fatal(err)
}
if matched, err := password.Check(strings.Repeat("x", 256), "StrongPass12"); matched || err != nil {
t.Fatal("非法哈希应被直接拒绝")
}
if err := password.CheckDummy("StrongPass12"); err != nil {
t.Fatal(err)
}
if _, err := password.Hash("StrongPass12"); err != nil {
t.Fatal(err)
}
}
func TestCapacityRealConcurrentComputations(t *testing.T) {
encoded, err := password.Hash("StrongPass12")
if err != nil {
t.Fatal(err)
}
var succeeded, rejected atomic.Int32
var wg sync.WaitGroup
start := make(chan struct{})
for index := 0; index < 64; index++ {
wg.Add(1)
go func(mode int) {
defer wg.Done()
<-start
var err error
switch mode % 3 {
case 0:
_, err = password.Hash("StrongPass12")
case 1:
var matched bool
matched, err = password.Check(encoded, "StrongPass12")
if err == nil && !matched {
t.Error("真实并发校验密码不匹配")
}
default:
err = password.CheckDummy("StrongPass12")
}
if errors.Is(err, password.ErrBusy) {
rejected.Add(1)
} else if err == nil {
succeeded.Add(1)
} else {
t.Errorf("真实并发计算异常:%v", err)
}
}(index)
}
close(start)
wg.Wait()
if succeeded.Load() == 0 || rejected.Load() == 0 || succeeded.Load()+rejected.Load() != 64 {
t.Fatalf("并发保护未生效:成功=%d,繁忙=%d", succeeded.Load(), rejected.Load())
}
t.Logf("64 路真实计算:成功=%d,繁忙=%d", succeeded.Load(), rejected.Load())
}

View File

@ -43,6 +43,11 @@ func Hash(plain string) (string, error) {
if plainSize < minSecretSize {
return "", ErrInvalidLength
}
release, err := acquireComputation()
if err != nil {
return "", err
}
defer release()
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
@ -82,28 +87,54 @@ func HashUserPassword(plain string) (string, error) {
// Verify 校验 Argon2id 密码哈希,不接受旧算法格式。
func Verify(encoded, plain string) bool {
matched, _ := Check(encoded, plain)
return matched
}
// Check 区分密码不匹配与计算资源繁忙,业务入口必须保留错误信息。
func Check(encoded, plain string) (bool, error) {
if len([]byte(plain)) < minSecretSize {
return false
return false, nil
}
if len(encoded) > maxHashSize {
return false, nil
}
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != format || parts[2] != fmt.Sprintf("v=%d", version) || parts[3] != fmt.Sprintf("m=%d,t=%d,p=%d", memory, iterations, parallelism) {
return false
return false, nil
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil || len(salt) != saltLength {
return false
return false, nil
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(expected) != keyLength {
return false
return false, nil
}
release, err := acquireComputation()
if err != nil {
return false, err
}
defer release()
actual := argon2.IDKey([]byte(plain), salt, iterations, memory, parallelism, keyLength)
return subtle.ConstantTimeCompare(actual, expected) == 1
return subtle.ConstantTimeCompare(actual, expected) == 1, nil
}
func VerifyDummy(plain string) bool {
_ = CheckDummy(plain)
return false
}
// CheckDummy 为不存在的凭据执行同等强度计算,并传递过载状态。
func CheckDummy(plain string) error {
release, err := acquireComputation()
if err != nil {
return err
}
defer release()
salt := [saltLength]byte{0x53, 0x6d, 0x61, 0x69, 0x6c, 0x2d, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x64, 0x75, 0x6d, 0x6d, 0x79}
expected := [keyLength]byte{0x91, 0xa4, 0x65, 0x82, 0x31, 0x07, 0x44, 0xc8, 0x7a, 0x22, 0xe1, 0x59, 0x3c, 0x5b, 0x8d, 0xf1, 0xd4, 0x3a, 0x68, 0x9c, 0xee, 0x20, 0x57, 0x61, 0x45, 0x3a, 0x71, 0x10, 0x5b, 0x26, 0xb6, 0xa0}
actual := argon2.IDKey([]byte(plain), salt[:], iterations, memory, parallelism, keyLength)
return subtle.ConstantTimeCompare(actual, expected[:]) == 1
_ = subtle.ConstantTimeCompare(actual, expected[:])
return nil
}

View File

@ -27,6 +27,7 @@
| PROD-013 | P0 | 共享主机资源隔离与容量 | [!] | 首轮测试期间发生全机 OOM,MySQL 被终止后自动重启;必须补齐资源规划 |
| PROD-014 | P1 | 投递结果写库恢复 | [✓] | 12 项远程测试及源码复审通过;持久故障后的进程退出仍需人工核对,不代表生产故障演练通过 |
| PROD-015 | P1 | 跨平台证书路径与插件测试 | [✓] | Linux 拒绝异平台路径误解析;保持证书校验、托管导入和插件目录权限要求 |
| PROD-016 | P0 | 密码计算全局并发和过载处理 | [~] | 保留 Argon2id 64 MiB 参数,增加单计算名额、立即拒绝和可重试错误;远程回归待执行 |
状态含义:`[ ]` 未开始,`[~]` 进行中,`[x]` 已完成待复审,`[✓]` 指定范围复审通过,`[!]` 暂缓或存在阻塞。