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 }