syncova-backup/packages/auth/service.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

555 lines
20 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"unicode"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/audit"
"github.com/syncova/syncova/packages/platform/config"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/platform/totp"
)
// issuerName erscheint in Authenticator-Apps als Aussteller.
const issuerName = "Syncova"
// recoveryCodeCount ist die Anzahl der bei der MFA-Einrichtung ausgegebenen Codes.
const recoveryCodeCount = 10
// minimumPasswordLength ist die geforderte Mindestlänge eines Passworts.
//
// Länge ist der wirksamste Einzelfaktor; Zeichenklassenregeln führen dagegen
// erfahrungsgemäß zu vorhersagbaren Mustern.
const minimumPasswordLength = 12
// Service bündelt die Anwendungsfälle rund um Identität und Anmeldung.
type Service struct {
// repository ist die Datenzugriffsschicht.
repository *Repository
// secretStore verschlüsselt MFA-Secrets.
secretStore crypto.SecretStore
// auditRecorder protokolliert sicherheitsrelevante Handlungen.
auditRecorder audit.Recorder
// authConfig steuert Sitzungsdauer und Brute-Force-Schutz.
authConfig config.AuthConfig
// logger protokolliert technische Fehler.
logger *slog.Logger
// timeSource liefert die aktuelle Zeit und ist in Tests ersetzbar.
timeSource func() time.Time
}
// NewService erzeugt den Identitätsdienst.
func NewService(repository *Repository, secretStore crypto.SecretStore, auditRecorder audit.Recorder, authConfig config.AuthConfig, baseLogger *slog.Logger) *Service {
return &Service{
repository: repository,
secretStore: secretStore,
auditRecorder: auditRecorder,
authConfig: authConfig,
logger: logging.WithComponent(baseLogger, "auth"),
timeSource: time.Now,
}
}
// RequestContext beschreibt die Herkunft einer Anfrage für das Auditprotokoll.
type RequestContext struct {
// IPAddress ist die Absenderadresse.
IPAddress string
// UserAgent ist die Kennung des verwendeten Programms.
UserAgent string
// CorrelationID verknüpft die Handlung mit dem Serverlog.
CorrelationID string
}
// Login prüft Anmeldename und Passwort.
//
// Ist ein zweiter Faktor eingerichtet, endet der Vorgang mit einer offenen
// Herausforderung statt mit einer Sitzung (SYNCOVA_API.md §2).
func (service *Service) Login(loginContext context.Context, username string, password string, requestContext RequestContext) (LoginResult, error) {
credentials, lookupError := service.repository.findCredentialsByUsername(loginContext, username)
if lookupError != nil {
if errors.Is(lookupError, ErrUserNotFound) {
// Gegen Benutzer-Enumeration: auch für ein unbekanntes Konto wird ein
// Passwortvergleich durchgeführt, damit die Antwortzeit sich nicht
// messbar von der eines existierenden Kontos unterscheidet.
service.performDummyPasswordVerification()
service.recordAudit(loginContext, audit.Event{
ActorUsername: username,
Action: audit.ActionLoginFailed,
Result: audit.ResultFailure,
IPAddress: requestContext.IPAddress,
UserAgent: requestContext.UserAgent,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"grund": "unbekannter benutzer"},
})
return LoginResult{}, ErrInvalidCredentials
}
return LoginResult{}, lookupError
}
// Ein gesperrtes Konto wird vor dem Passwortvergleich abgewiesen.
if credentials.lockedUntil != nil && credentials.lockedUntil.After(service.timeSource()) {
service.recordAudit(loginContext, audit.Event{
UserID: &credentials.id,
ActorUsername: credentials.username,
Action: audit.ActionLoginFailed,
Result: audit.ResultDenied,
IPAddress: requestContext.IPAddress,
UserAgent: requestContext.UserAgent,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"grund": "konto gesperrt"},
})
return LoginResult{}, ErrAccountLocked
}
if credentials.status == UserStatusDisabled {
service.recordAudit(loginContext, audit.Event{
UserID: &credentials.id,
ActorUsername: credentials.username,
Action: audit.ActionLoginFailed,
Result: audit.ResultDenied,
IPAddress: requestContext.IPAddress,
UserAgent: requestContext.UserAgent,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"grund": "konto deaktiviert"},
})
return LoginResult{}, ErrAccountDisabled
}
// Ein Konto ohne Passworthash kann sich nicht anmelden.
if credentials.passwordHash == "" {
service.performDummyPasswordVerification()
return LoginResult{}, ErrInvalidCredentials
}
if verifyError := crypto.VerifyPassword(password, credentials.passwordHash); verifyError != nil {
isNowLocked, recordError := service.repository.recordFailedLogin(
loginContext, credentials.id, service.authConfig.MaxFailedLoginAttempts, service.authConfig.LockoutDuration)
if recordError != nil {
service.logger.Error("fehlversuch konnte nicht vermerkt werden", slog.String("error", recordError.Error()))
}
service.recordAudit(loginContext, audit.Event{
UserID: &credentials.id,
ActorUsername: credentials.username,
Action: audit.ActionLoginFailed,
Result: audit.ResultFailure,
IPAddress: requestContext.IPAddress,
UserAgent: requestContext.UserAgent,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"grund": "falsches passwort"},
})
if isNowLocked {
service.recordAudit(loginContext, audit.Event{
UserID: &credentials.id,
ActorUsername: credentials.username,
Action: audit.ActionAccountLocked,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"grund": "zu viele fehlversuche"},
})
return LoginResult{}, ErrAccountLocked
}
return LoginResult{}, ErrInvalidCredentials
}
// Das Passwort stimmt. Bei aktivem zweiten Faktor fehlt noch dessen Nachweis.
if credentials.mfaEnabled {
challengeID, challengeError := service.repository.createMFAChallenge(
loginContext, credentials.id,
service.timeSource().Add(service.authConfig.MFAChallengeTTL),
requestContext.IPAddress)
if challengeError != nil {
return LoginResult{}, challengeError
}
return LoginResult{MFARequired: true, ChallengeID: &challengeID}, nil
}
return service.completeLogin(loginContext, credentials.id, credentials.username, requestContext)
}
// performDummyPasswordVerification verbrennt die Rechenzeit einer Passwortprüfung.
//
// Ohne diesen Schritt wäre eine Anmeldung mit unbekanntem Benutzernamen messbar
// schneller als eine mit falschem Passwort — daraus liessen sich gültige
// Anmeldenamen ableiten.
func (service *Service) performDummyPasswordVerification() {
// Ein fest hinterlegter Hash eines zufälligen Werts. Er dient ausschließlich
// dem Zeitverbrauch und schützt kein Konto.
const dummyHash = "$argon2id$v=19$m=65536,t=3,p=4$c3luY292YWR1bW15c2FsdA$3b8vLQjJ0kZQxHJmWx7uYqM5nR2pT8sVwXyZaBcDeFg"
_ = crypto.VerifyPassword("dummy-password-for-constant-timing", dummyHash)
}
// VerifyMFA schließt eine Anmeldung mit dem zweiten Faktor ab.
//
// Akzeptiert werden ein gültiger TOTP-Code oder ein noch unbenutzter
// Wiederherstellungscode.
func (service *Service) VerifyMFA(verifyContext context.Context, challengeID uuid.UUID, providedCode string, requestContext RequestContext) (LoginResult, error) {
challenge, challengeError := service.repository.findOpenMFAChallenge(verifyContext, challengeID)
if challengeError != nil {
return LoginResult{}, challengeError
}
// Zu viele Fehlversuche beenden die Herausforderung, damit ein sechsstelliger
// Code nicht durchprobiert werden kann.
if challenge.attempts >= service.authConfig.MaxMFAAttempts {
if consumeError := service.repository.consumeMFAChallenge(verifyContext, challenge.id); consumeError != nil {
service.logger.Error("herausforderung konnte nicht geschlossen werden", slog.String("error", consumeError.Error()))
}
return LoginResult{}, ErrChallengeAttemptsExceeded
}
verifiedUser, userError := service.repository.FindUserByID(verifyContext, challenge.userID)
if userError != nil {
return LoginResult{}, userError
}
codeAccepted, usedRecoveryCode, verifyError := service.verifySecondFactor(verifyContext, challenge.userID, providedCode)
if verifyError != nil {
return LoginResult{}, verifyError
}
if !codeAccepted {
if incrementError := service.repository.incrementMFAChallengeAttempts(verifyContext, challenge.id); incrementError != nil {
service.logger.Error("fehlversuch konnte nicht vermerkt werden", slog.String("error", incrementError.Error()))
}
service.recordAudit(verifyContext, audit.Event{
UserID: &verifiedUser.ID,
ActorUsername: verifiedUser.Username,
Action: audit.ActionMFAFailed,
Result: audit.ResultFailure,
IPAddress: requestContext.IPAddress,
UserAgent: requestContext.UserAgent,
CorrelationID: requestContext.CorrelationID,
})
return LoginResult{}, ErrInvalidMFACode
}
// Die Herausforderung wird eingelöst, bevor die Sitzung entsteht: so kann
// aus einer Herausforderung nie mehr als eine Sitzung hervorgehen.
if consumeError := service.repository.consumeMFAChallenge(verifyContext, challenge.id); consumeError != nil {
return LoginResult{}, consumeError
}
auditAction := audit.ActionMFASucceeded
if usedRecoveryCode {
auditAction = audit.ActionRecoveryCodeUsed
}
service.recordAudit(verifyContext, audit.Event{
UserID: &verifiedUser.ID,
ActorUsername: verifiedUser.Username,
Action: auditAction,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
UserAgent: requestContext.UserAgent,
CorrelationID: requestContext.CorrelationID,
})
return service.completeLogin(verifyContext, verifiedUser.ID, verifiedUser.Username, requestContext)
}
// verifySecondFactor prüft einen TOTP- oder Wiederherstellungscode.
//
// Der zweite Rückgabewert meldet, ob ein Wiederherstellungscode verwendet wurde.
func (service *Service) verifySecondFactor(verifyContext context.Context, userID uuid.UUID, providedCode string) (bool, bool, error) {
totpMethod, methodError := service.repository.findTOTPMethod(verifyContext, userID)
if methodError != nil {
return false, false, methodError
}
if !totpMethod.enabled {
return false, false, ErrMFANotEnrolled
}
secretBytes, decryptError := service.secretStore.Decrypt(totpMethod.secretCiphertext, totpMethod.keyVersion)
if decryptError != nil {
// Ohne Schlüssel ist keine Anmeldung möglich. Das ist ein Betriebsproblem
// und muss deutlich sichtbar sein (PROMPT.md §142).
service.logger.Error("mfa-secret konnte nicht entschlüsselt werden",
slog.String("key_version", totpMethod.keyVersion),
slog.String("error", decryptError.Error()))
return false, false, fmt.Errorf("der zweite faktor konnte nicht geprüft werden: %w", decryptError)
}
var lastUsedTimeStep int64
if totpMethod.lastUsedTimeStep != nil {
lastUsedTimeStep = *totpMethod.lastUsedTimeStep
}
validationResult, validationError := totp.Validate(
string(secretBytes), providedCode, service.timeSource(), lastUsedTimeStep, totp.DefaultConfiguration())
if validationError == nil {
// Der verwendete Zeitschritt wird vermerkt, damit derselbe Code nicht
// erneut gilt.
if updateError := service.repository.updateTOTPTimeStep(verifyContext, totpMethod.id, validationResult.TimeStep); updateError != nil {
service.logger.Error("verwendeter zeitschritt konnte nicht vermerkt werden", slog.String("error", updateError.Error()))
}
return true, false, nil
}
// Ein bereits verwendeter Code gilt als Fehlversuch, nicht als technischer Fehler.
if !errors.Is(validationError, totp.ErrInvalidCode) && !errors.Is(validationError, totp.ErrCodeAlreadyUsed) {
return false, false, validationError
}
// Der Code passte nicht als TOTP - vielleicht ist es ein Wiederherstellungscode.
return service.verifyRecoveryCode(verifyContext, userID, providedCode)
}
// verifyRecoveryCode prüft einen Wiederherstellungscode.
func (service *Service) verifyRecoveryCode(verifyContext context.Context, userID uuid.UUID, providedCode string) (bool, bool, error) {
unusedCodes, listError := service.repository.listUnusedRecoveryCodes(verifyContext, userID)
if listError != nil {
return false, false, listError
}
for _, unusedCode := range unusedCodes {
if verifyError := crypto.VerifyPassword(providedCode, unusedCode.codeHash); verifyError != nil {
continue
}
// Ein Wiederherstellungscode gilt genau einmal.
if consumeError := service.repository.consumeRecoveryCode(verifyContext, unusedCode.id); consumeError != nil {
return false, false, consumeError
}
return true, true, nil
}
return false, false, nil
}
// completeLogin erzeugt die Sitzung nach erfolgreicher Prüfung aller Faktoren.
func (service *Service) completeLogin(loginContext context.Context, userID uuid.UUID, username string, requestContext RequestContext) (LoginResult, error) {
tokenPair, sessionError := service.createSessionTokens(loginContext, userID, requestContext)
if sessionError != nil {
return LoginResult{}, sessionError
}
if recordError := service.repository.recordSuccessfulLogin(loginContext, userID); recordError != nil {
service.logger.Error("anmeldung konnte nicht vermerkt werden", slog.String("error", recordError.Error()))
}
loggedInUser, userError := service.repository.FindUserByID(loginContext, userID)
if userError != nil {
return LoginResult{}, userError
}
service.recordAudit(loginContext, audit.Event{
UserID: &userID,
ActorUsername: username,
Action: audit.ActionLoginSucceeded,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
UserAgent: requestContext.UserAgent,
CorrelationID: requestContext.CorrelationID,
})
return LoginResult{Tokens: tokenPair, User: &loggedInUser}, nil
}
// createSessionTokens erzeugt ein Tokenpaar und legt die Sitzung an.
func (service *Service) createSessionTokens(createContext context.Context, userID uuid.UUID, requestContext RequestContext) (*TokenPair, error) {
accessToken, accessError := crypto.GenerateToken()
if accessError != nil {
return nil, accessError
}
refreshToken, refreshError := crypto.GenerateToken()
if refreshError != nil {
return nil, refreshError
}
currentTime := service.timeSource()
accessExpiresAt := currentTime.Add(service.authConfig.AccessTokenTTL)
refreshExpiresAt := currentTime.Add(service.authConfig.RefreshTokenTTL)
// Gespeichert wird ausschließlich der Hash.
if _, sessionError := service.repository.createSession(createContext, userID,
crypto.HashToken(accessToken), crypto.HashToken(refreshToken),
accessExpiresAt, refreshExpiresAt,
requestContext.IPAddress, requestContext.UserAgent); sessionError != nil {
return nil, sessionError
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
AccessExpiresAt: accessExpiresAt,
RefreshExpiresAt: refreshExpiresAt,
}, nil
}
// Authenticate prüft ein Zugriffstoken und liefert den zugehörigen Benutzer.
//
// Die Funktion wird bei jedem geschützten Request aufgerufen.
func (service *Service) Authenticate(authenticateContext context.Context, accessToken string) (User, uuid.UUID, error) {
activeSession, sessionError := service.repository.findSessionByAccessTokenHash(authenticateContext, crypto.HashToken(accessToken))
if sessionError != nil {
return User{}, uuid.Nil, sessionError
}
authenticatedUser, userError := service.repository.FindUserByID(authenticateContext, activeSession.UserID)
if userError != nil {
return User{}, uuid.Nil, ErrSessionInvalid
}
// Ein zwischenzeitlich gesperrtes Konto verliert sofort den Zugriff -
// eine bestehende Sitzung darf die Sperre nicht überdauern.
if authenticatedUser.Status != UserStatusActive {
return User{}, uuid.Nil, ErrAccountDisabled
}
if touchError := service.repository.touchSession(authenticateContext, activeSession.ID); touchError != nil {
service.logger.Warn("sitzungsnutzung konnte nicht vermerkt werden", slog.String("error", touchError.Error()))
}
return authenticatedUser, activeSession.ID, nil
}
// Refresh erneuert eine Sitzung anhand des Erneuerungstokens.
//
// Beide Tokens werden dabei ausgetauscht (Rotation): ein abgefangenes
// Erneuerungstoken wird damit beim nächsten regulären Gebrauch wertlos.
func (service *Service) Refresh(refreshContext context.Context, refreshToken string, requestContext RequestContext) (*TokenPair, error) {
activeSession, sessionError := service.repository.findSessionByRefreshTokenHash(refreshContext, crypto.HashToken(refreshToken))
if sessionError != nil {
return nil, sessionError
}
sessionUser, userError := service.repository.FindUserByID(refreshContext, activeSession.UserID)
if userError != nil {
return nil, ErrSessionInvalid
}
if sessionUser.Status != UserStatusActive {
return nil, ErrAccountDisabled
}
newAccessToken, accessError := crypto.GenerateToken()
if accessError != nil {
return nil, accessError
}
newRefreshToken, refreshError := crypto.GenerateToken()
if refreshError != nil {
return nil, refreshError
}
currentTime := service.timeSource()
accessExpiresAt := currentTime.Add(service.authConfig.AccessTokenTTL)
refreshExpiresAt := currentTime.Add(service.authConfig.RefreshTokenTTL)
if rotateError := service.repository.rotateSessionTokens(refreshContext, activeSession.ID,
crypto.HashToken(newAccessToken), crypto.HashToken(newRefreshToken),
accessExpiresAt, refreshExpiresAt); rotateError != nil {
return nil, rotateError
}
service.recordAudit(refreshContext, audit.Event{
UserID: &sessionUser.ID,
ActorUsername: sessionUser.Username,
Action: audit.ActionTokenRefreshed,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
})
return &TokenPair{
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
AccessExpiresAt: accessExpiresAt,
RefreshExpiresAt: refreshExpiresAt,
}, nil
}
// Logout widerruft die angegebene Sitzung.
func (service *Service) Logout(logoutContext context.Context, sessionID uuid.UUID, actingUser User, requestContext RequestContext) error {
if revokeError := service.repository.revokeSession(logoutContext, sessionID); revokeError != nil {
return revokeError
}
service.recordAudit(logoutContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: audit.ActionLogout,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
})
return nil
}
// recordAudit schreibt ein Auditereignis und protokolliert Fehler.
//
// Ein fehlgeschlagener Auditeintrag lässt die auslösende Handlung nicht
// scheitern, wird aber als Fehler geloggt: die Handlung selbst ist bereits
// geschehen, und ein stiller Verlust wäre der schlechtere Ausgang.
func (service *Service) recordAudit(recordContext context.Context, auditEvent audit.Event) {
if recordError := service.auditRecorder.Record(recordContext, auditEvent); recordError != nil {
service.logger.Error("auditereignis konnte nicht geschrieben werden",
slog.String("action", string(auditEvent.Action)),
slog.String("error", recordError.Error()))
}
}
// ValidatePasswordStrength prüft die Mindestanforderungen an ein Passwort.
//
// Geprüft werden Länge und eine gewisse Vielfalt. Bewusst nicht gefordert werden
// starre Zeichenklassenregeln, da sie zu vorhersagbaren Mustern wie "Passwort1!"
// führen, ohne die Sicherheit zu erhöhen.
func ValidatePasswordStrength(password string) error {
if len([]rune(password)) < minimumPasswordLength {
return fmt.Errorf("%w: mindestens %d zeichen erforderlich", ErrWeakPassword, minimumPasswordLength)
}
// Ein Passwort aus einem einzigen wiederholten Zeichen ist trivial zu erraten.
var hasLetter, hasNonLetter bool
distinctCharacters := make(map[rune]struct{})
for _, passwordRune := range password {
distinctCharacters[passwordRune] = struct{}{}
if unicode.IsLetter(passwordRune) {
hasLetter = true
} else {
hasNonLetter = true
}
}
if len(distinctCharacters) < 5 {
return fmt.Errorf("%w: das passwort besteht aus zu wenigen verschiedenen zeichen", ErrWeakPassword)
}
if !hasLetter || !hasNonLetter {
return fmt.Errorf("%w: das passwort muss buchstaben und mindestens ein weiteres zeichen enthalten", ErrWeakPassword)
}
return nil
}