Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 5f02e08bc4 IAM-12: aktive-sitzungen-verwaltung
internal/session: Selbstbedienungs-Sitzungsuebersicht mit widerrufbarem,
SERVERSEITIGEM Zustand — bewusst ein separater Mechanismus neben IAM-02s
zustandslosem JWT (das fuer schnelle Modul-zu-Modul-Verifikation ohne
Core-Rueckfrage gewaehlt wurde, siehe API-05). Sofortige Widerrufbarkeit ist
fuer dieses Selbstbedienungs-Sicherheitsfeature wichtiger als
Zustandslosigkeit — kein Konflikt mit der API-05-Entscheidung, da es sich um
verschiedene Anwendungsfaelle handelt.

Store.Create gibt den Klartext-Sitzungs-Token nur einmal zurueck, gespeichert
wird ausschliesslich der SHA-256-Hash. Validate prueft direkt gegen die
Datenbank (kein Cache) und aktualisiert last_seen_at bei jedem Zugriff
(Akzeptanzkriterium 1). Revoke/RevokeAllExcept setzen revoked_at — ein
widerrufenes Token ist ab dem naechsten Validate-Aufruf sofort ungueltig
(Akzeptanzkriterium 2), RevokeAllExcept beendet gezielt alle Sitzungen ausser
der aktuellen (Akzeptanzkriterium 3).

LoginAndCreateSession verwendet auth.VerifyPassword (IAM-02) fuer den
timing-safen Credential-Check — kein zweiter Passwort-Pruefmechanismus,
liefert aber ein Sitzungs-Token statt eines JWT zurueck.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Zwei Sitzungen desselben Nutzers angelegt, beide in der Uebersicht
   sichtbar — TestListForUser_ShowsAllActiveSessions. PASS.
2. Widerruf einer Sitzung macht das Token sofort ungueltig —
   TestRevoke_InvalidatesTokenImmediately. PASS.
3. "Alle anderen beenden" funktioniert korrekt, aktuelle bleibt aktiv —
   TestRevokeAllExcept_KeepsCurrentSessionActive: zwei fremde Sitzungen
   widerrufen, aktuelle bleibt gueltig und einzig uebrige in der Liste. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:29:58 +02:00
