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
+30 -1
View File
@@ -43,6 +43,7 @@ type App struct {
JWT *auth.Manager
Sessions *auth.SessionStore
LoginGuard *auth.LoginGuard
PasswordCipher *auth.PasswordCipher
LLM llm.Client
ReloadableLLM *llm.ReloadableClient
RetrievalProvider retrieval.Provider
@@ -116,6 +117,17 @@ func New(configPath string) (*App, error) {
jwtMgr := auth.NewManager(cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL)
sessions := auth.NewSessionStore(rdb)
passwordCipher, err := buildPasswordCipher(cfg.Auth.PasswordCipher)
if err != nil {
pool.Close()
monitoringPool.Close()
_ = rdb.Close()
_ = mqClient.Close()
return nil, fmt.Errorf("init password cipher: %w", err)
}
if passwordCipher == nil {
logger.Warn("auth password cipher disabled; login request payloads may expose plaintext passwords in browser developer tools")
}
loginGuardCfg := auth.DefaultLoginGuardConfig("tenant")
loginGuardCfg.Enabled = cfg.Auth.LoginGuard.Enabled
if !loginGuardCfg.Enabled {
@@ -173,8 +185,14 @@ func New(configPath string) (*App, error) {
middleware.Recovery(logger),
middleware.RequestID(),
middleware.Logger(logger),
middleware.CORS(),
)
if cfg.Server.SecurityHeaders.Enabled {
engine.Use(middleware.SecurityHeaders())
}
engine.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowedOrigins: cfg.Server.AllowedOrigins,
AllowAll: len(cfg.Server.AllowedOrigins) == 0,
}))
engine.GET("/api/health/live", func(c *gin.Context) {
response.Success(c, gin.H{"status": "alive"})
@@ -210,6 +228,7 @@ func New(configPath string) (*App, error) {
JWT: jwtMgr,
Sessions: sessions,
LoginGuard: loginGuard,
PasswordCipher: passwordCipher,
LLM: llmClient,
ReloadableLLM: llmClient,
RetrievalProvider: retrievalProvider,
@@ -237,6 +256,16 @@ func New(configPath string) (*App, error) {
}, nil
}
func buildPasswordCipher(cfg config.PasswordCipherConfig) (*auth.PasswordCipher, error) {
if !cfg.Enabled {
return nil, nil
}
if cfg.PrivateKeyPEM == "" {
return auth.NewEphemeralPasswordCipher(cfg.KeyID)
}
return auth.NewPasswordCipher(cfg.PrivateKeyPEM, cfg.KeyID)
}
func (a *App) Config() *config.Config {
if a == nil || a.ConfigStore == nil {
return nil
+49 -6
View File
@@ -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
+60
View File
@@ -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)
}
+43
View File
@@ -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
+22 -7
View File
@@ -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)
+18 -1
View File
@@ -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))
}
@@ -0,0 +1,128 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"strings"
"sync"
)
const DefaultPasswordCipherKeyID = "login-password-rsa-oaep-v1"
type PasswordCipher struct {
mu sync.RWMutex
privateKey *rsa.PrivateKey
keyID string
publicPEM string
}
type PasswordCipherPublicKey struct {
KeyID string `json:"key_id"`
Algorithm string `json:"algorithm"`
PublicKey string `json:"public_key"`
}
func NewPasswordCipher(privateKeyPEM, keyID string) (*PasswordCipher, error) {
privateKey, err := parseRSAPrivateKeyPEM(privateKeyPEM)
if err != nil {
return nil, err
}
return newPasswordCipherFromKey(privateKey, keyID)
}
func NewEphemeralPasswordCipher(keyID string) (*PasswordCipher, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generate ephemeral rsa private key: %w", err)
}
return newPasswordCipherFromKey(privateKey, keyID)
}
func newPasswordCipherFromKey(privateKey *rsa.PrivateKey, keyID string) (*PasswordCipher, error) {
if strings.TrimSpace(keyID) == "" {
keyID = DefaultPasswordCipherKeyID
}
publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshal rsa public key: %w", err)
}
publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
return &PasswordCipher{
privateKey: privateKey,
keyID: keyID,
publicPEM: publicPEM,
}, nil
}
func (c *PasswordCipher) PublicKey() PasswordCipherPublicKey {
if c == nil {
return PasswordCipherPublicKey{}
}
c.mu.RLock()
defer c.mu.RUnlock()
return PasswordCipherPublicKey{
KeyID: c.keyID,
Algorithm: "RSA-OAEP-256",
PublicKey: c.publicPEM,
}
}
func (c *PasswordCipher) Decrypt(ciphertextB64, keyID string) (string, error) {
if c == nil {
return "", fmt.Errorf("password cipher is not configured")
}
c.mu.RLock()
privateKey := c.privateKey
expectedKeyID := c.keyID
c.mu.RUnlock()
if strings.TrimSpace(keyID) != "" && strings.TrimSpace(keyID) != expectedKeyID {
return "", fmt.Errorf("password cipher key id mismatch")
}
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(ciphertextB64))
if err != nil {
return "", fmt.Errorf("decode encrypted password: %w", err)
}
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decrypt encrypted password: %w", err)
}
return string(plaintext), nil
}
func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, fmt.Errorf("password cipher private key is required")
}
block, _ := pem.Decode([]byte(trimmed))
if block == nil {
return nil, fmt.Errorf("password cipher private key is not PEM encoded")
}
switch block.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse pkcs1 rsa private key: %w", err)
}
return key, nil
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse pkcs8 private key: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not RSA")
}
return rsaKey, nil
default:
return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type)
}
}
@@ -0,0 +1,64 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"testing"
"github.com/stretchr/testify/require"
)
func TestPasswordCipherDecryptsRSAOAEP256(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 := NewPasswordCipher(privatePEM, "test-key")
require.NoError(t, err)
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Admin@123"), nil)
require.NoError(t, err)
got, err := cipher.Decrypt(base64.StdEncoding.EncodeToString(encrypted), "test-key")
require.NoError(t, err)
require.Equal(t, "Admin@123", got)
}
func TestPasswordCipherRejectsWrongKeyID(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 := NewPasswordCipher(privatePEM, "expected")
require.NoError(t, err)
_, err = cipher.Decrypt("bad", "other")
require.Error(t, err)
require.Contains(t, err.Error(), "key id mismatch")
}
func TestNewEphemeralPasswordCipherExposesPublicKey(t *testing.T) {
t.Parallel()
cipher, err := NewEphemeralPasswordCipher("ephemeral")
require.NoError(t, err)
publicKey := cipher.PublicKey()
require.Equal(t, "ephemeral", publicKey.KeyID)
require.Equal(t, "RSA-OAEP-256", publicKey.Algorithm)
require.Contains(t, publicKey.PublicKey, "BEGIN PUBLIC KEY")
}
+40 -6
View File
@@ -44,9 +44,15 @@ type Config struct {
}
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
TrustedProxies []string `mapstructure:"trusted_proxies"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
TrustedProxies []string `mapstructure:"trusted_proxies"`
AllowedOrigins []string `mapstructure:"allowed_origins"`
SecurityHeaders SecurityHeaders `mapstructure:"security_headers"`
}
type SecurityHeaders struct {
Enabled bool `mapstructure:"enabled"`
}
type DatabaseConfig struct {
@@ -296,13 +302,20 @@ type JWTConfig struct {
}
type AuthConfig struct {
LoginGuard LoginGuardConfig `mapstructure:"login_guard"`
LoginGuard LoginGuardConfig `mapstructure:"login_guard"`
PasswordCipher PasswordCipherConfig `mapstructure:"password_cipher"`
}
type LoginGuardConfig struct {
Enabled bool `mapstructure:"enabled"`
}
type PasswordCipherConfig struct {
Enabled bool `mapstructure:"enabled"`
KeyID string `mapstructure:"key_id"`
PrivateKeyPEM string `mapstructure:"private_key_pem"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
@@ -498,6 +511,11 @@ func decodeResolvedConfig(configFile, localConfigFile string) (*Config, error) {
}
func applyConfigDefaults(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
@@ -540,11 +558,12 @@ func NormalizeServerConfig(cfg *ServerConfig) {
return
}
cfg.Mode = strings.TrimSpace(cfg.Mode)
cfg.AllowedOrigins = compactStringSlice(cfg.AllowedOrigins)
if cfg.TrustedProxies == nil {
cfg.TrustedProxies = DefaultTrustedProxies()
return
} else {
cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies)
}
cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies)
}
func compactStringSlice(values []string) []string {
@@ -964,6 +983,21 @@ func applyEnvOverrides(cfg *Config) {
if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok {
cfg.Server.TrustedProxies = strings.Split(trustedProxies, ",")
}
if origins, ok := lookupNonEmptyEnv("SERVER_ALLOWED_ORIGINS"); ok {
cfg.Server.AllowedOrigins = strings.Split(origins, ",")
}
if enabled, ok := lookupBoolEnv("SERVER_SECURITY_HEADERS_ENABLED"); ok {
cfg.Server.SecurityHeaders.Enabled = enabled
}
if enabled, ok := lookupBoolEnv("AUTH_PASSWORD_CIPHER_ENABLED"); ok {
cfg.Auth.PasswordCipher.Enabled = enabled
}
if keyID, ok := lookupNonEmptyEnv("AUTH_PASSWORD_CIPHER_KEY_ID"); ok {
cfg.Auth.PasswordCipher.KeyID = keyID
}
if privateKey, ok := lookupNonEmptyEnv("AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM"); ok {
cfg.Auth.PasswordCipher.PrivateKeyPEM = privateKey
}
if path, ok := lookupNonEmptyEnv("IP_REGION_V4_XDB_PATH"); ok {
cfg.IPRegion.V4XDBPath = path
}
@@ -168,6 +168,32 @@ server:
}
}
func TestLoadAppliesServerSecurityDefaultsAndOriginOverrides(t *testing.T) {
t.Setenv("SERVER_ALLOWED_ORIGINS", "https://admin.example.com, https://ops.example.com")
configPath := writeTestConfig(t, `
server: {}
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.Server.SecurityHeaders.Enabled {
t.Fatalf("expected security headers enabled by default")
}
wantOrigins := []string{"https://admin.example.com", "https://ops.example.com"}
if len(cfg.Server.AllowedOrigins) != len(wantOrigins) {
t.Fatalf("allowed origins = %#v, want %#v", cfg.Server.AllowedOrigins, wantOrigins)
}
for i := range wantOrigins {
if cfg.Server.AllowedOrigins[i] != wantOrigins[i] {
t.Fatalf("allowed origins = %#v, want %#v", cfg.Server.AllowedOrigins, wantOrigins)
}
}
}
func TestLoadAppliesSchedulerHTTPDefaultsAndDisableSentinel(t *testing.T) {
defaultConfigPath := writeTestConfig(t, `
scheduler: {}
@@ -358,6 +384,34 @@ jwt:
}
}
func TestLoadPrefersEnvPasswordCipherSettings(t *testing.T) {
t.Setenv("AUTH_PASSWORD_CIPHER_ENABLED", "true")
t.Setenv("AUTH_PASSWORD_CIPHER_KEY_ID", "env-key")
t.Setenv("AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM", "pem-value")
configPath := writeTestConfig(t, `
auth:
password_cipher:
enabled: false
key_id: config-key
private_key_pem: config-pem
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.Auth.PasswordCipher.Enabled {
t.Fatalf("expected env password cipher enabled")
}
if cfg.Auth.PasswordCipher.KeyID != "env-key" {
t.Fatalf("expected env key id, got %q", cfg.Auth.PasswordCipher.KeyID)
}
if cfg.Auth.PasswordCipher.PrivateKeyPEM != "pem-value" {
t.Fatalf("expected env private key pem, got %q", cfg.Auth.PasswordCipher.PrivateKeyPEM)
}
}
func TestLoadAppliesMembershipDefaults(t *testing.T) {
configPath := writeTestConfig(t, `
membership: {}
+1 -13
View File
@@ -5,17 +5,5 @@ import (
)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID")
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
return CORSWithConfig(CORSConfig{AllowAll: true})
}
+30 -1
View File
@@ -1,6 +1,9 @@
package middleware
import (
"net/url"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -27,7 +30,7 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
fields = append(fields, zap.String("route", route))
}
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
fields = append(fields, zap.String("query", rawQuery))
fields = append(fields, zap.String("query", redactRawQuery(rawQuery)))
}
if userAgent := c.Request.UserAgent(); userAgent != "" {
fields = append(fields, zap.String("user_agent", userAgent))
@@ -86,3 +89,29 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
}
}
}
var sensitiveQueryKeyPattern = regexp.MustCompile(`(?i)(password|passwd|pwd|token|secret|authorization|credential|session|cookie|key|signature|sig)`)
func redactRawQuery(raw string) string {
values, err := url.ParseQuery(raw)
if err != nil {
return redactQueryFallback(raw)
}
for key := range values {
if sensitiveQueryKeyPattern.MatchString(key) {
values[key] = []string{"[REDACTED]"}
}
}
return values.Encode()
}
func redactQueryFallback(raw string) string {
parts := strings.Split(raw, "&")
for i, part := range parts {
key, _, ok := strings.Cut(part, "=")
if ok && sensitiveQueryKeyPattern.MatchString(key) {
parts[i] = key + "=[REDACTED]"
}
}
return strings.Join(parts, "&")
}
@@ -105,3 +105,36 @@ func TestLoggerUsesForwardedClientIPFromTrustedProxy(t *testing.T) {
require.Equal(t, http.StatusOK, resp.Code)
require.Contains(t, logBuffer.String(), `"client_ip":"106.14.89.27"`)
}
func TestLoggerRedactsSensitiveQueryValues(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
var logBuffer bytes.Buffer
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = ""
logger := zap.New(zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(&logBuffer),
zapcore.DebugLevel,
))
router := gin.New()
router.Use(RequestID())
router.Use(Logger(logger))
router.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/test?password=secret&tab=cards&token=abc", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
logLine := logBuffer.String()
require.Contains(t, logLine, `"query":"password=%5BREDACTED%5D&tab=cards&token=%5BREDACTED%5D"`)
require.NotContains(t, logLine, "secret")
require.NotContains(t, logLine, "token=abc")
}
@@ -0,0 +1,119 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.Writer.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("X-Permitted-Cross-Domain-Policies", "none")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
if isHTTPSRequest(c) {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
if isSensitivePath(c.Request.URL.Path) {
h.Set("Cache-Control", "no-store")
h.Set("Pragma", "no-cache")
}
c.Next()
}
}
func isHTTPSRequest(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
func isSensitivePath(path string) bool {
switch strings.TrimSpace(path) {
case "/api/auth/login",
"/api/auth/refresh",
"/api/auth/me",
"/api/auth/logout",
"/api/auth/password-key",
"/api/ops/auth/login",
"/api/ops/auth/me",
"/api/ops/auth/password",
"/api/ops/auth/password-key":
return true
default:
return false
}
}
type CORSConfig struct {
AllowedOrigins []string
AllowAll bool
}
func CORSWithConfig(cfg CORSConfig) gin.HandlerFunc {
allowedOrigins := compactOrigins(cfg.AllowedOrigins)
allowedSet := make(map[string]struct{}, len(allowedOrigins))
for _, origin := range allowedOrigins {
allowedSet[origin] = struct{}{}
}
return func(c *gin.Context) {
origin := strings.TrimSpace(c.GetHeader("Origin"))
allowedOrigin := ""
switch {
case cfg.AllowAll:
allowedOrigin = "*"
case origin != "":
if _, ok := allowedSet[origin]; ok {
allowedOrigin = origin
}
}
if allowedOrigin != "" {
c.Header("Access-Control-Allow-Origin", allowedOrigin)
if allowedOrigin != "*" {
c.Header("Vary", "Origin")
}
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID, X-Internal-Metrics-Token")
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == http.MethodOptions {
if origin != "" && allowedOrigin == "" {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func compactOrigins(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
@@ -0,0 +1,75 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestSecurityHeadersAddsNoStoreForSensitiveAuthPaths(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SecurityHeaders())
router.POST("/api/auth/login", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.Header.Set("X-Forwarded-Proto", "https")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
require.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options"))
require.Equal(t, "DENY", resp.Header().Get("X-Frame-Options"))
require.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
require.Equal(t, "no-cache", resp.Header().Get("Pragma"))
require.Equal(t, "max-age=31536000; includeSubDomains", resp.Header().Get("Strict-Transport-Security"))
}
func TestCORSWithConfigRejectsUnknownPreflightOrigin(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://admin.example.com"}}))
router.POST("/api/auth/login", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
req.Header.Set("Origin", "https://evil.example.com")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusForbidden, resp.Code)
require.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
}
func TestCORSWithConfigAllowsConfiguredOrigin(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://admin.example.com"}}))
router.POST("/api/auth/login", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
req.Header.Set("Origin", "https://admin.example.com")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNoContent, resp.Code)
require.Equal(t, "https://admin.example.com", resp.Header().Get("Access-Control-Allow-Origin"))
require.Equal(t, "Origin", resp.Header().Get("Vary"))
}
@@ -19,10 +19,11 @@ var routeDocs = map[string]routeDoc{
"GET /api/health/ready": {"就绪探针", "Kubernetes readiness 探针,依赖(数据库等)就绪后返回 200。"},
// --- Auth ---
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
"GET /api/auth/password-key": {"登录密码加密公钥", "返回登录密码前端加密使用的临时公钥与 key_id。"},
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
// --- Public 资源 ---
"GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"},
+2 -1
View File
@@ -498,7 +498,8 @@ func errorResponse(description string) map[string]any {
}
func securityForPath(path string) []map[string][]string {
if path == "/api/auth/login" || path == "/api/auth/refresh" ||
if path == "/api/auth/password-key" ||
path == "/api/auth/login" || path == "/api/auth/refresh" ||
strings.HasPrefix(path, "/api/health/") ||
strings.HasPrefix(path, "/api/public/") {
return nil
+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"},