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>
353 lines
12 KiB
Go
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)
|
|
}
|
|
}
|