Files
nexarch/internal/lockout/lockout.go
sysopsandClaude Sonnet 5 4de9310213
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
AUD-06: audit-log-verdrahtung-in-sicherheitsrelevante-core-handler
Schliesst die in QA-05 gefundene Luecke: der zentrale Audit-Log (AUD-01/02)
existierte und war getestet, wurde aber von keinem Produktions-Handler
befuellt. Additive WithAudit(...)-Methode je Store (Konvention aus
lockout.Store.WithPolicy uebernommen, audit==nil bleibt gueltig, kein
Verhaltensbruch fuer bestehende Aufrufer):

- internal/policy.Store.Grant/Revoke -> policy.grant/policy.revoke
- internal/tenant.Registry (Suspend/Reactivate/ScheduleDeletion/
  CancelDeletion via transition) -> tenant.transition
- internal/lockout.Store.RecordFailure/Unlock -> auth.login_failed/
  auth.account_locked/auth.account_unlocked
- internal/kek.Store.RotateTenantKEK/RotateMasterKey -> kek.tenant_rotated/
  kek.master_rotated

Neues Testpaket internal/audit/wiring_test.go: fuer jeden der vier Bereiche
eine reale Aktion ausgefuehrt und per direkter audit_events-Abfrage
nachgewiesen (derselbe Nachweisstil wie der QA-05-Stichprobenabgleich, der
die Luecke fand). Alle bestehenden Tests der vier Pakete bleiben gruen.
51/51 Pakete gruen auf 192.168.1.131.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-29 17:24:26 +02:00

140 lines
5.0 KiB
Go

// Package lockout implementiert Core IAM-07: Account-Lockout nach
// Fehlversuchen und Login-Rate-Limiting mit geteiltem, externem
// (Postgres-basiertem) Zustand — kein In-Process-Zaehler, der bei
// Mehrinstanzbetrieb aushebelbar waere (bekannter Fehler aus archivdms
// internal/auth/ratelimit.go).
package lockout
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
)
// DefaultMaxFailedAttempts/DefaultLockoutDuration sind explizit benannte
// Defaults, ueberschreibbar via WithPolicy.
const (
DefaultMaxFailedAttempts = 5
DefaultLockoutDuration = 15 * time.Minute
)
type Store struct {
pool *pgxpool.Pool
maxFailed int
lockoutDuration time.Duration
audit *audit.Log
tenantSlug string
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool, maxFailed: DefaultMaxFailedAttempts, lockoutDuration: DefaultLockoutDuration}
}
func (s *Store) WithPolicy(maxFailed int, lockoutDuration time.Duration) *Store {
return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration, audit: s.audit, tenantSlug: s.tenantSlug}
}
// WithAudit liefert einen Store, der Fehlversuche sowie Sperrung/Entsperrung
// zusaetzlich im zentralen, unveraenderlichen Audit-Log protokolliert
// (AUD-06) — login_attempts liegt in der Tenant-Datenbank, audit_events in
// der Registry-Datenbank, daher ein eigener, an die Registry gebundener
// audit.Log UND der Tenant-Slug (fuer den Tenant-Bezug im Event) noetig.
// Rein additiv, log == nil bleibt gueltig (z.B. bestehende Tests).
func (s *Store) WithAudit(log *audit.Log, tenantSlug string) *Store {
return &Store{pool: s.pool, maxFailed: s.maxFailed, lockoutDuration: s.lockoutDuration, audit: log, tenantSlug: tenantSlug}
}
// IsLocked prueft, ob ein Konto aktuell gesperrt ist. Eine abgelaufene
// Sperre gilt automatisch als nicht mehr gesperrt (Akzeptanzkriterium 3) —
// es ist keine explizite Entsperr-Aktion noetig, der Zeitvergleich reicht.
func (s *Store) IsLocked(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
err = s.pool.QueryRow(ctx, `
SELECT locked_until FROM login_attempts WHERE email = $1 AND locked_until IS NOT NULL
`, email).Scan(&lockedUntil)
if err != nil {
return false, time.Time{}, nil // kein Datensatz oder kein Lock -> nicht gesperrt
}
return time.Now().Before(lockedUntil), lockedUntil, nil
}
// RecordFailure erhoeht den Fehlversuchszaehler ATOMAR (UPSERT) und sperrt
// das Konto, sobald die Schwelle erreicht ist (Akzeptanzkriterium 1 / 2).
// Der Zustand liegt ausschliesslich in Postgres, mehrere Core-Instanzen
// teilen sich denselben Zaehler (Akzeptanzkriterium 2).
func (s *Store) RecordFailure(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
var failedCount int
var lockedUntilPtr *time.Time
err = s.pool.QueryRow(ctx, `
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
VALUES ($1, 1, CASE WHEN 1 >= $2 THEN now() + $3::interval ELSE NULL END, now())
ON CONFLICT (email) DO UPDATE SET
failed_count = login_attempts.failed_count + 1,
last_attempt_at = now(),
locked_until = CASE
WHEN login_attempts.failed_count + 1 >= $2 THEN now() + $3::interval
ELSE login_attempts.locked_until
END
RETURNING failed_count, locked_until
`, email, s.maxFailed, fmt.Sprintf("%f seconds", s.lockoutDuration.Seconds())).Scan(&failedCount, &lockedUntilPtr)
if err != nil {
return false, time.Time{}, fmt.Errorf("fehlversuch erfassen: %w", err)
}
locked = lockedUntilPtr != nil && time.Now().Before(*lockedUntilPtr)
if s.audit != nil {
action := "auth.login_failed"
if locked {
action = "auth.account_locked"
}
_ = s.audit.Record(ctx, audit.Event{
TenantSlug: s.tenantSlug,
Actor: email,
Action: action,
Target: email,
Metadata: map[string]any{"failed_count": failedCount},
})
}
if lockedUntilPtr != nil {
return locked, *lockedUntilPtr, nil
}
return false, time.Time{}, nil
}
// RecordSuccess setzt den Fehlversuchszaehler nach erfolgreichem Login zurueck.
func (s *Store) RecordSuccess(ctx context.Context, email string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
VALUES ($1, 0, NULL, now())
ON CONFLICT (email) DO UPDATE SET failed_count = 0, locked_until = NULL, last_attempt_at = now()
`, email)
if err != nil {
return fmt.Errorf("erfolgreichen login erfassen: %w", err)
}
return nil
}
// Unlock entsperrt ein Konto durch Administratoreingriff, unabhaengig von
// der Sperrzeit (Akzeptanzkriterium 3).
func (s *Store) Unlock(ctx context.Context, email string) error {
_, err := s.pool.Exec(ctx, `
UPDATE login_attempts SET failed_count = 0, locked_until = NULL WHERE email = $1
`, email)
if err != nil {
return fmt.Errorf("konto entsperren: %w", err)
}
if s.audit != nil {
_ = s.audit.Record(ctx, audit.Event{
TenantSlug: s.tenantSlug,
Actor: "admin",
Action: "auth.account_unlocked",
Target: email,
})
}
return nil
}