package auth import ( "context" "encoding/json" "net/http" "time" ) // RevokeAuthorize entscheidet, ob caller den Admin-Endpunkt // RevokeUserSessions aufrufen darf. Bewusst dieselbe schmale Schnittstelle // wie internal/audit.Authorize (AUD-04) — eine vollwertige Rollen-/ // Rechteschicht ist nicht Teil dieser Kachel (IAM-09 baut ggf. darauf auf). type RevokeAuthorize func(ctx context.Context, caller string) bool // Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung, // Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht // Teil dieser Kachel (siehe IAM-03..07). type Handler struct { login *LoginService issuer *TokenIssuer revocation *RevocationStore authorize RevokeAuthorize } // NewHandler baut den Login/Logout-Handler. authorize darf nil sein, dann // ist RevokeUserSessions (Admin-Massenwiderruf fuer FREMDE Benutzer) // deaktiviert und liefert 403 — RevokeAllSessions (Selbstbedienung fuer die // EIGENE Session) funktioniert unabhaengig davon immer. func NewHandler(login *LoginService, issuer *TokenIssuer, revocation *RevocationStore, authorize RevokeAuthorize) *Handler { return &Handler{login: login, issuer: issuer, revocation: revocation, authorize: authorize} } type loginRequest struct { Email string `json:"email"` Password string `json:"password"` } func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) return } token, err := h.login.Login(r.Context(), req.Email, req.Password) if err != nil { http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized) return } http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: int(AccessTokenTTL.Seconds()), }) w.WriteHeader(http.StatusOK) } // Logout loescht das Session-Cookie UND widerruft das zugehoerige Token // sofort im RevocationStore (IAM-17 Akzeptanzkriterium 1) — vorher blieb ein // separat vom Cookie extrahiertes Token bis zum Ablauf gueltig // (Threat-Model-Finding, Core-Board 2026-09-12). Bewusst idempotent: fehlt // das Cookie oder ist das Token bereits ungueltig, wird trotzdem 200 // zurueckgegeben, damit ein doppelter Logout keinen Fehler erzeugt. func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(CookieName); err == nil { if claims, verifyErr := h.issuer.Verify(cookie.Value); verifyErr == nil { if err := h.revocation.RevokeToken(r.Context(), claims.ID, claims.UserID); err != nil { http.Error(w, "abmelden fehlgeschlagen", http.StatusInternalServerError) return } } } http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(0, 0), }) w.WriteHeader(http.StatusOK) } // RevokeAllSessions ist der Selbstbedienungs-Endpunkt fuer Akzeptanzkriterium // 2: der angemeldete Benutzer widerruft ALLE eigenen aktiven Tokens auf // einen Schlag (z. B. bei Verdacht auf Kompromittierung), ohne andere // Benutzer zu beeinflussen. Muss hinter RequireAuth haengen. func (h *Handler) RevokeAllSessions(w http.ResponseWriter, r *http.Request) { claims, ok := ClaimsFromContext(r.Context()) if !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } if err := h.revocation.RevokeAllForUser(r.Context(), claims.UserID); err != nil { http.Error(w, "sitzungen widerrufen fehlgeschlagen", http.StatusInternalServerError) return } // Das Cookie der aktuellen Anfrage ist durch den Stichtag ohnehin // ungueltig geworden — gleich mit loeschen, statt auf den naechsten // abgelehnten Zugriff zu warten. http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(0, 0), }) w.WriteHeader(http.StatusOK) } // RevokeUserSessions ist der Admin-Endpunkt fuer Akzeptanzkriterium 2: // Widerruf ALLER aktiven Sessions eines FREMDEN Benutzerkontos, z. B. durch // Support/Sicherheitsteam bei erkanntem Diebstahl. caller/userID kommen als // Query-Parameter (Muster wie internal/audit.ExportHandler, AUD-04). func (h *Handler) RevokeUserSessions(w http.ResponseWriter, r *http.Request) { caller := r.URL.Query().Get("caller") if h.authorize == nil || caller == "" || !h.authorize(r.Context(), caller) { http.Error(w, "keine berechtigung fuer diesen widerruf", http.StatusForbidden) return } userID := r.URL.Query().Get("user_id") if userID == "" { http.Error(w, "user_id fehlt", http.StatusBadRequest) return } if err := h.revocation.RevokeAllForUser(r.Context(), userID); err != nil { http.Error(w, "sitzungen widerrufen fehlgeschlagen", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type mobileLoginResponse struct { Token string `json:"token"` ExpiresAt string `json:"expiresAt"` } // MobileLogin ist wie Login (Handler), gibt das Token aber im JSON-Body // zurueck statt als Cookie (native Clients haben keinen Cookie-Speicher) // und mit MobileAccessTokenTTL statt AccessTokenTTL (INT-21). func (h *Handler) MobileLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) return } token, err := h.login.LoginWithTTL(r.Context(), req.Email, req.Password, MobileAccessTokenTTL) if err != nil { http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(mobileLoginResponse{ Token: token, ExpiresAt: time.Now().Add(MobileAccessTokenTTL).UTC().Format(time.RFC3339), }) }