Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
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
140 lines
5.0 KiB
Go
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
|
|
}
|