syncova-backup/apps/api/internal/httpapi/auth_handler.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

353 lines
12 KiB
Go

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)
}
}