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>
391 lines
13 KiB
Go
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
|
|
}
|