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>
41 lines
1.4 KiB
Go
41 lines
1.4 KiB
Go
package authtoken
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
)
|
|
|
|
// CompletePasswordReset loest ein Reset-Token ein und setzt das neue
|
|
// Passwort — komplett OHNE bestehende Session (Akzeptanzkriterium 2 gilt
|
|
// analog fuer Reset wie fuer Invite: der Vorgang ersetzt eine Anmeldung).
|
|
func CompletePasswordReset(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, newPassword string) error {
|
|
return completeWithToken(ctx, tokens, users, token, PurposeReset, newPassword)
|
|
}
|
|
|
|
// CompleteInvitation loest ein Einladungs-Token ein und setzt das
|
|
// Erstpasswort eines neu angelegten Benutzers — ohne bestehende Session
|
|
// (Akzeptanzkriterium 2).
|
|
func CompleteInvitation(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, initialPassword string) error {
|
|
return completeWithToken(ctx, tokens, users, token, PurposeInvite, initialPassword)
|
|
}
|
|
|
|
func completeWithToken(ctx context.Context, tokens *Store, users *user.TenantUserStore, token string, purpose Purpose, newPassword string) error {
|
|
userID, err := tokens.Consume(ctx, token, purpose)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hash, err := auth.HashPassword(newPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("passwort hashen: %w", err)
|
|
}
|
|
|
|
if err := users.SetPasswordHash(ctx, userID, hash); err != nil {
|
|
return fmt.Errorf("passwort setzen: %w", err)
|
|
}
|
|
return nil
|
|
}
|