fix: harden login for MLPS requirements
This commit is contained in:
@@ -117,6 +117,7 @@ type AuthService struct {
|
||||
audits *AuditService
|
||||
issuer *TokenIssuer
|
||||
guard *sharedauth.LoginGuard
|
||||
cipher *sharedauth.PasswordCipher
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
@@ -132,12 +133,27 @@ func NewAuthService(
|
||||
return &AuthService{accounts: accounts, audits: audits, issuer: issuer, guard: guard}
|
||||
}
|
||||
|
||||
func (s *AuthService) WithPasswordCipher(cipher *sharedauth.PasswordCipher) *AuthService {
|
||||
s.cipher = cipher
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AuthService) PasswordPublicKey() *sharedauth.PasswordCipherPublicKey {
|
||||
if s == nil || s.cipher == nil {
|
||||
return nil
|
||||
}
|
||||
publicKey := s.cipher.PublicKey()
|
||||
return &publicKey
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Username string
|
||||
Password string
|
||||
IP string
|
||||
UserAgent string
|
||||
RequestID string
|
||||
Username string
|
||||
Password string
|
||||
EncryptedPassword string
|
||||
PasswordKeyID string
|
||||
IP string
|
||||
UserAgent string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
@@ -176,6 +192,10 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
||||
if in.Username == "" {
|
||||
return nil, response.ErrBadRequest(40000, "invalid_payload", "username is required")
|
||||
}
|
||||
password, err := s.loginPassword(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
permit, err := s.acquireLoginPermit(ctx, in)
|
||||
if err != nil {
|
||||
@@ -183,6 +203,9 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
||||
}
|
||||
credentialsVerified := false
|
||||
defer func() {
|
||||
if permit == nil {
|
||||
return
|
||||
}
|
||||
if credentialsVerified {
|
||||
permit.RecordSuccess(ctx)
|
||||
return
|
||||
@@ -203,7 +226,7 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
||||
return nil, response.ErrForbidden(40310, "account_disabled", "账号已停用")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.Password)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(password)); err != nil {
|
||||
permit.RecordFailure(ctx)
|
||||
s.recordLoginFailure(ctx, in, "wrong_password")
|
||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
||||
@@ -237,6 +260,26 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) loginPassword(in LoginInput) (string, error) {
|
||||
if strings.TrimSpace(in.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(in.EncryptedPassword, in.PasswordKeyID)
|
||||
if err != nil {
|
||||
return "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
|
||||
}
|
||||
if password == "" {
|
||||
return "", response.ErrBadRequest(40000, "invalid_payload", "password is required")
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
if in.Password == "" {
|
||||
return "", response.ErrBadRequest(40000, "invalid_payload", "password is required")
|
||||
}
|
||||
return in.Password, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) acquireLoginPermit(ctx context.Context, in LoginInput) (*sharedauth.LoginPermit, error) {
|
||||
if s.guard == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpsAuthLoginPasswordDecryptsEncryptedPassword(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 := sharedauth.NewPasswordCipher(privatePEM, "ops-test-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Ops@1234"), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := (&AuthService{}).WithPasswordCipher(cipher)
|
||||
got, err := svc.loginPassword(LoginInput{
|
||||
EncryptedPassword: base64.StdEncoding.EncodeToString(encrypted),
|
||||
PasswordKeyID: "ops-test-key",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Ops@1234", got)
|
||||
}
|
||||
|
||||
func TestOpsAuthLoginPasswordAllowsPlainPasswordCompatibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := (&AuthService{}).loginPassword(LoginInput{Password: "Ops@1234"})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Ops@1234", got)
|
||||
}
|
||||
|
||||
func TestOpsAuthLoginPasswordRejectsEncryptedWhenCipherMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := (&AuthService{}).loginPassword(LoginInput{EncryptedPassword: "abc"})
|
||||
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*response.AppError)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "password_cipher_unavailable", appErr.Message)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type Config struct {
|
||||
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Auth sharedconfig.AuthConfig `mapstructure:"auth"`
|
||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
@@ -245,6 +246,9 @@ func Diff(previous, current *Config) []FieldChange {
|
||||
if previous.JWT != current.JWT {
|
||||
add("jwt", true)
|
||||
}
|
||||
if previous.Auth != current.Auth {
|
||||
add("auth", false)
|
||||
}
|
||||
if previous.AdminUsers != current.AdminUsers {
|
||||
add("admin_users", true)
|
||||
}
|
||||
@@ -310,6 +314,11 @@ func load(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
func applySharedDefaults(settings map[string]any) {
|
||||
serverSettings := ensureMapSetting(settings, "server")
|
||||
securityHeadersSettings := ensureMapSetting(serverSettings, "security_headers")
|
||||
if _, ok := securityHeadersSettings["enabled"]; !ok {
|
||||
securityHeadersSettings["enabled"] = true
|
||||
}
|
||||
cacheSettings := ensureMapSetting(settings, "cache")
|
||||
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
||||
cacheSettings["metrics_enabled"] = true
|
||||
@@ -429,12 +438,46 @@ func applyEnvOverrides(cfg *Config) error {
|
||||
if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); v != "" {
|
||||
cfg.Server.TrustedProxies = strings.Split(v, ",")
|
||||
}
|
||||
if v := os.Getenv("OPS_SERVER_ALLOWED_ORIGINS"); v != "" {
|
||||
cfg.Server.AllowedOrigins = strings.Split(v, ",")
|
||||
}
|
||||
if v := os.Getenv("OPS_SERVER_SECURITY_HEADERS_ENABLED"); v != "" {
|
||||
enabled, err := parseBoolEnv("OPS_SERVER_SECURITY_HEADERS_ENABLED", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Server.SecurityHeaders.Enabled = enabled
|
||||
}
|
||||
if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_ENABLED"); v != "" {
|
||||
enabled, err := parseBoolEnv("OPS_AUTH_PASSWORD_CIPHER_ENABLED", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Auth.PasswordCipher.Enabled = enabled
|
||||
}
|
||||
if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_KEY_ID"); v != "" {
|
||||
cfg.Auth.PasswordCipher.KeyID = v
|
||||
}
|
||||
if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM"); v != "" {
|
||||
cfg.Auth.PasswordCipher.PrivateKeyPEM = v
|
||||
}
|
||||
if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" {
|
||||
cfg.RabbitMQ.URL = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBoolEnv(name, value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true, nil
|
||||
case "0", "false", "no", "n", "off":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("%s must be a boolean", name)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpsRabbitMQConfig(cfg *sharedconfig.RabbitMQConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
EncryptedPassword string `json:"encrypted_password"`
|
||||
PasswordKeyID string `json:"password_key_id"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
@@ -28,11 +30,13 @@ func loginHandler(svc *app.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
result, err := svc.Login(c.Request.Context(), app.LoginInput{
|
||||
Username: body.Username,
|
||||
Password: body.Password,
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
RequestID: middleware.RequestIDFromGin(c),
|
||||
Username: body.Username,
|
||||
Password: body.Password,
|
||||
EncryptedPassword: body.EncryptedPassword,
|
||||
PasswordKeyID: body.PasswordKeyID,
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
RequestID: middleware.RequestIDFromGin(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
@@ -47,6 +51,17 @@ func loginHandler(svc *app.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func passwordPublicKeyHandler(svc *app.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := svc.PasswordPublicKey()
|
||||
if key == nil {
|
||||
response.Error(c, response.ErrNotFound(40420, "password_cipher_disabled", "password cipher is disabled"))
|
||||
return
|
||||
}
|
||||
response.Success(c, key)
|
||||
}
|
||||
}
|
||||
|
||||
func meHandler(accounts *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
actor := actorFromGin(c)
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Engine *gin.Engine
|
||||
Server sharedconfig.ServerConfig
|
||||
Logger *zap.Logger
|
||||
DB *pgxpool.Pool
|
||||
MonitoringDB *pgxpool.Pool
|
||||
@@ -26,13 +28,27 @@ type Deps struct {
|
||||
Compliance *app.ComplianceService
|
||||
}
|
||||
|
||||
func (d Deps) ServerAllowedOrigins() []string {
|
||||
return d.Server.AllowedOrigins
|
||||
}
|
||||
|
||||
func (d Deps) ServerSecurityHeadersEnabled() bool {
|
||||
return d.Server.SecurityHeaders.Enabled
|
||||
}
|
||||
|
||||
func RegisterRoutes(d Deps) {
|
||||
d.Engine.Use(
|
||||
middleware.Recovery(d.Logger),
|
||||
middleware.RequestID(),
|
||||
middleware.Logger(d.Logger),
|
||||
middleware.CORS(),
|
||||
)
|
||||
if d.ServerSecurityHeadersEnabled() {
|
||||
d.Engine.Use(middleware.SecurityHeaders())
|
||||
}
|
||||
d.Engine.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowedOrigins: d.ServerAllowedOrigins(),
|
||||
AllowAll: len(d.ServerAllowedOrigins()) == 0,
|
||||
}))
|
||||
|
||||
d.Engine.GET("/api/health/live", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"status": "alive"})
|
||||
@@ -53,6 +69,7 @@ func RegisterRoutes(d Deps) {
|
||||
|
||||
api := d.Engine.Group("/api/ops")
|
||||
{
|
||||
api.GET("/auth/password-key", passwordPublicKeyHandler(d.Auth))
|
||||
api.POST("/auth/login", loginHandler(d.Auth))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user