fix: harden login for MLPS requirements

This commit is contained in:
2026-05-14 11:05:20 +08:00
parent ecf3ee1ef0
commit 879677516f
39 changed files with 1230 additions and 71 deletions
+48 -5
View File
@@ -21,6 +21,7 @@ type AuthService struct {
jwt *auth.Manager
sessions *auth.SessionStore
loginGuard *auth.LoginGuard
cipher *auth.PasswordCipher
}
func NewAuthService(
@@ -45,11 +46,18 @@ func NewAuthService(
}
}
func (s *AuthService) WithPasswordCipher(cipher *auth.PasswordCipher) *AuthService {
s.cipher = cipher
return s
}
type LoginRequest struct {
Identifier string `json:"identifier"`
Email string `json:"email"`
Password string `json:"password" binding:"required"`
IP string `json:"-"`
Identifier string `json:"identifier"`
Email string `json:"email"`
Password string `json:"password"`
EncryptedPassword string `json:"encrypted_password"`
PasswordKeyID string `json:"password_key_id"`
IP string `json:"-"`
}
type LoginResponse struct {
@@ -107,6 +115,10 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
if identifier == "" {
return nil, response.ErrBadRequest(40001, "invalid_params", "login identifier is required")
}
password, err := s.loginPassword(req)
if err != nil {
return nil, err
}
permit, err := s.acquireLoginPermit(ctx, identifier, req.IP)
if err != nil {
@@ -114,6 +126,9 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
}
credentialsVerified := false
defer func() {
if permit == nil {
return
}
if credentialsVerified {
permit.RecordSuccess(ctx)
return
@@ -127,7 +142,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
permit.RecordFailure(ctx)
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
}
@@ -178,6 +193,34 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
}, nil
}
func (s *AuthService) PasswordPublicKey() *auth.PasswordCipherPublicKey {
if s == nil || s.cipher == nil {
return nil
}
publicKey := s.cipher.PublicKey()
return &publicKey
}
func (s *AuthService) loginPassword(req LoginRequest) (string, error) {
if strings.TrimSpace(req.EncryptedPassword) != "" {
if s.cipher == nil {
return "", response.ErrBadRequest(40002, "password_cipher_unavailable", "encrypted password is not supported by this server")
}
password, err := s.cipher.Decrypt(req.EncryptedPassword, req.PasswordKeyID)
if err != nil {
return "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
}
if password == "" {
return "", response.ErrBadRequest(40001, "invalid_params", "password is required")
}
return password, nil
}
if req.Password == "" {
return "", response.ErrBadRequest(40001, "invalid_params", "password is required")
}
return req.Password, nil
}
func (s *AuthService) acquireLoginPermit(ctx context.Context, identifier, ip string) (*auth.LoginPermit, error) {
if s.loginGuard == nil {
return nil, nil
@@ -0,0 +1,60 @@
package app
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"testing"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/stretchr/testify/require"
)
func TestAuthServiceLoginPasswordDecryptsEncryptedPassword(t *testing.T) {
t.Parallel()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
privatePEM := string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}))
cipher, err := auth.NewPasswordCipher(privatePEM, "tenant-test-key")
require.NoError(t, err)
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Test@123"), nil)
require.NoError(t, err)
svc := (&AuthService{}).WithPasswordCipher(cipher)
got, err := svc.loginPassword(LoginRequest{
EncryptedPassword: base64.StdEncoding.EncodeToString(encrypted),
PasswordKeyID: "tenant-test-key",
})
require.NoError(t, err)
require.Equal(t, "Test@123", got)
}
func TestAuthServiceLoginPasswordAllowsPlainPasswordCompatibility(t *testing.T) {
t.Parallel()
got, err := (&AuthService{}).loginPassword(LoginRequest{Password: "Test@123"})
require.NoError(t, err)
require.Equal(t, "Test@123", got)
}
func TestAuthServiceLoginPasswordRejectsEncryptedWhenCipherMissing(t *testing.T) {
t.Parallel()
_, err := (&AuthService{}).loginPassword(LoginRequest{EncryptedPassword: "abc"})
require.Error(t, err)
appErr, ok := err.(*response.AppError)
require.True(t, ok)
require.Equal(t, "password_cipher_unavailable", appErr.Message)
}
@@ -25,10 +25,19 @@ func NewAuthHandler(a *bootstrap.App) *AuthHandler {
a.JWT,
a.Sessions,
a.LoginGuard,
),
).WithPasswordCipher(a.PasswordCipher),
}
}
func (h *AuthHandler) PasswordPublicKey(c *gin.Context) {
key := h.svc.PasswordPublicKey()
if key == nil {
response.Error(c, response.ErrNotFound(40420, "password_cipher_disabled", "password cipher is disabled"))
return
}
response.Success(c, key)
}
func (h *AuthHandler) Login(c *gin.Context) {
var req app.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -13,6 +13,7 @@ func RegisterRoutes(app *bootstrap.App) {
authAPI := app.Engine.Group("/api/auth")
authHandler := NewAuthHandler(app)
authAPI.GET("/password-key", authHandler.PasswordPublicKey)
authAPI.POST("/login", authHandler.Login)
authAPI.POST("/refresh", authHandler.Refresh)
@@ -59,6 +59,7 @@ func TestPublicRoutes_NoAuth(t *testing.T) {
method string
path string
}{
{http.MethodGet, "/api/auth/password-key"},
{http.MethodPost, "/api/auth/login"},
{http.MethodPost, "/api/auth/refresh"},
{http.MethodGet, "/api/health/live"},