// 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[:] }