Bislang gab es keine vom Nutzer-Rollenmodell (creator/agentur/marke/
kanzlei) getrennte Betreiber-Rolle — jede Verwaltungsaufgabe (welche
Kanzlei darf im öffentlichen Verzeichnis stehen, wer sind unsere
Accounts) wäre nur per Hand in der Datenbank möglich gewesen. Admin
ist von Anfang an als fünfte app_user-Rolle im Datenmodell verankert,
nicht nachträglich aufgesetzt.
Migration 0004:
- app_user.role erlaubt zusätzlich 'admin' (kein Self-Service-Weg
dorthin — /register bietet die Rolle nicht an, erster Admin wird
einmalig per SQL angelegt, siehe CLAUDE.md).
- account.verified: Freigabe fürs kostenlose Kanzlei-Verzeichnis
(§ 49b Abs. 3 BRAO: reine Auflistung, kein Routing/keine Vermittlung).
- audit_log: append-only-Protokoll jeder Admin-Aktion (gleicher Trigger
wie finding/extraction/evidence_package).
Neue Routen:
- GET /admin, /admin/accounts, /admin/accounts/{id}: Accounts-Übersicht
und -Detail (Logins je Account), requireAdmin (404 statt 403 für
angemeldete Nicht-Admins, wie beim bestehenden Mandanten-404-Muster).
- POST /admin/accounts/{id}/verifizieren: Kanzlei-Freigabe umschalten,
schreibt einen Audit-Log-Eintrag.
- GET /admin/audit-log: Protokoll ansehen.
- GET /kanzleien: öffentliches Verzeichnis (kein Login), zeigt nur
Accounts, die sowohl verified sind als auch einen Nutzer der Rolle
"kanzlei" haben.
Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End manuell
gegen einen laufenden Server verifiziert (Admin-Login, Verify-Toggle,
Erscheinen im öffentlichen Verzeichnis, Audit-Log-Eintrag, 404 für
Nicht-Admin-Zugriff).
102 lines
3.3 KiB
Go
102 lines
3.3 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
const sessionCookieName = "deklarix_session"
|
|
|
|
type contextKey int
|
|
|
|
const userContextKey contextKey = iota
|
|
|
|
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
|
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
|
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
|
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
|
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
|
|
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err != nil || cookie.Value == "" {
|
|
return store.User{}, false
|
|
}
|
|
|
|
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
|
if err != nil {
|
|
return store.User{}, false
|
|
}
|
|
if time.Now().After(sess.ExpiresAt) {
|
|
return store.User{}, false
|
|
}
|
|
|
|
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
|
if err != nil {
|
|
return store.User{}, false
|
|
}
|
|
return user, true
|
|
}
|
|
|
|
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
|
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
|
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.authenticate(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
|
}
|
|
}
|
|
|
|
// requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese
|
|
// werden nur aus einer bereits authentifizierten Seite heraus
|
|
// aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung
|
|
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
|
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.authenticate(r)
|
|
if !ok {
|
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
|
}
|
|
}
|
|
|
|
// requireAdmin schützt den Admin-Bereich. Ohne Sitzung geht es wie bei
|
|
// requirePage zu /login; mit einer Sitzung, aber ohne Admin-Rolle, gibt
|
|
// es 404 statt 403 — sonst würde eine 403 einem angemeldeten, aber
|
|
// unprivilegierten Nutzer verraten, dass unter dieser URL überhaupt
|
|
// etwas existiert.
|
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.authenticate(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if user.Role != "admin" {
|
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
|
}
|
|
}
|
|
|
|
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
|
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
|
|
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
|
|
// kein Laufzeitfall, den man abfangen sollte.
|
|
func currentUser(r *http.Request) store.User {
|
|
user, ok := r.Context().Value(userContextKey).(store.User)
|
|
if !ok {
|
|
panic("web: currentUser aufgerufen ohne requirePage/requireAPI")
|
|
}
|
|
return user
|
|
}
|