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

483 lines
18 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// createSession legt eine Sitzung an.
//
// Gespeichert werden ausschließlich die Hashes der Tokens; die Klartextwerte
// verlassen den Server nur einmal in der Anmeldeantwort.
func (repository *Repository) createSession(createContext context.Context, userID uuid.UUID, accessTokenHash string, refreshTokenHash string, accessExpiresAt time.Time, refreshExpiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error) {
const insertStatement = `
INSERT INTO sessions
(user_id, access_token_hash, refresh_token_hash, access_expires_at,
refresh_expires_at, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`
var createdSessionID uuid.UUID
insertError := repository.connectionPool.QueryRow(createContext, insertStatement,
userID, accessTokenHash, refreshTokenHash, accessExpiresAt, refreshExpiresAt,
nullIfEmptyInet(ipAddress), nullIfEmpty(userAgent)).Scan(&createdSessionID)
if insertError != nil {
return uuid.Nil, fmt.Errorf("die sitzung konnte nicht angelegt werden: %w", insertError)
}
return createdSessionID, nil
}
// findSessionByAccessTokenHash sucht eine gültige Sitzung zum Zugriffstoken.
//
// Abgelaufene und widerrufene Sitzungen werden in der Abfrage ausgeschlossen,
// damit ein vergessener Zustandsvergleich im Code nicht zu einem Zugriff führt.
func (repository *Repository) findSessionByAccessTokenHash(queryContext context.Context, accessTokenHash string) (Session, error) {
const selectStatement = `
SELECT id, user_id, access_expires_at, refresh_expires_at
FROM sessions
WHERE access_token_hash = $1
AND revoked_at IS NULL
AND access_expires_at > now()`
var foundSession Session
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, accessTokenHash).Scan(
&foundSession.ID, &foundSession.UserID, &foundSession.AccessExpiresAt, &foundSession.RefreshExpiresAt)
if errors.Is(scanError, pgx.ErrNoRows) {
return Session{}, ErrSessionInvalid
}
if scanError != nil {
return Session{}, fmt.Errorf("die sitzung konnte nicht gelesen werden: %w", scanError)
}
return foundSession, nil
}
// findSessionByRefreshTokenHash sucht eine gültige Sitzung zum Erneuerungstoken.
func (repository *Repository) findSessionByRefreshTokenHash(queryContext context.Context, refreshTokenHash string) (Session, error) {
const selectStatement = `
SELECT id, user_id, access_expires_at, refresh_expires_at
FROM sessions
WHERE refresh_token_hash = $1
AND revoked_at IS NULL
AND refresh_expires_at > now()`
var foundSession Session
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, refreshTokenHash).Scan(
&foundSession.ID, &foundSession.UserID, &foundSession.AccessExpiresAt, &foundSession.RefreshExpiresAt)
if errors.Is(scanError, pgx.ErrNoRows) {
return Session{}, ErrSessionInvalid
}
if scanError != nil {
return Session{}, fmt.Errorf("die sitzung konnte nicht gelesen werden: %w", scanError)
}
return foundSession, nil
}
// touchSession vermerkt die Verwendung einer Sitzung.
func (repository *Repository) touchSession(updateContext context.Context, sessionID uuid.UUID) error {
if _, updateError := repository.connectionPool.Exec(updateContext,
"UPDATE sessions SET last_used_at = now() WHERE id = $1", sessionID); updateError != nil {
return fmt.Errorf("die sitzung konnte nicht aktualisiert werden: %w", updateError)
}
return nil
}
// rotateSessionTokens tauscht die Tokens einer Sitzung aus.
//
// Bei jeder Erneuerung werden beide Tokens ersetzt. Ein abgefangenes
// Erneuerungstoken wird damit spätestens beim nächsten regulären Gebrauch wertlos.
func (repository *Repository) rotateSessionTokens(updateContext context.Context, sessionID uuid.UUID, newAccessTokenHash string, newRefreshTokenHash string, accessExpiresAt time.Time, refreshExpiresAt time.Time) error {
const updateStatement = `
UPDATE sessions
SET access_token_hash = $2,
refresh_token_hash = $3,
access_expires_at = $4,
refresh_expires_at = $5,
last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL`
commandTag, updateError := repository.connectionPool.Exec(updateContext, updateStatement,
sessionID, newAccessTokenHash, newRefreshTokenHash, accessExpiresAt, refreshExpiresAt)
if updateError != nil {
return fmt.Errorf("die sitzung konnte nicht erneuert werden: %w", updateError)
}
// Keine betroffene Zeile bedeutet: die Sitzung wurde zwischenzeitlich widerrufen.
if commandTag.RowsAffected() == 0 {
return ErrSessionInvalid
}
return nil
}
// revokeSession widerruft eine einzelne Sitzung.
func (repository *Repository) revokeSession(updateContext context.Context, sessionID uuid.UUID) error {
if _, updateError := repository.connectionPool.Exec(updateContext,
"UPDATE sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", sessionID); updateError != nil {
return fmt.Errorf("die sitzung konnte nicht widerrufen werden: %w", updateError)
}
return nil
}
// RevokeAllSessionsOfUser widerruft sämtliche Sitzungen eines Benutzers.
//
// Das ist die Reaktion auf eine Passwortänderung, eine Sperre oder einen
// Sicherheitsvorfall.
func (repository *Repository) RevokeAllSessionsOfUser(updateContext context.Context, userID uuid.UUID) error {
if _, updateError := repository.connectionPool.Exec(updateContext,
"UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", userID); updateError != nil {
return fmt.Errorf("die sitzungen konnten nicht widerrufen werden: %w", updateError)
}
return nil
}
// DeleteExpiredSessions entfernt abgelaufene Sitzungen.
//
// Die Bereinigung hält die Tabelle klein; sicherheitsrelevant ist sie nicht,
// da abgelaufene Sitzungen bereits in den Abfragen ausgeschlossen werden.
func (repository *Repository) DeleteExpiredSessions(deleteContext context.Context) (int64, error) {
commandTag, deleteError := repository.connectionPool.Exec(deleteContext,
"DELETE FROM sessions WHERE refresh_expires_at < now() - interval '7 days'")
if deleteError != nil {
return 0, fmt.Errorf("die abgelaufenen sitzungen konnten nicht entfernt werden: %w", deleteError)
}
return commandTag.RowsAffected(), nil
}
// ---------------------------------------------------------------------------
// MFA-Herausforderungen
// ---------------------------------------------------------------------------
// createMFAChallenge legt eine offene MFA-Herausforderung an.
func (repository *Repository) createMFAChallenge(createContext context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string) (uuid.UUID, error) {
const insertStatement = `
INSERT INTO mfa_challenges (user_id, expires_at, ip_address)
VALUES ($1, $2, $3)
RETURNING id`
var createdChallengeID uuid.UUID
insertError := repository.connectionPool.QueryRow(createContext, insertStatement,
userID, expiresAt, nullIfEmptyInet(ipAddress)).Scan(&createdChallengeID)
if insertError != nil {
return uuid.Nil, fmt.Errorf("die anmeldung konnte nicht fortgesetzt werden: %w", insertError)
}
return createdChallengeID, nil
}
// challengeRecord ist eine gelesene MFA-Herausforderung.
type challengeRecord struct {
// id ist der Bezeichner der Herausforderung.
id uuid.UUID
// userID ist der zugehörige Benutzer.
userID uuid.UUID
// attempts ist die Anzahl bisheriger Fehlversuche.
attempts int
}
// findOpenMFAChallenge liest eine noch offene Herausforderung.
func (repository *Repository) findOpenMFAChallenge(queryContext context.Context, challengeID uuid.UUID) (challengeRecord, error) {
const selectStatement = `
SELECT id, user_id, attempts
FROM mfa_challenges
WHERE id = $1 AND consumed_at IS NULL AND expires_at > now()`
var record challengeRecord
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, challengeID).Scan(
&record.id, &record.userID, &record.attempts)
if errors.Is(scanError, pgx.ErrNoRows) {
return challengeRecord{}, ErrChallengeNotFound
}
if scanError != nil {
return challengeRecord{}, fmt.Errorf("die anmeldung konnte nicht geprüft werden: %w", scanError)
}
return record, nil
}
// incrementMFAChallengeAttempts erhöht den Fehlversuchszähler einer Herausforderung.
func (repository *Repository) incrementMFAChallengeAttempts(updateContext context.Context, challengeID uuid.UUID) error {
if _, updateError := repository.connectionPool.Exec(updateContext,
"UPDATE mfa_challenges SET attempts = attempts + 1 WHERE id = $1", challengeID); updateError != nil {
return fmt.Errorf("der fehlversuch konnte nicht vermerkt werden: %w", updateError)
}
return nil
}
// consumeMFAChallenge markiert eine Herausforderung als eingelöst.
//
// Die Bedingung auf consumed_at stellt sicher, dass zwei gleichzeitige Anfragen
// nicht beide eine Sitzung erhalten.
func (repository *Repository) consumeMFAChallenge(updateContext context.Context, challengeID uuid.UUID) error {
commandTag, updateError := repository.connectionPool.Exec(updateContext,
"UPDATE mfa_challenges SET consumed_at = now() WHERE id = $1 AND consumed_at IS NULL", challengeID)
if updateError != nil {
return fmt.Errorf("die anmeldung konnte nicht abgeschlossen werden: %w", updateError)
}
if commandTag.RowsAffected() == 0 {
return ErrChallengeNotFound
}
return nil
}
// ---------------------------------------------------------------------------
// Zweiter Faktor
// ---------------------------------------------------------------------------
// mfaMethodRecord ist eine gelesene MFA-Methode.
type mfaMethodRecord struct {
// id ist der Bezeichner der Methode.
id uuid.UUID
// secretCiphertext ist das verschlüsselte Secret.
secretCiphertext []byte
// keyVersion benennt den zur Entschlüsselung nötigen Schlüssel.
keyVersion string
// enabled meldet, ob die Methode bestätigt wurde.
enabled bool
// lastUsedTimeStep ist der zuletzt akzeptierte TOTP-Zeitschritt.
lastUsedTimeStep *int64
}
// upsertTOTPMethod legt eine TOTP-Methode an oder ersetzt eine unbestätigte.
func (repository *Repository) upsertTOTPMethod(upsertContext context.Context, userID uuid.UUID, secretCiphertext []byte, keyVersion string) error {
// Eine bereits bestätigte Methode wird nicht überschrieben: sonst könnte ein
// Angreifer mit einer offenen Sitzung den zweiten Faktor stillschweigend ersetzen.
const upsertStatement = `
INSERT INTO user_mfa_methods (user_id, type, secret_ciphertext, key_version, enabled)
VALUES ($1, 'totp', $2, $3, FALSE)
ON CONFLICT (user_id, type) DO UPDATE
SET secret_ciphertext = EXCLUDED.secret_ciphertext,
key_version = EXCLUDED.key_version,
last_used_time_step = NULL
WHERE user_mfa_methods.enabled = FALSE`
commandTag, upsertError := repository.connectionPool.Exec(upsertContext, upsertStatement, userID, secretCiphertext, keyVersion)
if upsertError != nil {
return fmt.Errorf("der zweite faktor konnte nicht gespeichert werden: %w", upsertError)
}
if commandTag.RowsAffected() == 0 {
return ErrMFAAlreadyEnabled
}
return nil
}
// findTOTPMethod liest die TOTP-Methode eines Benutzers.
func (repository *Repository) findTOTPMethod(queryContext context.Context, userID uuid.UUID) (mfaMethodRecord, error) {
const selectStatement = `
SELECT id, secret_ciphertext, key_version, enabled, last_used_time_step
FROM user_mfa_methods
WHERE user_id = $1 AND type = 'totp'`
var record mfaMethodRecord
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, userID).Scan(
&record.id, &record.secretCiphertext, &record.keyVersion, &record.enabled, &record.lastUsedTimeStep)
if errors.Is(scanError, pgx.ErrNoRows) {
return mfaMethodRecord{}, ErrMFANotEnrolled
}
if scanError != nil {
return mfaMethodRecord{}, fmt.Errorf("der zweite faktor konnte nicht gelesen werden: %w", scanError)
}
return record, nil
}
// confirmTOTPMethod schaltet eine Methode scharf und aktiviert MFA am Konto.
func (repository *Repository) confirmTOTPMethod(confirmContext context.Context, userID uuid.UUID, usedTimeStep int64) error {
databaseTransaction, transactionError := repository.connectionPool.Begin(confirmContext)
if transactionError != nil {
return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
defer func() { _ = databaseTransaction.Rollback(confirmContext) }()
if _, updateError := databaseTransaction.Exec(confirmContext, `
UPDATE user_mfa_methods
SET enabled = TRUE, confirmed_at = now(), last_used_time_step = $2
WHERE user_id = $1 AND type = 'totp'`, userID, usedTimeStep); updateError != nil {
return fmt.Errorf("der zweite faktor konnte nicht bestätigt werden: %w", updateError)
}
if _, updateError := databaseTransaction.Exec(confirmContext,
"UPDATE users SET mfa_enabled = TRUE, updated_at = now() WHERE id = $1", userID); updateError != nil {
return fmt.Errorf("das konto konnte nicht aktualisiert werden: %w", updateError)
}
if commitError := databaseTransaction.Commit(confirmContext); commitError != nil {
return fmt.Errorf("die bestätigung konnte nicht gespeichert werden: %w", commitError)
}
return nil
}
// updateTOTPTimeStep vermerkt den zuletzt verwendeten Zeitschritt.
//
// Die Bedingung stellt sicher, dass der Wert nur steigt: bei gleichzeitigen
// Anfragen darf ein älterer Schritt den neueren nicht überschreiben.
func (repository *Repository) updateTOTPTimeStep(updateContext context.Context, methodID uuid.UUID, usedTimeStep int64) error {
const updateStatement = `
UPDATE user_mfa_methods
SET last_used_time_step = $2
WHERE id = $1 AND (last_used_time_step IS NULL OR last_used_time_step < $2)`
if _, updateError := repository.connectionPool.Exec(updateContext, updateStatement, methodID, usedTimeStep); updateError != nil {
return fmt.Errorf("der verwendete code konnte nicht vermerkt werden: %w", updateError)
}
return nil
}
// disableMFA entfernt alle zweiten Faktoren und Wiederherstellungscodes.
func (repository *Repository) disableMFA(disableContext context.Context, userID uuid.UUID) error {
databaseTransaction, transactionError := repository.connectionPool.Begin(disableContext)
if transactionError != nil {
return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
defer func() { _ = databaseTransaction.Rollback(disableContext) }()
if _, deleteError := databaseTransaction.Exec(disableContext,
"DELETE FROM user_mfa_methods WHERE user_id = $1", userID); deleteError != nil {
return fmt.Errorf("der zweite faktor konnte nicht entfernt werden: %w", deleteError)
}
if _, deleteError := databaseTransaction.Exec(disableContext,
"DELETE FROM user_recovery_codes WHERE user_id = $1", userID); deleteError != nil {
return fmt.Errorf("die wiederherstellungscodes konnten nicht entfernt werden: %w", deleteError)
}
if _, updateError := databaseTransaction.Exec(disableContext,
"UPDATE users SET mfa_enabled = FALSE, updated_at = now() WHERE id = $1", userID); updateError != nil {
return fmt.Errorf("das konto konnte nicht aktualisiert werden: %w", updateError)
}
if commitError := databaseTransaction.Commit(disableContext); commitError != nil {
return fmt.Errorf("die änderung konnte nicht gespeichert werden: %w", commitError)
}
return nil
}
// ---------------------------------------------------------------------------
// Wiederherstellungscodes
// ---------------------------------------------------------------------------
// replaceRecoveryCodes ersetzt sämtliche Wiederherstellungscodes eines Benutzers.
func (repository *Repository) replaceRecoveryCodes(replaceContext context.Context, userID uuid.UUID, codeHashes []string) error {
databaseTransaction, transactionError := repository.connectionPool.Begin(replaceContext)
if transactionError != nil {
return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
defer func() { _ = databaseTransaction.Rollback(replaceContext) }()
if _, deleteError := databaseTransaction.Exec(replaceContext,
"DELETE FROM user_recovery_codes WHERE user_id = $1", userID); deleteError != nil {
return fmt.Errorf("die bisherigen codes konnten nicht entfernt werden: %w", deleteError)
}
for _, codeHash := range codeHashes {
if _, insertError := databaseTransaction.Exec(replaceContext,
"INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)", userID, codeHash); insertError != nil {
return fmt.Errorf("ein wiederherstellungscode konnte nicht gespeichert werden: %w", insertError)
}
}
if commitError := databaseTransaction.Commit(replaceContext); commitError != nil {
return fmt.Errorf("die wiederherstellungscodes konnten nicht gespeichert werden: %w", commitError)
}
return nil
}
// recoveryCodeRecord ist ein ungenutzter Wiederherstellungscode.
type recoveryCodeRecord struct {
// id ist der Bezeichner des Codes.
id uuid.UUID
// codeHash ist der gespeicherte Argon2id-Hash.
codeHash string
}
// listUnusedRecoveryCodes liest alle noch nicht verwendeten Codes.
func (repository *Repository) listUnusedRecoveryCodes(queryContext context.Context, userID uuid.UUID) ([]recoveryCodeRecord, error) {
const selectStatement = `
SELECT id, code_hash
FROM user_recovery_codes
WHERE user_id = $1 AND used_at IS NULL`
codeRows, queryError := repository.connectionPool.Query(queryContext, selectStatement, userID)
if queryError != nil {
return nil, fmt.Errorf("die wiederherstellungscodes konnten nicht gelesen werden: %w", queryError)
}
defer codeRows.Close()
var unusedCodes []recoveryCodeRecord
for codeRows.Next() {
var record recoveryCodeRecord
if scanError := codeRows.Scan(&record.id, &record.codeHash); scanError != nil {
return nil, fmt.Errorf("ein wiederherstellungscode konnte nicht gelesen werden: %w", scanError)
}
unusedCodes = append(unusedCodes, record)
}
if rowsError := codeRows.Err(); rowsError != nil {
return nil, fmt.Errorf("die wiederherstellungscodes konnten nicht vollständig gelesen werden: %w", rowsError)
}
return unusedCodes, nil
}
// consumeRecoveryCode entwertet einen Wiederherstellungscode.
//
// Die Bedingung auf used_at verhindert, dass derselbe Code bei gleichzeitigen
// Anfragen zweimal gilt.
func (repository *Repository) consumeRecoveryCode(updateContext context.Context, codeID uuid.UUID) error {
commandTag, updateError := repository.connectionPool.Exec(updateContext,
"UPDATE user_recovery_codes SET used_at = now() WHERE id = $1 AND used_at IS NULL", codeID)
if updateError != nil {
return fmt.Errorf("der wiederherstellungscode konnte nicht entwertet werden: %w", updateError)
}
if commandTag.RowsAffected() == 0 {
return ErrInvalidMFACode
}
return nil
}
// nullIfEmptyInet wandelt eine leere Adresse in NULL.
func nullIfEmptyInet(ipAddress string) *string {
if ipAddress == "" {
return nil
}
return &ipAddress
}