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:
@@ -23,14 +23,17 @@ const (
|
||||
|
||||
// User represents a user account in the system.
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
Source string // "local" or "ldap"
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
TenantID *int64 `json:"tenant_id,omitempty"`
|
||||
ID int64
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
Source string // "local" or "ldap"
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
TenantID *int64 `json:"tenant_id,omitempty"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
TOTPResetAt *time.Time `json:"totp_reset_at,omitempty"`
|
||||
TOTPResetBy *string `json:"totp_reset_by,omitempty"`
|
||||
}
|
||||
|
||||
// CreateUserRequest holds parameters for creating a new user.
|
||||
@@ -99,6 +102,16 @@ func (s *Store) initSchema(ctx context.Context) error {
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_username_time ON login_attempts (username, attempted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users (tenant_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// PROJ-24: TOTP 2FA columns
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret BYTEA;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_reset_at TIMESTAMPTZ;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_reset_by TEXT;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -134,7 +147,7 @@ func (s *Store) Create(req CreateUserRequest) (*User, error) {
|
||||
func (s *Store) GetByID(id int64) (*User, error) {
|
||||
ctx := context.Background()
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users WHERE id = $1`, id,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE id = $1`, id,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
@@ -143,7 +156,7 @@ func (s *Store) GetByID(id int64) (*User, error) {
|
||||
func (s *Store) GetByUsername(username string) (*User, error) {
|
||||
ctx := context.Background()
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users WHERE username = $1`, username,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE username = $1`, username,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
@@ -153,13 +166,13 @@ func (s *Store) GetByUsername(username string) (*User, error) {
|
||||
func (s *Store) VerifyPassword(username, password string) (*User, error) {
|
||||
ctx := context.Background()
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, password_hash FROM users WHERE username = $1`,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, password_hash FROM users WHERE username = $1`,
|
||||
username,
|
||||
)
|
||||
|
||||
var u User
|
||||
var hash string
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &hash)
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &hash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("userstore: user not found")
|
||||
}
|
||||
@@ -231,10 +244,10 @@ func (s *Store) List(role string) ([]*User, error) {
|
||||
|
||||
if role == "" {
|
||||
rows, err = s.pool.Query(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users ORDER BY id`)
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users ORDER BY id`)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users WHERE role = $1 ORDER BY id`, role)
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE role = $1 ORDER BY id`, role)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: list: %w", err)
|
||||
@@ -255,7 +268,7 @@ func (s *Store) List(role string) ([]*User, error) {
|
||||
// ListByTenant returns all users belonging to a specific tenant.
|
||||
func (s *Store) ListByTenant(ctx context.Context, tenantID int64) ([]*User, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users WHERE tenant_id = $1 ORDER BY id`,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE tenant_id = $1 ORDER BY id`,
|
||||
tenantID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -381,7 +394,7 @@ func (s *Store) UpsertLDAPUser(username, email, role string, tenantID *int64) (*
|
||||
|
||||
func scanUser(row pgx.Row) (*User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID)
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("userstore: not found")
|
||||
}
|
||||
@@ -393,8 +406,63 @@ func scanUser(row pgx.Row) (*User, error) {
|
||||
|
||||
func scanUserRow(rows pgx.Rows) (*User, error) {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy); err != nil {
|
||||
return nil, fmt.Errorf("userstore: scan row: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// ── PROJ-24: TOTP 2FA Methods ────────────────────────────────────────────
|
||||
|
||||
// SetTOTPSecret stores the encrypted TOTP secret (not yet activated).
|
||||
func (s *Store) SetTOTPSecret(ctx context.Context, userID int64, encryptedSecret []byte) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE users SET totp_secret = $1 WHERE id = $2`, encryptedSecret, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("userstore: set totp secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableTOTP activates TOTP for the user (after code confirmation).
|
||||
func (s *Store) EnableTOTP(ctx context.Context, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE users SET totp_enabled = true WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("userstore: enable totp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableTOTP deactivates TOTP and removes the secret (user self-service).
|
||||
func (s *Store) DisableTOTP(ctx context.Context, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("userstore: disable totp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetTOTP resets TOTP for a user (admin action) and logs who performed the reset.
|
||||
func (s *Store) ResetTOTP(ctx context.Context, userID int64, resetBy string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE users SET totp_enabled = false, totp_secret = NULL, totp_reset_at = NOW(), totp_reset_by = $1 WHERE id = $2`,
|
||||
resetBy, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("userstore: reset totp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTOTPSecret returns the encrypted TOTP secret and enabled status for a user.
|
||||
func (s *Store) GetTOTPSecret(ctx context.Context, userID int64) (secret []byte, enabled bool, err error) {
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, userID,
|
||||
).Scan(&secret, &enabled)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, fmt.Errorf("userstore: user not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("userstore: get totp secret: %w", err)
|
||||
}
|
||||
return secret, enabled, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user