This commit is contained in:
parent
0177de8a9f
commit
b4f5f51c0e
@ -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)
|
||||
|
||||
22
backend/controllers/password_capacity.go
Normal file
22
backend/controllers/password_capacity.go
Normal 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
|
||||
}
|
||||
86
backend/controllers/password_capacity_test.go
Normal file
86
backend/controllers/password_capacity_test.go
Normal 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("包装后的繁忙错误未被识别")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
63
backend/internal/passwordtest/hold.go
Normal file
63
backend/internal/passwordtest/hold.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
73
backend/services/auth/capacity_test.go
Normal file
73
backend/services/auth/capacity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
54
backend/services/credentials/capacity_test.go
Normal file
54
backend/services/credentials/capacity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
47
backend/services/recovery/capacity_test.go
Normal file
47
backend/services/recovery/capacity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
17
backend/utils/password/capacity.go
Normal file
17
backend/utils/password/capacity.go
Normal 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
|
||||
}
|
||||
}
|
||||
105
backend/utils/password/capacity_test.go
Normal file
105
backend/utils/password/capacity_test.go
Normal 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())
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
| PROD-013 | P0 | 共享主机资源隔离与容量 | [!] | 首轮测试期间发生全机 OOM,MySQL 被终止后自动重启;必须补齐资源规划 |
|
||||
| PROD-014 | P1 | 投递结果写库恢复 | [✓] | 12 项远程测试及源码复审通过;持久故障后的进程退出仍需人工核对,不代表生产故障演练通过 |
|
||||
| PROD-015 | P1 | 跨平台证书路径与插件测试 | [✓] | Linux 拒绝异平台路径误解析;保持证书校验、托管导入和插件目录权限要求 |
|
||||
| PROD-016 | P0 | 密码计算全局并发和过载处理 | [~] | 保留 Argon2id 64 MiB 参数,增加单计算名额、立即拒绝和可重试错误;远程回归待执行 |
|
||||
|
||||
状态含义:`[ ]` 未开始,`[~]` 进行中,`[x]` 已完成待复审,`[✓]` 指定范围复审通过,`[!]` 暂缓或存在阻塞。
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user