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>
555 lines
20 KiB
Go
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
|
|
}
|