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

391 lines
13 KiB
Go

package auth
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/audit"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/platform/totp"
)
// CreateUserRequest beschreibt einen anzulegenden Benutzer.
type CreateUserRequest struct {
// Username ist der gewünschte Anmeldename.
Username string
// Email ist die optionale Mailadresse.
Email string
// Password ist das Anfangspasswort.
Password string
// RoleNames sind die zuzuweisenden Rollen.
RoleNames []string
}
// CreateUser legt einen Benutzer an.
func (service *Service) CreateUser(createContext context.Context, request CreateUserRequest, actingUser User, requestContext RequestContext) (User, error) {
normalizedUsername := strings.TrimSpace(strings.ToLower(request.Username))
if normalizedUsername == "" {
return User{}, fmt.Errorf("der benutzername darf nicht leer sein")
}
if strengthError := ValidatePasswordStrength(request.Password); strengthError != nil {
return User{}, strengthError
}
// Ein Benutzer ohne Rolle könnte sich anmelden, aber nichts tun. Das ist
// fast immer ein Versehen und wird deshalb abgelehnt.
if len(request.RoleNames) == 0 {
return User{}, fmt.Errorf("es muss mindestens eine rolle zugewiesen werden")
}
passwordHash, hashError := crypto.HashPassword(request.Password)
if hashError != nil {
return User{}, hashError
}
createdUserID, createError := service.repository.CreateUser(createContext,
normalizedUsername, strings.TrimSpace(request.Email), passwordHash, request.RoleNames)
if createError != nil {
return User{}, createError
}
service.recordAudit(createContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: audit.ActionUserCreated,
EntityType: "user",
EntityID: &createdUserID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
// Das Passwort erscheint selbstverständlich nicht in den Details.
Details: map[string]any{"username": normalizedUsername, "rollen": request.RoleNames},
})
return service.repository.FindUserByID(createContext, createdUserID)
}
// UpdateUserRequest beschreibt zu ändernde Felder eines Benutzers.
//
// Ein nil-Zeiger bedeutet "unverändert".
type UpdateUserRequest struct {
// Email ist die neue Mailadresse.
Email *string
// Status ist der neue Kontozustand.
Status *UserStatus
// Password ist das neue Passwort.
Password *string
// RoleNames sind die neuen Rollen; sie ersetzen die bisherigen vollständig.
RoleNames []string
}
// UpdateUser ändert einen Benutzer.
func (service *Service) UpdateUser(updateContext context.Context, targetUserID uuid.UUID, request UpdateUserRequest, actingUser User, requestContext RequestContext) (User, error) {
// Wer die Rechte oder den Zustand des letzten Administrators ändert, könnte
// die Installation unbedienbar machen (PROMPT.md §126).
if request.Status != nil && *request.Status != UserStatusActive {
if guardError := service.ensureNotLastAdministrator(updateContext, targetUserID); guardError != nil {
return User{}, guardError
}
}
if request.RoleNames != nil {
if guardError := service.ensureRoleChangeKeepsAdministrator(updateContext, targetUserID, request.RoleNames); guardError != nil {
return User{}, guardError
}
}
var newPasswordHash *string
if request.Password != nil {
if strengthError := ValidatePasswordStrength(*request.Password); strengthError != nil {
return User{}, strengthError
}
hashedPassword, hashError := crypto.HashPassword(*request.Password)
if hashError != nil {
return User{}, hashError
}
newPasswordHash = &hashedPassword
}
if updateError := service.repository.UpdateUser(updateContext, targetUserID, request.Email, request.Status, newPasswordHash); updateError != nil {
return User{}, updateError
}
if request.RoleNames != nil {
if roleError := service.repository.ReplaceUserRoles(updateContext, targetUserID, request.RoleNames); roleError != nil {
return User{}, roleError
}
service.recordAudit(updateContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: audit.ActionRoleAssigned,
EntityType: "user",
EntityID: &targetUserID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"rollen": request.RoleNames},
})
}
// Eine Passwortänderung beendet alle bestehenden Sitzungen: andernfalls
// bliebe ein Angreifer trotz neuem Passwort angemeldet.
if request.Password != nil {
if revokeError := service.repository.RevokeAllSessionsOfUser(updateContext, targetUserID); revokeError != nil {
return User{}, revokeError
}
service.recordAudit(updateContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: audit.ActionPasswordChanged,
EntityType: "user",
EntityID: &targetUserID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
})
}
// Ein deaktiviertes Konto verliert sofort alle Sitzungen.
if request.Status != nil && *request.Status != UserStatusActive {
if revokeError := service.repository.RevokeAllSessionsOfUser(updateContext, targetUserID); revokeError != nil {
return User{}, revokeError
}
}
service.recordAudit(updateContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: audit.ActionUserUpdated,
EntityType: "user",
EntityID: &targetUserID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
})
return service.repository.FindUserByID(updateContext, targetUserID)
}
// DeleteUser löscht einen Benutzer weich.
func (service *Service) DeleteUser(deleteContext context.Context, targetUserID uuid.UUID, actingUser User, requestContext RequestContext) error {
// Sich selbst zu löschen würde die eigene Sitzung ungültig machen und ist
// fast immer ein Versehen.
if targetUserID == actingUser.ID {
return fmt.Errorf("das eigene konto kann nicht gelöscht werden")
}
if guardError := service.ensureNotLastAdministrator(deleteContext, targetUserID); guardError != nil {
return guardError
}
deletedUser, lookupError := service.repository.FindUserByID(deleteContext, targetUserID)
if lookupError != nil {
return lookupError
}
if deleteError := service.repository.SoftDeleteUser(deleteContext, targetUserID); deleteError != nil {
return deleteError
}
service.recordAudit(deleteContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: audit.ActionUserDeleted,
EntityType: "user",
EntityID: &targetUserID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
Details: map[string]any{"username": deletedUser.Username},
})
return nil
}
// ensureNotLastAdministrator verhindert das Entfernen des letzten Administrators.
func (service *Service) ensureNotLastAdministrator(guardContext context.Context, targetUserID uuid.UUID) error {
targetUser, lookupError := service.repository.FindUserByID(guardContext, targetUserID)
if lookupError != nil {
return lookupError
}
// Nur Konten mit Benutzerverwaltungsrecht sind hier überhaupt relevant.
if !targetUser.HasPermission("users.write") {
return nil
}
remainingAdministrators, countError := service.repository.CountAdministrators(guardContext, &targetUserID)
if countError != nil {
return countError
}
if remainingAdministrators == 0 {
return ErrLastAdministrator
}
return nil
}
// ensureRoleChangeKeepsAdministrator prüft, ob nach einer Rollenänderung noch
// ein Administrator übrig bleibt.
func (service *Service) ensureRoleChangeKeepsAdministrator(guardContext context.Context, targetUserID uuid.UUID, newRoleNames []string) error {
targetUser, lookupError := service.repository.FindUserByID(guardContext, targetUserID)
if lookupError != nil {
return lookupError
}
if !targetUser.HasPermission("users.write") {
return nil
}
// Behält der Benutzer eine Rolle mit Benutzerverwaltung, ändert sich nichts.
for _, newRoleName := range newRoleNames {
if newRoleName == "super_administrator" || newRoleName == "security_administrator" {
return nil
}
}
remainingAdministrators, countError := service.repository.CountAdministrators(guardContext, &targetUserID)
if countError != nil {
return countError
}
if remainingAdministrators == 0 {
return ErrLastAdministrator
}
return nil
}
// ListUsers liest eine Seite von Benutzern.
func (service *Service) ListUsers(queryContext context.Context, searchTerm string, page int, pageSize int) ([]User, int64, error) {
return service.repository.ListUsers(queryContext, searchTerm, page, pageSize)
}
// GetUser liest einen Benutzer.
func (service *Service) GetUser(queryContext context.Context, userID uuid.UUID) (User, error) {
return service.repository.FindUserByID(queryContext, userID)
}
// ---------------------------------------------------------------------------
// Zweiter Faktor
// ---------------------------------------------------------------------------
// BeginMFAEnrollment erzeugt ein neues TOTP-Secret samt Wiederherstellungscodes.
//
// Der zweite Faktor ist erst nach Bestätigung mit einem gültigen Code aktiv:
// sonst könnte sich ein Benutzer aussperren, dessen App das Secret nicht
// korrekt übernommen hat.
func (service *Service) BeginMFAEnrollment(enrollContext context.Context, targetUser User) (MFAEnrollment, error) {
generatedSecret, secretError := totp.GenerateSecret()
if secretError != nil {
return MFAEnrollment{}, secretError
}
encryptedSecret, keyVersion, encryptError := service.secretStore.Encrypt([]byte(generatedSecret))
if encryptError != nil {
return MFAEnrollment{}, fmt.Errorf("das secret konnte nicht sicher abgelegt werden: %w", encryptError)
}
if upsertError := service.repository.upsertTOTPMethod(enrollContext, targetUser.ID, encryptedSecret, keyVersion); upsertError != nil {
return MFAEnrollment{}, upsertError
}
// Die Wiederherstellungscodes werden hier im Klartext erzeugt und danach
// ausschließlich als Hash gespeichert.
plainRecoveryCodes := make([]string, 0, recoveryCodeCount)
hashedRecoveryCodes := make([]string, 0, recoveryCodeCount)
for codeIndex := 0; codeIndex < recoveryCodeCount; codeIndex++ {
recoveryCode, codeError := crypto.GenerateRecoveryCode()
if codeError != nil {
return MFAEnrollment{}, codeError
}
hashedCode, hashError := crypto.HashPassword(recoveryCode)
if hashError != nil {
return MFAEnrollment{}, hashError
}
plainRecoveryCodes = append(plainRecoveryCodes, recoveryCode)
hashedRecoveryCodes = append(hashedRecoveryCodes, hashedCode)
}
if replaceError := service.repository.replaceRecoveryCodes(enrollContext, targetUser.ID, hashedRecoveryCodes); replaceError != nil {
return MFAEnrollment{}, replaceError
}
return MFAEnrollment{
Secret: generatedSecret,
ProvisioningURI: totp.ProvisioningURI(generatedSecret, targetUser.Username, issuerName, totp.DefaultConfiguration()),
RecoveryCodes: plainRecoveryCodes,
}, nil
}
// ConfirmMFAEnrollment schaltet den zweiten Faktor nach erfolgreicher Prüfung scharf.
func (service *Service) ConfirmMFAEnrollment(confirmContext context.Context, targetUser User, providedCode string, requestContext RequestContext) error {
totpMethod, methodError := service.repository.findTOTPMethod(confirmContext, targetUser.ID)
if methodError != nil {
return methodError
}
if totpMethod.enabled {
return ErrMFAAlreadyEnabled
}
secretBytes, decryptError := service.secretStore.Decrypt(totpMethod.secretCiphertext, totpMethod.keyVersion)
if decryptError != nil {
return fmt.Errorf("der zweite faktor konnte nicht geprüft werden: %w", decryptError)
}
validationResult, validationError := totp.Validate(
string(secretBytes), providedCode, service.timeSource(), 0, totp.DefaultConfiguration())
if validationError != nil {
return ErrInvalidMFACode
}
if confirmError := service.repository.confirmTOTPMethod(confirmContext, targetUser.ID, validationResult.TimeStep); confirmError != nil {
return confirmError
}
service.recordAudit(confirmContext, audit.Event{
UserID: &targetUser.ID,
ActorUsername: targetUser.Username,
Action: audit.ActionMFAEnrolled,
EntityType: "user",
EntityID: &targetUser.ID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
})
return nil
}
// DisableMFA entfernt den zweiten Faktor eines Benutzers.
func (service *Service) DisableMFA(disableContext context.Context, targetUserID uuid.UUID, actingUser User, requestContext RequestContext) error {
if disableError := service.repository.disableMFA(disableContext, targetUserID); disableError != nil {
return disableError
}
service.recordAudit(disableContext, audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: audit.ActionMFADisabled,
EntityType: "user",
EntityID: &targetUserID,
Result: audit.ResultSuccess,
IPAddress: requestContext.IPAddress,
CorrelationID: requestContext.CorrelationID,
})
return nil
}