feat: add auth and multi-tenancy (Schritt 2, part 1/2)

internal/auth is pure logic (bcrypt hashing, session token generation)
with no DB access — persistence for account/app_user/session lives in
internal/store like everything else, via migration 0003.

account is the tenant (Mandant); app_user is a login inside one account;
session is a real server-side row (not a signed stateless token) so
logout can actually end a session rather than the client just
forgetting a JWT. submission.account_id is NOT NULL — added directly
rather than the nullable-then-backfill dance, since no submission rows
exist anywhere yet (verified empty on the test server before writing
the migration). Added as migration 0003 (new file), not folded into an
earlier one, since 0001/0002 are already applied on the test server.

store.ErrNotFound lets callers distinguish "wrong email" / "unknown
session" from a genuine DB error — matters for login, where those two
cases should both fail closed but for different reasons.

Not yet wired into internal/web — that's the next commit. All of this
is tested against real Postgres (14 store tests green) but isn't
reachable from any HTTP handler yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 15:48:40 +02:00
parent f5ad08cebd
commit 2a16dc2200
13 changed files with 498 additions and 19 deletions

56
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,56 @@
// Package auth enthält die reine Logik für Anmeldung: Passwort-Hashing,
// Session-Token-Erzeugung. Es fasst keine Datenbank an — das macht
// internal/store (account, app_user, session), damit Persistenz an
// einer Stelle im Projekt gebündelt bleibt.
package auth
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
// MinPasswordLength ist die einzige Passwort-Regel, die wir prüfen —
// keine erzwungenen Sonderzeichen/Ziffern-Vorgaben, die Nutzer nur zu
// vorhersehbaren Mustern verleiten.
const MinPasswordLength = 8
// SessionDuration ist die Gültigkeitsdauer einer neu erstellten Sitzung.
const SessionDuration = 30 * 24 * time.Hour
// HashPassword hasht ein Klartext-Passwort für die Speicherung. Lehnt
// zu kurze Passwörter ab, statt sie stillschweigend zu hashen.
func HashPassword(password string) (string, error) {
if len(password) < MinPasswordLength {
return "", fmt.Errorf("auth: passwort muss mindestens %d Zeichen haben", MinPasswordLength)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("auth: passwort hashen: %w", err)
}
return string(hash), nil
}
// VerifyPassword prüft ein Klartext-Passwort gegen einen gespeicherten
// Hash. Ein Fehler bedeutet: falsches Passwort oder ungültiger Hash —
// beides führt zu "Anmeldung abgelehnt", nie zu einem Unterschied, den
// ein Angreifer für User-Enumeration nutzen könnte.
func VerifyPassword(hash, password string) error {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return fmt.Errorf("auth: passwort ungültig: %w", err)
}
return nil
}
// NewSessionToken erzeugt ein kryptographisch zufälliges Session-Token
// (32 Byte, hex-kodiert).
func NewSessionToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: token generieren: %w", err)
}
return hex.EncodeToString(buf), nil
}