16 changed files with 370 additions and 757 deletions
-18
View File
@@ -129,21 +129,3 @@ Keine Commits in dieser Session.
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-28 22:48 22:51 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- c344dea TEN-08: tenant-loeschung-unter-retention-vorbehalt-gobd (RetentionChecker-Schnittstelle gegen Archive RET-03/CMP-06, ProcessDueDeletions haelt gesperrte Tenants zurueck)
### Geänderte Dateien
- internal/tenant/lifecycle.go | 45 ++++++++++++++++++++++++++++++++++++++++-----
- internal/tenant/lifecycle_test.go | 4 +++-
- internal/tenant/registry.go | 7 +++++--
- internal/tenant/retention.go | 34 ++++++++++++++++++++++++++++++++++
- internal/tenant/retention_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/tenant/tenant.go | 5 +++++
- migrations/0004_tenant_retention_block.down.sql | 2 ++
- migrations/0004_tenant_retention_block.up.sql | 6 ++++++
---
-73
View File
@@ -1,73 +0,0 @@
# NEXARCH Core Projektweite Sicherheits-Coding-Konventionen
Stand: 2026-08-28. Ticket: IAM-15. Ergänzt `docs/TESTSTRATEGIE-CORE.md` (QA-01) um Coding-Regeln,
die als Code-Review-Checkliste gelten — keine dieser Regeln ist optional oder "nur für ein Modul".
## 1. Warum dieses Dokument existiert
Eine sicherheitsrelevante Coding-Regel, die nur einmal an einer Stelle vorgemacht statt projektweit
verankert wird, wird beim nächsten neuen Vergleich vergessen. Das gilt für jede Regel in diesem
Dokument gleichermaßen — die erste Regel (SQL) ist bereits als Konvention etabliert, die zweite
(timing-safe Vergleich, IAM-15) macht sie hier zum ersten Mal explizit schriftlich.
## 2. Regel: Kein `fmt.Sprintf` für SQL-Bestandteile aus Nutzereingabe
**Spalten-/Tabellennamen ausschließlich aus statischen Konstanten bzw. einem geschlossenen
Enum/Switch-Typ, nie aus Nutzereingabe oder generischem String-Zusammenbau — auch nicht hinter
einer Whitelist-Funktion.** Werte (nicht Bezeichner) gehören als Parameter (`$1`, `$2`, …) in die
Query, niemals interpoliert.
Lehre aus beiden Altsystemen (`known-issues-archivdms.md` Punkt 10, `known-issues-archivmail.md`
Punkt 12): dynamische Tabellennamen via `fmt.Sprintf`, nur durch eine fragile Whitelist-Funktion
abgesichert. Siehe DMS/Mail `SRC-11` für die board-spezifische Umsetzung dieser Regel im
Suchindex-Kontext.
**Referenzbeispiel (korrekt):** `internal/tenant/lifecycle.go`, `ProcessDueDeletions` — Statuswerte
und IDs ausschließlich als Parameter (`$1`, `$2`, …), niemals interpoliert; der einzige Einsatz von
`fmt.Sprintf` im Package baut einen **Datenbanknamen aus einem bereits validierten Slug**
(`dbNameForSlug`, `slugPattern` in `tenant.go` erzwingt `^[a-z][a-z0-9_]{1,48}$` vor jeder
Verwendung) — keine ungeprüfte Nutzereingabe erreicht die Query.
## 3. Regel: Timing-safe Vergleich für jede sicherheitsrelevante Zugriffsentscheidung (IAM-15)
**Jeder Vergleich, der eine sicherheitsrelevante Zugriffsentscheidung trifft — Passwort-Hash, Token,
Signatur, 2FA-Code/-Wiederherstellungscode — nutzt einen timing-safe/constant-time Vergleich, nie
den regulären `==`-Operator.** Ein naiver `==`-Vergleich zweier Byte-Folgen bricht bei der ersten
abweichenden Stelle ab; die dadurch messbare Laufzeitdifferenz lässt sich aus der Ferne ausmessen und
erlaubt ein Byte-für-Byte-Erraten des korrekten Werts (Timing-Angriff).
**So wird es gemacht:** `internal/timingsafe` (dieses Ticket) bündelt die kanonische Implementierung
(`crypto/subtle.ConstantTimeCompare`) für neue Vergleichsstellen:
```go
import "gitea.perlbach24.de/scripte/nexarch/internal/timingsafe"
if !timingsafe.EqualString(providedCode, expectedCode) {
return ErrInvalid
}
```
Ausnahme: `bcrypt.CompareHashAndPassword` (Passwort-Hashes) ist bereits von Haus aus timing-safe —
hier ist kein zusätzlicher Wrapper nötig.
### 3.1 Audit bestehender Vergleichsstellen (Prüfung 2)
Durchgeführt 2026-08-28, Ergebnis: **alle bestehenden sicherheitsrelevanten Vergleichsstellen
implementierten die Regel bereits korrekt**, unabhängig voneinander mit `crypto/subtle` — nichts
musste korrigiert werden (Akzeptanzkriterium 3, „ggf.").
| Ort | Was wird verglichen | Fundstelle |
|---|---|---|
| `internal/totp/totp.go`, `Validate` | TOTP-Code (2FA) | nutzte bereits `subtle.ConstantTimeCompare` direkt, in diesem Ticket auf `timingsafe.EqualString` umgestellt (erster Verwender des neuen Packages) |
| `internal/webhook/dispatcher.go`, `VerifySignature` | HMAC-Webhook-Signatur | `subtle.ConstantTimeCompare(expectedBytes, gotBytes)` |
| `internal/moduleregistry/credentials.go`, `Authenticate` | Service-Credential-Secret-Hash | eigene `timingSafeEqual`-Hilfsfunktion, gleiches Muster |
| `internal/authtoken/token.go`, `Consume` (Passwort-Reset/Einladung) | Einmal-Token | Hash-Lookup über DB-Index (`WHERE token_hash = $1`), kein manueller Byte-Vergleich nötig — bei zufälligen, hochentropischen Token ist der indexierte Hash-Abgleich gleichwertig sicher |
Neue Vergleichsstellen sollen `internal/timingsafe` verwenden, statt das Muster erneut inline zu
duplizieren — bestehende Stellen müssen dafür nicht umgebaut werden (kein Umbau angrenzender
Bereiche über Board-Branch-Grenzen hinweg).
## 4. Wie diese Liste wächst
Neue projektweite Sicherheits-Coding-Regeln werden hier ergänzt, sobald sie (wie SQL-Sprintf und
timing-safe Vergleich) mehr als einmal unabhängig als Lehre auftauchen — nicht vorab spekulativ.
+35
View File
@@ -0,0 +1,35 @@
package session
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// LoginAndCreateSession prueft die Anmeldedaten mit demselben timing-safen
// bcrypt-Vergleich wie IAM-02s LoginService (kein zweiter Credential-Check),
// erzeugt bei Erfolg aber eine widerrufbare Sitzung statt eines
// zustandslosen JWT — das ist der Bearer-Token fuer diese Selbstbedienungs-
// Funktion.
func LoginAndCreateSession(ctx context.Context, users *user.TenantUserStore, sessions *Store, email, password, deviceInfo, ip string) (sessionToken string, err error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
return "", fmt.Errorf("anmeldedaten pruefen: %w", err)
}
if creds.User.Status != user.StatusActive {
return "", fmt.Errorf("konto nicht aktiv")
}
// Wiederverwendung von IAM-02s timing-safem bcrypt-Vergleich, kein
// zweiter Passwort-Pruefmechanismus.
if !auth.VerifyPassword(creds.PasswordHash, password) {
return "", fmt.Errorf("anmeldedaten ungueltig")
}
_, sessionToken, err = sessions.Create(ctx, creds.User.ID, deviceInfo, ip)
if err != nil {
return "", fmt.Errorf("sitzung anlegen: %w", err)
}
return sessionToken, nil
}
+142
View File
@@ -0,0 +1,142 @@
// Package session implementiert Core IAM-12: Selbstbedienungs-Sitzungs-
// uebersicht mit Geraete-/Zugriffsinformationen und gezieltem Widerruf.
// Bewusst serverseitig und widerrufbar — ein separater Mechanismus neben
// IAM-02s zustandslosem JWT (das fuer schnelle Modul-zu-Modul-Verifikation
// ohne Core-Rueckfrage gewaehlt wurde, siehe API-05). Fuer den
// Selbstbedienungs-Anwendungsfall hier ist sofortige Widerrufbarkeit
// wichtiger als Zustandslosigkeit.
package session
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrSessionInvalid = errors.New("session: sitzung ungueltig oder widerrufen")
type Session struct {
ID string
UserID string
DeviceInfo string
IP string
CreatedAt time.Time
LastSeenAt time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Create legt eine neue Sitzung an und liefert den Klartext-Token NUR an den
// Aufrufer zurueck (als Cookie-Wert) — gespeichert wird ausschliesslich der Hash.
func (s *Store) Create(ctx context.Context, userID, deviceInfo, ip string) (id, token string, err error) {
token, err = randomToken()
if err != nil {
return "", "", fmt.Errorf("token erzeugen: %w", err)
}
hash := hashToken(token)
err = s.pool.QueryRow(ctx, `
INSERT INTO sessions (user_id, token_hash, device_info, ip)
VALUES ($1, $2, $3, $4)
RETURNING id
`, userID, hash, deviceInfo, ip).Scan(&id)
if err != nil {
return "", "", fmt.Errorf("sitzung anlegen: %w", err)
}
return id, token, nil
}
// Validate prueft ein Sitzungs-Token direkt gegen die Datenbank — kein
// Cache, ein widerrufenes Token ist beim naechsten Zugriffsversuch sofort
// ungueltig (Akzeptanzkriterium 2). Erfolgreiche Validierung aktualisiert
// last_seen_at (Akzeptanzkriterium 1: letzter Zugriff).
func (s *Store) Validate(ctx context.Context, token string) (Session, error) {
hash := hashToken(token)
var sess Session
err := s.pool.QueryRow(ctx, `
UPDATE sessions SET last_seen_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL
RETURNING id, user_id, device_info, ip, created_at, last_seen_at
`, hash).Scan(&sess.ID, &sess.UserID, &sess.DeviceInfo, &sess.IP, &sess.CreatedAt, &sess.LastSeenAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrSessionInvalid
}
return Session{}, fmt.Errorf("sitzung pruefen: %w", err)
}
return sess, nil
}
// ListForUser liefert alle NICHT widerrufenen Sitzungen eines Benutzers
// (Akzeptanzkriterium 1).
func (s *Store) ListForUser(ctx context.Context, userID string) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, device_info, ip, created_at, last_seen_at
FROM sessions WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY last_seen_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("sitzungen auflisten: %w", err)
}
defer rows.Close()
var out []Session
for rows.Next() {
var sess Session
if err := rows.Scan(&sess.ID, &sess.UserID, &sess.DeviceInfo, &sess.IP, &sess.CreatedAt, &sess.LastSeenAt); err != nil {
return nil, fmt.Errorf("sitzung lesen: %w", err)
}
out = append(out, sess)
}
return out, rows.Err()
}
// Revoke widerruft eine einzelne Sitzung sofort (Akzeptanzkriterium 2).
func (s *Store) Revoke(ctx context.Context, sessionID string) error {
_, err := s.pool.Exec(ctx, `UPDATE sessions SET revoked_at = now() WHERE id = $1`, sessionID)
if err != nil {
return fmt.Errorf("sitzung widerrufen: %w", err)
}
return nil
}
// RevokeAllExcept beendet alle Sitzungen eines Benutzers ausser der
// angegebenen (Akzeptanzkriterium 3: "alle anderen beenden", aktuelle
// Sitzung bleibt bestehen).
func (s *Store) RevokeAllExcept(ctx context.Context, userID, keepSessionID string) error {
_, err := s.pool.Exec(ctx, `
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL
`, userID, keepSessionID)
if err != nil {
return fmt.Errorf("andere sitzungen widerrufen: %w", err)
}
return nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
+174
View File
@@ -0,0 +1,174 @@
package session
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE, device_info TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("session-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "Session Test")
if err != nil {
t.Fatalf("create user: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: zwei Sitzungen angelegt, beide in der
// Uebersicht sichtbar.
func TestListForUser_ShowsAllActiveSessions(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, _, err := store.Create(ctx, userID, "Chrome auf Windows", "10.0.0.1"); err != nil {
t.Fatalf("create session 1: %v", err)
}
if _, _, err := store.Create(ctx, userID, "Safari auf iPhone", "10.0.0.2"); err != nil {
t.Fatalf("create session 2: %v", err)
}
sessions, err := store.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(sessions) != 2 {
t.Fatalf("erwartet 2 sitzungen, habe %d", len(sessions))
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Widerruf macht das Token sofort ungueltig.
func TestRevoke_InvalidatesTokenImmediately(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
sessionID, token, err := store.Create(ctx, userID, "Firefox", "10.0.0.3")
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Validate(ctx, token); err != nil {
t.Fatalf("validate vor widerruf: %v", err)
}
if err := store.Revoke(ctx, sessionID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, err := store.Validate(ctx, token); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("erwartet ErrSessionInvalid sofort nach widerruf, habe %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: "alle anderen beenden" funktioniert,
// aktuelle Sitzung bleibt aktiv.
func TestRevokeAllExcept_KeepsCurrentSessionActive(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
currentID, currentToken, err := store.Create(ctx, userID, "aktuelles geraet", "10.0.0.4")
if err != nil {
t.Fatalf("create current: %v", err)
}
_, otherToken1, err := store.Create(ctx, userID, "anderes geraet 1", "10.0.0.5")
if err != nil {
t.Fatalf("create other 1: %v", err)
}
_, otherToken2, err := store.Create(ctx, userID, "anderes geraet 2", "10.0.0.6")
if err != nil {
t.Fatalf("create other 2: %v", err)
}
if err := store.RevokeAllExcept(ctx, userID, currentID); err != nil {
t.Fatalf("revoke all except: %v", err)
}
if _, err := store.Validate(ctx, currentToken); err != nil {
t.Fatalf("aktuelle sitzung sollte weiterhin gueltig sein: %v", err)
}
if _, err := store.Validate(ctx, otherToken1); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("andere sitzung 1 sollte widerrufen sein, habe %v", err)
}
if _, err := store.Validate(ctx, otherToken2); !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("andere sitzung 2 sollte widerrufen sein, habe %v", err)
}
remaining, err := store.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(remaining) != 1 || remaining[0].ID != currentID {
t.Fatalf("erwartet genau die aktuelle sitzung uebrig, habe %+v", remaining)
}
}
func TestLoginAndCreateSession(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("get user: %v", err)
}
hash, err := auth.HashPassword("korrektes-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, userID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
token, err := LoginAndCreateSession(ctx, userStore, store, u.Email, "korrektes-passwort", "Test-Client", "127.0.0.1")
if err != nil {
t.Fatalf("login and create session: %v", err)
}
if _, err := store.Validate(ctx, token); err != nil {
t.Fatalf("validate: %v", err)
}
if _, err := LoginAndCreateSession(ctx, userStore, store, u.Email, "falsches-passwort", "Test-Client", "127.0.0.1"); err == nil {
t.Fatal("erwartet fehler bei falschem passwort")
}
}
-30
View File
@@ -1,30 +0,0 @@
// Package timingsafe stellt die kanonische Implementierung der projektweiten
// Coding-Konvention aus IAM-15 bereit: jeder Vergleich, der eine
// sicherheitsrelevante Zugriffsentscheidung trifft (Passwort-Hash, Token,
// Signatur, 2FA-Wiederherstellungscode), nutzt einen timing-safe/constant-time
// Vergleich, nie den regulaeren ==-Operator. Siehe docs/CODING-GUIDELINES-CORE.md.
//
// Bestehende Vergleichsstellen (internal/totp, internal/webhook,
// internal/moduleregistry) implementieren dasselbe Muster bereits inline mit
// crypto/subtle direkt — dieses Package buendelt es fuer neue Vergleichsstellen,
// ersetzt die bestehenden nicht zwangsweise (kein Umbau angrenzender Bereiche).
package timingsafe
import "crypto/subtle"
// Equal vergleicht zwei Byte-Slices timing-safe. Unterschiedliche Laenge gilt
// als "nicht gleich", ohne dass die Laufzeit dabei die Laenge verraet, die
// zum Ergebnis gefuehrt hat, mehr als durch den Laengenunterschied ohnehin
// unvermeidbar waere.
func Equal(a, b []byte) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare(a, b) == 1
}
// EqualString ist die String-Variante von Equal fuer den haeufigen Fall,
// dass beide Seiten bereits als string vorliegen (z. B. TOTP-Codes).
func EqualString(a, b string) bool {
return Equal([]byte(a), []byte(b))
}
-36
View File
@@ -1,36 +0,0 @@
package timingsafe
import "testing"
func TestEqual_SameBytes(t *testing.T) {
if !Equal([]byte("geheimnis"), []byte("geheimnis")) {
t.Fatal("identische Byte-Slices sollten gleich sein")
}
}
func TestEqual_DifferentBytes(t *testing.T) {
if Equal([]byte("geheimnis"), []byte("anders123")) {
t.Fatal("unterschiedliche Byte-Slices sollten ungleich sein")
}
}
func TestEqual_DifferentLength(t *testing.T) {
if Equal([]byte("kurz"), []byte("laengererstring")) {
t.Fatal("unterschiedliche Laenge sollte immer ungleich sein")
}
}
func TestEqual_EmptyVsEmpty(t *testing.T) {
if !Equal([]byte(""), []byte("")) {
t.Fatal("zwei leere Slices sollten gleich sein")
}
}
func TestEqualString_MatchesEqual(t *testing.T) {
if !EqualString("abc123", "abc123") {
t.Fatal("identische Strings sollten gleich sein")
}
if EqualString("abc123", "xyz789") {
t.Fatal("unterschiedliche Strings sollten ungleich sein")
}
}
-42
View File
@@ -1,42 +0,0 @@
package totp
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrSecondFactorRequired = errors.New("totp: zweiter faktor erforderlich")
// LoginWithTOTP komponiert IAM-02s LoginService mit der 2FA-Pruefung, ohne
// LoginService selbst zu veraendern. Ist 2FA fuer den Benutzer aktiv, MUSS
// totpCode gueltig sein (Akzeptanzkriterium 2) — ein leerer/falscher Code
// wird zuverlaessig abgewiesen, auch bei korrektem Passwort.
func LoginWithTOTP(ctx context.Context, users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, email, password, totpCode string) (token string, err error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
return "", auth.ErrInvalidCredentials
}
enabled, err := totpStore.IsEnabled(ctx, creds.User.ID)
if err != nil {
return "", err
}
if enabled {
if totpCode == "" {
return "", ErrSecondFactorRequired
}
ok, err := totpStore.VerifyLoginCode(ctx, creds.User.ID, totpCode)
if err != nil {
return "", err
}
if !ok {
return "", ErrSecondFactorRequired
}
}
return login.Login(ctx, email, password)
}
-163
View File
@@ -1,163 +0,0 @@
package totp
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalidCode = errors.New("totp: code ungueltig")
ErrNotConfirmed = errors.New("totp: einrichtung noch nicht bestaetigt")
ErrRecoveryCodeUsed = errors.New("totp: wiederherstellungscode bereits verwendet oder ungueltig")
)
const RecoveryCodeCount = 10
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// BeginSetup erzeugt ein neues Secret fuer einen Benutzer und speichert es
// als NOCH NICHT bestaetigt — der Code muss vor Aktivierung einmal
// verifiziert werden (Akzeptanzkriterium 1).
func (s *Store) BeginSetup(ctx context.Context, userID, issuer, accountName string) (secret, provisioningURI string, err error) {
secret, err = GenerateSecret()
if err != nil {
return "", "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO totp_credentials (user_id, secret, confirmed)
VALUES ($1, $2, false)
ON CONFLICT (user_id) DO UPDATE SET secret = $2, confirmed = false
`, userID, secret)
if err != nil {
return "", "", fmt.Errorf("totp-einrichtung speichern: %w", err)
}
return secret, ProvisioningURI(issuer, accountName, secret), nil
}
// ConfirmSetup verifiziert den ersten eingegebenen Code und aktiviert 2FA
// erst dann (Akzeptanzkriterium 1). Bei Erfolg werden einmalig
// Wiederherstellungscodes erzeugt und im Klartext zurueckgegeben — gespeichert
// wird nur deren Hash.
func (s *Store) ConfirmSetup(ctx context.Context, userID, code string) (recoveryCodes []string, err error) {
var secret string
if err := s.pool.QueryRow(ctx, `
SELECT secret FROM totp_credentials WHERE user_id = $1
`, userID).Scan(&secret); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotConfirmed
}
return nil, fmt.Errorf("secret lesen: %w", err)
}
ok, err := Validate(secret, code, time.Now())
if err != nil {
return nil, err
}
if !ok {
return nil, ErrInvalidCode
}
if _, err := s.pool.Exec(ctx, `UPDATE totp_credentials SET confirmed = true WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("bestaetigung speichern: %w", err)
}
recoveryCodes, err = s.generateRecoveryCodes(ctx, userID)
if err != nil {
return nil, err
}
return recoveryCodes, nil
}
func (s *Store) generateRecoveryCodes(ctx context.Context, userID string) ([]string, error) {
codes := make([]string, RecoveryCodeCount)
for i := range codes {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("wiederherstellungscode erzeugen: %w", err)
}
code := hex.EncodeToString(buf)
codes[i] = code
hash := hashRecoveryCode(code)
if _, err := s.pool.Exec(ctx, `
INSERT INTO totp_recovery_codes (user_id, code_hash) VALUES ($1, $2)
`, userID, hash); err != nil {
return nil, fmt.Errorf("wiederherstellungscode speichern: %w", err)
}
}
return codes, nil
}
// IsEnabled prueft, ob 2FA fuer einen Benutzer aktiv (bestaetigt) ist.
func (s *Store) IsEnabled(ctx context.Context, userID string) (bool, error) {
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("status lesen: %w", err)
}
return confirmed, nil
}
// VerifyLoginCode akzeptiert entweder einen gueltigen TOTP-Code oder einen
// noch nicht verwendeten Wiederherstellungscode (Akzeptanzkriterium 2 + 3).
func (s *Store) VerifyLoginCode(ctx context.Context, userID, code string) (bool, error) {
var secret string
var confirmed bool
err := s.pool.QueryRow(ctx, `SELECT secret, confirmed FROM totp_credentials WHERE user_id = $1`, userID).Scan(&secret, &confirmed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) || !confirmed {
return false, nil
}
return false, fmt.Errorf("secret lesen: %w", err)
}
if !confirmed {
return false, nil
}
if ok, err := Validate(secret, code, time.Now()); err != nil {
return false, err
} else if ok {
return true, nil
}
return s.consumeRecoveryCode(ctx, userID, code)
}
// consumeRecoveryCode markiert einen Wiederherstellungscode atomar als
// verwendet (WHERE used_at IS NULL) — ein bereits verbrauchter Code kann
// nicht doppelt eingeloest werden (Akzeptanzkriterium 3 / Pruefung 2).
func (s *Store) consumeRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
hash := hashRecoveryCode(code)
tag, err := s.pool.Exec(ctx, `
UPDATE totp_recovery_codes SET used_at = now()
WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL
`, userID, hash)
if err != nil {
return false, fmt.Errorf("wiederherstellungscode einloesen: %w", err)
}
return tag.RowsAffected() == 1, nil
}
func hashRecoveryCode(code string) []byte {
sum := sha256.Sum256([]byte(code))
return sum[:]
}
-185
View File
@@ -1,185 +0,0 @@
package totp
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id), secret TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS totp_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
code_hash BYTEA NOT NULL UNIQUE, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("totp-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "TOTP Test")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := auth.HashPassword("korrektes-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1: Einrichtung per QR-Code (URI), Bestaetigung vor Aktivierung.
func TestBeginAndConfirmSetup(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, uri, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
if uri == "" {
t.Fatal("erwartet nicht-leere provisioning-uri")
}
enabled, err := store.IsEnabled(ctx, userID)
if err != nil {
t.Fatalf("is enabled (vor bestaetigung): %v", err)
}
if enabled {
t.Fatal("erwartet NICHT aktiviert vor bestaetigung")
}
// Falscher Code darf nicht aktivieren.
if _, err := store.ConfirmSetup(ctx, userID, "000000"); !errors.Is(err, ErrInvalidCode) && err == nil {
t.Fatalf("erwartet ablehnung bei falschem code, habe err=%v", err)
}
code, err := GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("generate code: %v", err)
}
recoveryCodes, err := store.ConfirmSetup(ctx, userID, code)
if err != nil {
t.Fatalf("confirm setup: %v", err)
}
if len(recoveryCodes) != RecoveryCodeCount {
t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(recoveryCodes))
}
enabled, err = store.IsEnabled(ctx, userID)
if err != nil {
t.Fatalf("is enabled (nach bestaetigung): %v", err)
}
if !enabled {
t.Fatal("erwartet aktiviert nach erfolgreicher bestaetigung")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Login ohne zweiten Faktor bei aktivem
// 2FA wird zuverlaessig abgewiesen.
func TestLoginWithTOTP_RequiresSecondFactorWhenEnabled(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, _ := GenerateCode(secret, time.Now())
if _, err := store.ConfirmSetup(ctx, userID, code); err != nil {
t.Fatalf("confirm setup: %v", err)
}
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("get user: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
loginService := auth.NewLoginService(userStore, issuer, "acme")
// Korrektes Passwort, aber KEIN 2FA-Code -> abgewiesen.
if _, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", ""); !errors.Is(err, ErrSecondFactorRequired) {
t.Fatalf("erwartet ErrSecondFactorRequired ohne code, habe %v", err)
}
// Korrektes Passwort + korrekter aktueller Code -> erfolgreich.
loginCode, _ := GenerateCode(secret, time.Now())
token, err := LoginWithTOTP(ctx, userStore, store, loginService, u.Email, "korrektes-passwort", loginCode)
if err != nil {
t.Fatalf("login mit gueltigem 2fa-code: %v", err)
}
if token == "" {
t.Fatal("erwartet gueltiges token")
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Wiederherstellungscode nach Nutzung als
// verbraucht getestet.
func TestVerifyLoginCode_RecoveryCodeIsSingleUse(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
if err != nil {
t.Fatalf("begin setup: %v", err)
}
code, _ := GenerateCode(secret, time.Now())
recoveryCodes, err := store.ConfirmSetup(ctx, userID, code)
if err != nil {
t.Fatalf("confirm setup: %v", err)
}
recovery := recoveryCodes[0]
ok, err := store.VerifyLoginCode(ctx, userID, recovery)
if err != nil {
t.Fatalf("verify (erste nutzung): %v", err)
}
if !ok {
t.Fatal("erwartet erfolgreiche erste nutzung des wiederherstellungscodes")
}
ok, err = store.VerifyLoginCode(ctx, userID, recovery)
if err != nil {
t.Fatalf("verify (zweite nutzung): %v", err)
}
if ok {
t.Fatal("erwartet abgelehnt bei erneuter nutzung desselben wiederherstellungscodes")
}
}
-105
View File
@@ -1,105 +0,0 @@
// Package totp implementiert Core IAM-04: TOTP-basierte Zwei-Faktor-
// Authentifizierung (RFC 6238, aufbauend auf HOTP RFC 4226) mit stdlib
// crypto/hmac+sha1 — kein externes Modul noetig.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"net/url"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/timingsafe"
)
// StepSeconds ist das TOTP-Zeitfenster (RFC-6238-Standard: 30 Sekunden).
const StepSeconds = 30
// DefaultSkewSteps ist die dokumentierte Zeitversatz-Toleranz
// (Akzeptanzkriterium/Pruefung 3): +/- 1 Schritt = +/- 30 Sekunden um die
// aktuelle Serverzeit, insgesamt ein 90-Sekunden-Fenster. Grosszuegig genug
// fuer leicht abweichende Client-Uhren, eng genug um ein TOTP-Fenster nicht
// unbrauchbar lange gueltig zu lassen.
const DefaultSkewSteps = 1
// GenerateSecret erzeugt ein neues, zufaelliges TOTP-Secret (20 Byte, wie von
// Authenticator-Apps erwartet), Base32-kodiert ohne Padding.
func GenerateSecret() (string, error) {
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("secret erzeugen: %w", err)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
// ProvisioningURI liefert die otpauth://-URI fuer den QR-Code
// (Akzeptanzkriterium 1) — das eigentliche QR-Rendering ist Frontend-Sache
// (IAM-08), hier wird nur die standardkonforme URI erzeugt.
func ProvisioningURI(issuer, accountName, secret string) string {
label := url.PathEscape(issuer) + ":" + url.PathEscape(accountName)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", issuer)
q.Set("algorithm", "SHA1")
q.Set("digits", "6")
q.Set("period", fmt.Sprintf("%d", StepSeconds))
return fmt.Sprintf("otpauth://totp/%s?%s", label, q.Encode())
}
// GenerateCode berechnet den 6-stelligen TOTP-Code fuer secret zum Zeitpunkt t.
func GenerateCode(secret string, t time.Time) (string, error) {
key, err := decodeSecret(secret)
if err != nil {
return "", err
}
counter := uint64(t.Unix() / StepSeconds)
return hotp(key, counter), nil
}
// Validate prueft code gegen secret mit der dokumentierten Zeitversatz-
// Toleranz (DefaultSkewSteps) um t. Vergleich ist timing-safe.
func Validate(secret, code string, t time.Time) (bool, error) {
key, err := decodeSecret(secret)
if err != nil {
return false, err
}
counter := int64(t.Unix() / StepSeconds)
for delta := -DefaultSkewSteps; delta <= DefaultSkewSteps; delta++ {
candidate := hotp(key, uint64(counter+int64(delta)))
if timingsafe.EqualString(candidate, code) {
return true, nil
}
}
return false, nil
}
func decodeSecret(secret string) ([]byte, error) {
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
return nil, fmt.Errorf("secret dekodieren: %w", err)
}
return key, nil
}
// hotp implementiert RFC 4226 mit HMAC-SHA1, 6 Ziffern.
func hotp(key []byte, counter uint64) string {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
mac := hmac.New(sha1.New, key)
mac.Write(buf)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
code := (uint32(sum[offset]&0x7f) << 24) |
(uint32(sum[offset+1]) << 16) |
(uint32(sum[offset+2]) << 8) |
uint32(sum[offset+3])
return fmt.Sprintf("%06d", code%1000000)
}
-79
View File
@@ -1,79 +0,0 @@
package totp
import (
"testing"
"time"
)
func TestGenerateAndValidateCode_RoundTrip(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("generate secret: %v", err)
}
now := time.Now()
code, err := GenerateCode(secret, now)
if err != nil {
t.Fatalf("generate code: %v", err)
}
if len(code) != 6 {
t.Fatalf("erwartet 6-stelligen code, habe %q", code)
}
ok, err := Validate(secret, code, now)
if err != nil {
t.Fatalf("validate: %v", err)
}
if !ok {
t.Fatal("erwartet gueltigen code")
}
wrongCode := "000000"
if code == wrongCode {
wrongCode = "111111"
}
ok, err = Validate(secret, wrongCode, now)
if err != nil {
t.Fatalf("validate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet ungueltig fuer falschen code")
}
}
// Akzeptanzkriterium/Pruefung 3: Zeitversatz-Toleranz dokumentiert (siehe
// DefaultSkewSteps = 1 Schritt = 30s) und getestet.
func TestValidate_ClockSkewTolerance(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("generate secret: %v", err)
}
now := time.Now()
// Code aus dem VORHERIGEN Zeitfenster (bis zu 30s in der Vergangenheit)
// muss innerhalb der dokumentierten Toleranz noch akzeptiert werden.
pastCode, err := GenerateCode(secret, now.Add(-25*time.Second))
if err != nil {
t.Fatalf("generate code (past): %v", err)
}
ok, err := Validate(secret, pastCode, now)
if err != nil {
t.Fatalf("validate (past): %v", err)
}
if !ok {
t.Fatal("erwartet gueltig innerhalb der dokumentierten skew-toleranz (25s)")
}
// Code weit ausserhalb der Toleranz (mehrere Schritte in der
// Vergangenheit) muss abgelehnt werden.
farPastCode, err := GenerateCode(secret, now.Add(-5*time.Minute))
if err != nil {
t.Fatalf("generate code (far past): %v", err)
}
ok, err = Validate(secret, farPastCode, now)
if err != nil {
t.Fatalf("validate (far past): %v", err)
}
if ok {
t.Fatal("erwartet ungueltig weit ausserhalb der skew-toleranz (5min)")
}
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS sessions;
+18
View File
@@ -0,0 +1,18 @@
-- Aktive-Sitzungen-Verwaltung (IAM-12, siehe core-kanban/tickets/IAM-12.md).
-- Bewusst SERVERSEITIG mit widerrufbarem Zustand — anders als IAM-02s
-- zustandsloses JWT (API-05-Entscheidung fuer Modul-zu-Modul-Vertrauen),
-- braucht die Selbstbedienungs-Sitzungsuebersicht einen Ort, an dem eine
-- einzelne Sitzung sofort und nachweislich ungueltig gemacht werden kann.
-- token_hash enthaelt NIEMALS den Klartext-Token, nur dessen SHA-256-Hash.
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE,
device_info TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX sessions_user_idx ON sessions (user_id);
-3
View File
@@ -1,3 +0,0 @@
DROP TABLE IF EXISTS totp_policy;
DROP TABLE IF EXISTS totp_recovery_codes;
DROP TABLE IF EXISTS totp_credentials;
-23
View File
@@ -1,23 +0,0 @@
-- Zwei-Faktor-Authentifizierung TOTP (IAM-04, siehe core-kanban/tickets/IAM-04.md).
CREATE TABLE totp_credentials (
user_id UUID PRIMARY KEY REFERENCES users(id),
secret TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- code_hash enthaelt NIEMALS den Klartext-Wiederherstellungscode, nur dessen
-- SHA-256-Hash. used_at markiert einmalige Verwendbarkeit (Akzeptanzkriterium 3).
CREATE TABLE totp_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
code_hash BYTEA NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Erzwingbare Aktivierungspflicht pro Tenant (Singleton-Zeile, Modell C).
CREATE TABLE totp_policy (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
required BOOLEAN NOT NULL DEFAULT false
);