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 }