package totp import ( "encoding/json" "net/http" "gitea.perlbach24.de/scripte/nexarch/internal/auth" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) // Handler stellt Login (inklusive optionalem zweiten Faktor) und die // 2FA-Einrichtung als HTTP-Endpunkte bereit (IAM-08). Ersetzt auth.Handler.Login // nicht (der bleibt fuer Faelle ohne 2FA-Bedarf nutzbar), bietet aber den // EINEN Login-Endpunkt, den das Frontend tatsaechlich aufruft — LoginWithTOTP // deckt beide Faelle (mit/ohne 2FA) bereits ab. type Handler struct { users *user.TenantUserStore totp *Store login *auth.LoginService issuer string // fuer die otpauth://-Provisioning-URI (Akzeptanzkriterium 1) } func NewHandler(users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, issuer string) *Handler { return &Handler{users: users, totp: totpStore, login: login, issuer: issuer} } type loginRequest struct { Email string `json:"email"` Password string `json:"password"` TOTPCode string `json:"totp_code"` } type loginErrorResponse struct { // Error ist bewusst IMMER dieselbe generische Meldung fuer falsches // Passwort/Token (Akzeptanzkriterium 3 / Pruefung 1) — Code unterscheidet // intern zwischen "second_factor_required" (Formular soll TOTP-Feld // einblenden) und "invalid_credentials" (alles andere), ohne dem Client // mehr ueber den tatsaechlichen Fehlgrund zu verraten. Error string `json:"error"` Code string `json:"code"` } // Login ist der einzige Login-Endpunkt des Frontends (Akzeptanzkriterium 1). 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 := LoginWithTOTP(r.Context(), h.users, h.totp, h.login, req.Email, req.Password, req.TOTPCode) if err != nil { w.Header().Set("Content-Type", "application/json") if err == ErrSecondFactorRequired { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(loginErrorResponse{ Error: "Anmeldedaten oder Code ungültig.", Code: "second_factor_required", }) return } w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(loginErrorResponse{ Error: "Anmeldedaten oder Code ungültig.", Code: "invalid_credentials", }) return } http.SetCookie(w, &http.Cookie{ Name: auth.CookieName, Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: int(auth.AccessTokenTTL.Seconds()), }) w.WriteHeader(http.StatusOK) } type statusResponse struct { Enabled bool `json:"enabled"` } // Status liefert, ob 2FA fuer den angemeldeten Benutzer aktiv ist — fuer die // Profilseite (Akzeptanzkriterium 2: zeigt an, ob Einrichtung schon // stattgefunden hat). Muss hinter auth.RequireAuth haengen. func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } enabled, err := h.totp.IsEnabled(r.Context(), claims.UserID) if err != nil { http.Error(w, "status konnte nicht ermittelt werden", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(statusResponse{Enabled: enabled}) } type setupBeginResponse struct { Secret string `json:"secret"` ProvisioningURI string `json:"provisioning_uri"` } // SetupBegin startet die 2FA-Einrichtung (Akzeptanzkriterium 2) — muss hinter // auth.RequireAuth haengen. func (h *Handler) SetupBegin(w http.ResponseWriter, r *http.Request) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } u, err := h.users.Get(r.Context(), claims.UserID) if err != nil { http.Error(w, "benutzer nicht gefunden", http.StatusNotFound) return } secret, uri, err := h.totp.BeginSetup(r.Context(), claims.UserID, h.issuer, u.Email) if err != nil { http.Error(w, "einrichtung konnte nicht gestartet werden", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(setupBeginResponse{Secret: secret, ProvisioningURI: uri}) } type setupConfirmRequest struct { Code string `json:"code"` } type setupConfirmResponse struct { RecoveryCodes []string `json:"recovery_codes"` } // SetupConfirm bestaetigt die Einrichtung und liefert die Wiederherstellungscodes // EINMALIG im Klartext (Akzeptanzkriterium 2) — muss hinter auth.RequireAuth haengen. func (h *Handler) SetupConfirm(w http.ResponseWriter, r *http.Request) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } var req setupConfirmRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) return } codes, err := h.totp.ConfirmSetup(r.Context(), claims.UserID, req.Code) if err != nil { http.Error(w, "Code ungültig.", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(setupConfirmResponse{RecoveryCodes: codes}) }