IAM-03: passwort-reset-einladungs-flow
internal/authtoken: Einmal-Token fuer Passwort-Reset und Einladung, getrennt von internal/auth (Sessions/JWT), da der Vorgang bewusst session-los ist. Store.Create gibt das Klartext-Token NUR an den Aufrufer zurueck (fuer E-Mail-Versand, IAM-08), gespeichert wird ausschliesslich der SHA-256-Hash. Store.Consume markiert ein Token atomar als verwendet (UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING user_id) — Wiederverwendung und Ablauf werden serverseitig in derselben Datenbankoperation durchgesetzt, kein Race zwischen Pruefen und Verbrauchen moeglich. Ungueltig, bereits verwendet und abgelaufen liefern denselben ErrInvalidToken (Akzeptanz- kriterium 3), damit die Antwort keinen der drei Faelle verraet. CompletePasswordReset/CompleteInvitation loesen ein Token ein und setzen das Passwort ueber user.TenantUserStore.SetPasswordHash (IAM-01) — keine Auth-Middleware, keine Session noetig (Akzeptanzkriterium 2). Log-Statements bei Create/Consume enthalten bewusst nur user_id/purpose, niemals das Token selbst. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Wiederverwendung eines bereits eingeloesten Tokens automatisiert abgewiesen — TestConsume_RejectsReuse. PASS. 2. Ablaufzeit serverseitig durchgesetzt — TestConsume_RejectsExpiredToken (Token mit negativer TTL sofort abgelaufen). PASS. 3. Token werden nicht im Klartext geloggt (Stichprobe im Log-Ausgang) — TestCreateAndConsume_NeverLogTokenPlaintext: Log-Buffer nach Create + ungueltigem + gueltigem Consume enthaelt das Klartext-Token nachweislich nicht. PASS. Zusaetzlich: TestCompleteInvitation_SetsPasswordWithoutSession belegt Akzeptanzkriterium 2 konkret (Passwort gesetzt und verifizierbar, keine Session im Ablauf beteiligt). PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3d20d86a4f
commit
b6a941feec
@@ -0,0 +1,103 @@
|
||||
// Package authtoken implementiert Core IAM-03: einmal-verwendbare Tokens fuer
|
||||
// Passwort-Reset und Einladung neuer Benutzer, mit Ablaufzeit — bewusst
|
||||
// getrennt von internal/auth (Sessions/JWT), da es hier um einen
|
||||
// vorgelagerten, session-losen Vorgang geht (Akzeptanzkriterium 2: Einladung
|
||||
// setzt das Erstpasswort OHNE bestehende Session).
|
||||
package authtoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Purpose string
|
||||
|
||||
const (
|
||||
PurposeReset Purpose = "reset"
|
||||
PurposeInvite Purpose = "invite"
|
||||
)
|
||||
|
||||
// ErrInvalidToken deckt sowohl unbekannte als auch bereits verwendete oder
|
||||
// abgelaufene Tokens ab (Akzeptanzkriterium 3) — bewusst EIN Fehler, damit
|
||||
// ein Angreifer aus der Antwort nicht ableiten kann, welcher der drei Faelle
|
||||
// vorliegt.
|
||||
var ErrInvalidToken = errors.New("authtoken: token ungueltig, bereits verwendet oder abgelaufen")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// Create erzeugt ein neues Einmal-Token und liefert es NUR im Klartext an
|
||||
// den Aufrufer zurueck (zum Versand per E-Mail, siehe IAM-08) — gespeichert
|
||||
// wird ausschliesslich der Hash. Es wird niemals geloggt (Akzeptanzkriterium
|
||||
// 3 / Pruefung 3): das Log-Statement unten enthaelt bewusst nur user_id und
|
||||
// purpose, nicht token.
|
||||
func (s *Store) Create(ctx context.Context, userID string, purpose Purpose, ttl time.Duration) (token string, err error) {
|
||||
token, err = randomToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token erzeugen: %w", err)
|
||||
}
|
||||
hash := hashToken(token)
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO password_tokens (user_id, token_hash, purpose, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, userID, hash, string(purpose), expiresAt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token speichern: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("passwort-token erzeugt", "user_id", userID, "purpose", purpose, "laeuft_ab", expiresAt)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Consume loest ein Token ein: gueltig nur, wenn es existiert, noch nicht
|
||||
// verwendet und nicht abgelaufen ist. Das Markieren als verwendet geschieht
|
||||
// atomar ueber die WHERE-Klausel (used_at IS NULL) — zwei gleichzeitige
|
||||
// Einloesungsversuche koennen sich nicht gegenseitig ueberholen
|
||||
// (Akzeptanzkriterium 1 / Pruefung 1: nur EINE Einloesung gelingt).
|
||||
func (s *Store) Consume(ctx context.Context, token string, purpose Purpose) (userID string, err error) {
|
||||
hash := hashToken(token)
|
||||
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
UPDATE password_tokens
|
||||
SET used_at = now()
|
||||
WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now()
|
||||
RETURNING user_id
|
||||
`, hash, string(purpose)).Scan(&userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
slog.Warn("token-einloesung abgewiesen", "purpose", purpose, "grund", "ungueltig, bereits verwendet oder abgelaufen")
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
return "", fmt.Errorf("token einloesen: %w", err)
|
||||
}
|
||||
return userID, 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[:]
|
||||
}
|
||||
Reference in New Issue
Block a user