package httpapi import ( "encoding/json" "errors" "io" "log/slog" "net/http" "github.com/google/uuid" "github.com/syncova/syncova/packages/auth" "github.com/syncova/syncova/packages/platform/logging" ) // authHandler bedient die Anmelde-Endpunkte (SYNCOVA_API.md §2, §3). type authHandler struct { // authService ist die Domänenlogik der Identitätsverwaltung. authService *auth.Service // logger protokolliert technische Fehler. logger *slog.Logger } // loginRequest ist der Rumpf von POST /auth/login. type loginRequest struct { // Username ist der Anmeldename. Username string `json:"username"` // Password ist das Passwort. Password string `json:"password"` } // mfaVerifyRequest ist der Rumpf von POST /auth/mfa/verify. type mfaVerifyRequest struct { // ChallengeID benennt die offene Herausforderung. ChallengeID string `json:"challenge_id"` // Code ist der TOTP- oder Wiederherstellungscode. Code string `json:"code"` } // refreshRequest ist der Rumpf von POST /auth/refresh. type refreshRequest struct { // RefreshToken ist das Erneuerungstoken. RefreshToken string `json:"refresh_token"` } // handleLogin bedient POST /auth/login. func (handler *authHandler) handleLogin(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) var loginPayload loginRequest if decodeError := decodeJSONBody(request, &loginPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if loginPayload.Username == "" || loginPayload.Password == "" { WriteError(responseWriter, request, requestLogger, NewValidationError("Benutzername und Passwort sind erforderlich.")) return } loginResult, loginError := handler.authService.Login( request.Context(), loginPayload.Username, loginPayload.Password, RequestContextFrom(request)) if loginError != nil { WriteError(responseWriter, request, requestLogger, translateAuthError(loginError)) return } WriteSuccess(responseWriter, request, http.StatusOK, loginResult) } // handleVerifyMFA bedient POST /auth/mfa/verify. func (handler *authHandler) handleVerifyMFA(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) var verifyPayload mfaVerifyRequest if decodeError := decodeJSONBody(request, &verifyPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } challengeID, parseError := uuid.Parse(verifyPayload.ChallengeID) if parseError != nil { // Eine unlesbare Kennung wird wie eine abgelaufene behandelt, damit // die Antwort keine Rückschlüsse auf gültige Kennungen erlaubt. WriteError(responseWriter, request, requestLogger, &APIError{ StatusCode: http.StatusUnauthorized, Code: ErrorCodeUnauthenticated, Message: auth.ErrChallengeNotFound.Error(), }) return } if verifyPayload.Code == "" { WriteError(responseWriter, request, requestLogger, NewValidationError("Der Code ist erforderlich.")) return } loginResult, verifyError := handler.authService.VerifyMFA( request.Context(), challengeID, verifyPayload.Code, RequestContextFrom(request)) if verifyError != nil { WriteError(responseWriter, request, requestLogger, translateAuthError(verifyError)) return } WriteSuccess(responseWriter, request, http.StatusOK, loginResult) } // handleRefresh bedient POST /auth/refresh. func (handler *authHandler) handleRefresh(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) var refreshPayload refreshRequest if decodeError := decodeJSONBody(request, &refreshPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if refreshPayload.RefreshToken == "" { WriteError(responseWriter, request, requestLogger, NewValidationError("Das Erneuerungstoken ist erforderlich.")) return } tokenPair, refreshError := handler.authService.Refresh( request.Context(), refreshPayload.RefreshToken, RequestContextFrom(request)) if refreshError != nil { WriteError(responseWriter, request, requestLogger, translateAuthError(refreshError)) return } WriteSuccess(responseWriter, request, http.StatusOK, tokenPair) } // handleLogout bedient POST /auth/logout. func (handler *authHandler) handleLogout(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) authenticatedUser, _ := AuthenticatedUserFromContext(request.Context()) sessionID, hasSession := SessionIDFromContext(request.Context()) if !hasSession { WriteError(responseWriter, request, requestLogger, newUnauthenticatedError("Es besteht keine aktive Sitzung.")) return } if logoutError := handler.authService.Logout(request.Context(), sessionID, authenticatedUser, RequestContextFrom(request)); logoutError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(logoutError)) return } WriteSuccess(responseWriter, request, http.StatusOK, map[string]string{ "status": "abgemeldet", }) } // handleGetCurrentUser bedient GET /me. func (handler *authHandler) handleGetCurrentUser(responseWriter http.ResponseWriter, request *http.Request) { authenticatedUser, _ := AuthenticatedUserFromContext(request.Context()) WriteSuccess(responseWriter, request, http.StatusOK, authenticatedUser) } // mfaConfirmRequest ist der Rumpf von POST /me/mfa/confirm. type mfaConfirmRequest struct { // Code ist der zur Bestätigung eingegebene TOTP-Code. Code string `json:"code"` } // handleBeginMFAEnrollment bedient POST /me/mfa/enroll. func (handler *authHandler) handleBeginMFAEnrollment(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) authenticatedUser, _ := AuthenticatedUserFromContext(request.Context()) enrollment, enrollError := handler.authService.BeginMFAEnrollment(request.Context(), authenticatedUser) if enrollError != nil { WriteError(responseWriter, request, requestLogger, translateAuthError(enrollError)) return } // Secret und Wiederherstellungscodes werden genau einmal ausgeliefert. // Sie erscheinen bewusst nicht im Log (PROMPT.md §12). WriteSuccess(responseWriter, request, http.StatusOK, enrollment) } // handleConfirmMFAEnrollment bedient POST /me/mfa/confirm. func (handler *authHandler) handleConfirmMFAEnrollment(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) authenticatedUser, _ := AuthenticatedUserFromContext(request.Context()) var confirmPayload mfaConfirmRequest if decodeError := decodeJSONBody(request, &confirmPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if confirmError := handler.authService.ConfirmMFAEnrollment( request.Context(), authenticatedUser, confirmPayload.Code, RequestContextFrom(request)); confirmError != nil { WriteError(responseWriter, request, requestLogger, translateAuthError(confirmError)) return } WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "mfa_enabled": true, }) } // decodeJSONBody liest einen JSON-Rumpf und lehnt unbekannte Felder ab. // // Unbekannte Felder deuten auf einen Tippfehler oder eine falsche API-Version // hin; sie stillschweigend zu übergehen führte zu Einstellungen, die der // Aufrufer gesetzt zu haben glaubt (PROMPT.md §140). func decodeJSONBody(request *http.Request, targetStructure any) *APIError { jsonDecoder := json.NewDecoder(request.Body) jsonDecoder.DisallowUnknownFields() if decodeError := jsonDecoder.Decode(targetStructure); decodeError != nil { // Ein überschrittenes Größenlimit erhält eine eigene, klare Meldung. var maxBytesError *http.MaxBytesError if errors.As(decodeError, &maxBytesError) { return &APIError{ StatusCode: http.StatusRequestEntityTooLarge, Code: ErrorCodePayloadTooLarge, Message: "Die Anfrage ist zu groß.", } } if errors.Is(decodeError, io.EOF) { return NewBadRequestError("Die Anfrage enthält keinen Inhalt.") } return NewBadRequestError("Die Anfrage konnte nicht gelesen werden. Erwartet wird gültiges JSON.") } return nil } // translateAuthError übersetzt Domänenfehler in API-Antworten. // // Anmeldefehler erhalten bewusst dieselbe Meldung, damit sich aus der Antwort // nicht ableiten lässt, ob ein Konto existiert (PROMPT.md §45). func translateAuthError(domainError error) *APIError { switch { case errors.Is(domainError, auth.ErrInvalidCredentials): return &APIError{ StatusCode: http.StatusUnauthorized, Code: ErrorCodeUnauthenticated, Message: "Benutzername oder Passwort ist falsch.", } case errors.Is(domainError, auth.ErrAccountLocked): return &APIError{ StatusCode: http.StatusUnauthorized, Code: "ACCOUNT_LOCKED", Message: "Das Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte später erneut versuchen.", } case errors.Is(domainError, auth.ErrAccountDisabled): return &APIError{ StatusCode: http.StatusUnauthorized, Code: "ACCOUNT_DISABLED", Message: "Das Konto ist deaktiviert. Bitte an die Administration wenden.", } case errors.Is(domainError, auth.ErrInvalidMFACode): return &APIError{ StatusCode: http.StatusUnauthorized, Code: "MFA_CODE_INVALID", Message: "Der Code ist falsch oder wurde bereits verwendet.", } case errors.Is(domainError, auth.ErrChallengeNotFound): return &APIError{ StatusCode: http.StatusUnauthorized, Code: "MFA_CHALLENGE_EXPIRED", Message: "Die Anmeldung ist abgelaufen. Bitte erneut anmelden.", } case errors.Is(domainError, auth.ErrChallengeAttemptsExceeded): return &APIError{ StatusCode: http.StatusTooManyRequests, Code: ErrorCodeRateLimited, Message: "Zu viele Fehlversuche. Bitte erneut anmelden.", } case errors.Is(domainError, auth.ErrSessionInvalid): return newUnauthenticatedError("Die Sitzung ist ungültig oder abgelaufen. Bitte erneut anmelden.") case errors.Is(domainError, auth.ErrUserNotFound): return NewNotFoundError("Der Benutzer existiert nicht.") case errors.Is(domainError, auth.ErrRoleNotFound): return NewValidationError(domainError.Error()) case errors.Is(domainError, auth.ErrUsernameTaken): return &APIError{ StatusCode: http.StatusConflict, Code: ErrorCodeConflict, Message: "Dieser Benutzername ist bereits vergeben.", } case errors.Is(domainError, auth.ErrEmailTaken): return &APIError{ StatusCode: http.StatusConflict, Code: ErrorCodeConflict, Message: "Diese Mailadresse ist bereits vergeben.", } case errors.Is(domainError, auth.ErrWeakPassword): return NewValidationError(domainError.Error()) case errors.Is(domainError, auth.ErrLastAdministrator): return &APIError{ StatusCode: http.StatusConflict, Code: "LAST_ADMINISTRATOR", Message: "Der letzte Administrator kann nicht entfernt oder entrechtet werden. Andernfalls wäre die Installation nicht mehr verwaltbar.", } case errors.Is(domainError, auth.ErrSystemRoleImmutable): return &APIError{ StatusCode: http.StatusConflict, Code: "SYSTEM_ROLE_IMMUTABLE", Message: "Mitgelieferte Rollen können nicht geändert oder gelöscht werden.", } case errors.Is(domainError, auth.ErrRoleInUse): return &APIError{ StatusCode: http.StatusConflict, Code: "ROLE_IN_USE", Message: domainError.Error(), } case errors.Is(domainError, auth.ErrMFAAlreadyEnabled): return &APIError{ StatusCode: http.StatusConflict, Code: "MFA_ALREADY_ENABLED", Message: "Für dieses Konto ist bereits ein zweiter Faktor eingerichtet.", } case errors.Is(domainError, auth.ErrMFANotEnrolled): return &APIError{ StatusCode: http.StatusConflict, Code: "MFA_NOT_ENROLLED", Message: "Für dieses Konto ist kein zweiter Faktor eingerichtet.", } default: // Unbekannte Fehler sind technische Probleme; ihre Ursache bleibt intern. return NewInternalError(domainError) } }