fix(security): behebe F-01/F-02/W-03/W-04 aus Security-Audit + PROJ-24 TOTP 2FA
F-01: err.Error() wird nicht mehr an HTTP-Clients gesendet.
Stattdessen generische Fehlermeldungen + Server-Log.
Betrifft: handleCreateUser, handleUpdateUser, handleDeleteUser,
handleSyncNow, handleSecurityConfig, handleUpload.
F-02: Login-Audit-Log enthält keinen rohen err.Error() mehr.
Neue classifyLoginError() Funktion: invalid_password / ldap_error /
account_disabled / unknown — schützt vor LDAP-Info-Leak via Audit-API.
W-03: remoteIP() trimmt jetzt Leerzeichen aus X-Forwarded-For.
Vollständige Lösung erfordert Proxy-Konfiguration (W-03 bleibt WARN).
W-04: Attachment-Dateiname wird durch sanitizeFilename() bereinigt.
Nur [a-zA-Z0-9._- ] erlaubt — verhindert Header-Injection.
PROJ-24: TOTP 2FA vollständig implementiert:
- internal/auth/totp.go: GenerateSecret, ValidateTOTP, QRCodeSVG
- internal/api/totp_handlers.go: Setup, Login-Step2, Admin-Reset
- internal/userstore: SetTOTPSecret, EnableTOTP, DisableTOTP, ResetTOTP
- Login-Flow: totp_pending JWT → /api/auth/totp → vollwertiger JWT
- AES-256-GCM verschlüsseltes Secret in users.totp_secret
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+190
-7
@@ -2,10 +2,13 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,17 +40,21 @@ type Manager struct {
|
||||
store *userstore.Store
|
||||
ldapStore *ldapcfg.Store
|
||||
jwtSecret []byte
|
||||
aesKey []byte // PROJ-24: AES-256-GCM key for TOTP secret encryption
|
||||
tenantLdapStore *ldapcfg.TenantStore // PROJ-23: per-tenant LDAP config
|
||||
tenantLookup TenantDomainLookup // PROJ-23: domain -> tenant_id resolution
|
||||
}
|
||||
|
||||
// New creates a new auth Manager.
|
||||
// ldapStore may be nil; in that case LDAP fallback is disabled.
|
||||
func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string) *Manager {
|
||||
// aesKey is the hex-encoded AES-256 key for encrypting TOTP secrets.
|
||||
func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string, aesKey string) *Manager {
|
||||
aesKeyBytes, _ := hex.DecodeString(aesKey)
|
||||
return &Manager{
|
||||
store: store,
|
||||
ldapStore: ldapStore,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
aesKey: aesKeyBytes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +68,18 @@ func (m *Manager) SetTenantLDAP(tenantLdapStore *ldapcfg.TenantStore, tenantLook
|
||||
// Login verifies credentials and returns a signed JWT token.
|
||||
// It first attempts a local password check. If that fails and LDAP is
|
||||
// configured and enabled, it falls back to LDAP authentication.
|
||||
func (m *Manager) Login(username, password string) (string, *userstore.User, error) {
|
||||
// If the user has TOTP enabled, totpRequired is true and the token is a
|
||||
// short-lived pending token that can only be used with ValidateTOTPLogin.
|
||||
func (m *Manager) Login(username, password string) (token string, user *userstore.User, totpRequired bool, err error) {
|
||||
// 1. Try local authentication first.
|
||||
user, err := m.store.VerifyPassword(username, password)
|
||||
user, err = m.store.VerifyPassword(username, password)
|
||||
if err == nil {
|
||||
return m.issueToken(user)
|
||||
if user.TOTPEnabled {
|
||||
t, e := m.issuePendingTOTPToken(user)
|
||||
return t, user, true, e
|
||||
}
|
||||
t, u, e := m.issueToken(user)
|
||||
return t, u, false, e
|
||||
}
|
||||
|
||||
// 2. PROJ-23: Per-tenant LDAP — checked first so tenant config takes priority
|
||||
@@ -107,7 +121,12 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
|
||||
}
|
||||
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID)
|
||||
if upsertErr == nil {
|
||||
return m.issueToken(ldapUser)
|
||||
if ldapUser.TOTPEnabled {
|
||||
t, e := m.issuePendingTOTPToken(ldapUser)
|
||||
return t, ldapUser, true, e
|
||||
}
|
||||
t, u, e := m.issueToken(ldapUser)
|
||||
return t, u, false, e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,13 +167,18 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
|
||||
}
|
||||
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, nil)
|
||||
if upsertErr == nil {
|
||||
return m.issueToken(ldapUser)
|
||||
if ldapUser.TOTPEnabled {
|
||||
t, e := m.issuePendingTOTPToken(ldapUser)
|
||||
return t, ldapUser, true, e
|
||||
}
|
||||
t, u, e := m.issueToken(ldapUser)
|
||||
return t, u, false, e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil, fmt.Errorf("auth: login: invalid credentials")
|
||||
return "", nil, false, fmt.Errorf("auth: login: invalid credentials")
|
||||
}
|
||||
|
||||
// issueToken signs a JWT for the given user and returns the token string.
|
||||
@@ -186,6 +210,99 @@ func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, err
|
||||
return signed, user, nil
|
||||
}
|
||||
|
||||
// issuePendingTOTPToken issues a short-lived JWT (5 min) that signals TOTP is required.
|
||||
// This token MUST NOT be accepted as a full auth token — only for /api/auth/totp.
|
||||
func (m *Manager) issuePendingTOTPToken(user *userstore.User) (string, error) {
|
||||
jti := generateJTI()
|
||||
now := time.Now()
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.Username,
|
||||
"uid": user.ID,
|
||||
"jti": jti,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(5 * time.Minute).Unix(),
|
||||
"totp_pending": true,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(m.jwtSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: sign pending totp token: %w", err)
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// ValidateTOTPLogin validates a pending TOTP token and TOTP code, then issues a full JWT.
|
||||
func (m *Manager) ValidateTOTPLogin(pendingToken, code string) (string, *userstore.User, error) {
|
||||
// Parse the pending token
|
||||
token, err := jwt.Parse(pendingToken, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return m.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("auth: invalid pending token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return "", nil, errors.New("auth: pending token not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", nil, errors.New("auth: bad claims")
|
||||
}
|
||||
|
||||
// Verify this is actually a pending TOTP token
|
||||
pending, _ := claims["totp_pending"].(bool)
|
||||
if !pending {
|
||||
return "", nil, errors.New("auth: not a pending TOTP token")
|
||||
}
|
||||
|
||||
// Extract user ID
|
||||
var userID int64
|
||||
switch v := claims["uid"].(type) {
|
||||
case float64:
|
||||
userID = int64(v)
|
||||
case int64:
|
||||
userID = v
|
||||
default:
|
||||
return "", nil, errors.New("auth: missing uid in pending token")
|
||||
}
|
||||
|
||||
// Load user from DB
|
||||
user, err := m.store.GetByID(userID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("auth: user lookup: %w", err)
|
||||
}
|
||||
if !user.TOTPEnabled {
|
||||
return "", nil, errors.New("auth: TOTP not enabled for this user")
|
||||
}
|
||||
|
||||
// Load and decrypt TOTP secret
|
||||
encSecret, enabled, err := m.store.GetTOTPSecret(context.Background(), userID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("auth: get totp secret: %w", err)
|
||||
}
|
||||
if !enabled || len(encSecret) == 0 {
|
||||
return "", nil, errors.New("auth: TOTP not configured")
|
||||
}
|
||||
|
||||
plainSecret, err := m.decryptAES(encSecret)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("auth: decrypt totp secret: %w", err)
|
||||
}
|
||||
|
||||
// Validate the TOTP code
|
||||
if !ValidateTOTP(string(plainSecret), code) {
|
||||
return "", nil, errors.New("auth: invalid TOTP code")
|
||||
}
|
||||
|
||||
// Issue a full auth token
|
||||
return m.issueToken(user)
|
||||
}
|
||||
|
||||
// ValidateToken parses and validates the token, checking the blacklist.
|
||||
func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||
@@ -206,6 +323,11 @@ func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
|
||||
return nil, errors.New("auth: bad claims")
|
||||
}
|
||||
|
||||
// PROJ-24: Reject pending TOTP tokens — they must not be used as full auth tokens.
|
||||
if pending, _ := claims["totp_pending"].(bool); pending {
|
||||
return nil, errors.New("auth: pending TOTP token cannot be used for authentication")
|
||||
}
|
||||
|
||||
jti, _ := claims["jti"].(string)
|
||||
blacklisted, err := m.store.IsBlacklisted(jti)
|
||||
if err != nil {
|
||||
@@ -361,3 +483,64 @@ func generateJTI() string {
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ── PROJ-24: AES-256-GCM encryption helpers for TOTP secrets ─────────────
|
||||
|
||||
// EncryptAES encrypts plaintext using AES-256-GCM with the manager's AES key.
|
||||
// The 12-byte nonce is prepended to the ciphertext.
|
||||
func (m *Manager) EncryptAES(plaintext []byte) ([]byte, error) {
|
||||
return encryptAESGCM(m.aesKey, plaintext)
|
||||
}
|
||||
|
||||
// decryptAES decrypts data encrypted with EncryptAES.
|
||||
func (m *Manager) decryptAES(data []byte) ([]byte, error) {
|
||||
return decryptAESGCM(m.aesKey, data)
|
||||
}
|
||||
|
||||
// DecryptAESForHandler exposes AES decryption for use in API handlers (e.g., TOTP setup confirmation).
|
||||
func (m *Manager) DecryptAESForHandler(data []byte) ([]byte, error) {
|
||||
return decryptAESGCM(m.aesKey, data)
|
||||
}
|
||||
|
||||
func encryptAESGCM(key, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: aes cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: gcm: %w", err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("auth: random nonce: %w", err)
|
||||
}
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func decryptAESGCM(key, data []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: aes cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: gcm: %w", err)
|
||||
}
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return nil, fmt.Errorf("auth: ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: decrypt: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// GetUserStore returns the underlying user store (used by TOTP handlers).
|
||||
func (m *Manager) GetUserStore() *userstore.Store {
|
||||
return m.store
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user