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>
498 lines
18 KiB
Go
498 lines
18 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Repository kapselt sämtliche Datenbankzugriffe der Identitätsverwaltung.
|
|
//
|
|
// Alle Abfragen verwenden ausschließlich gebundene Parameter; Benutzereingaben
|
|
// fließen niemals in den SQL-Text (PROMPT.md §97).
|
|
type Repository struct {
|
|
// connectionPool ist der Datenbankpool der Control Plane.
|
|
connectionPool *pgxpool.Pool
|
|
}
|
|
|
|
// NewRepository erzeugt eine Datenzugriffsschicht.
|
|
func NewRepository(connectionPool *pgxpool.Pool) *Repository {
|
|
return &Repository{connectionPool: connectionPool}
|
|
}
|
|
|
|
// credentialRecord bündelt die zur Anmeldung nötigen Felder eines Benutzers.
|
|
//
|
|
// Der Typ ist paketintern, damit der Passworthash die Domäne nicht verlässt.
|
|
type credentialRecord struct {
|
|
// id ist der Bezeichner des Benutzers.
|
|
id uuid.UUID
|
|
// username ist der Anmeldename.
|
|
username string
|
|
// passwordHash ist der gespeicherte Argon2id-Hash.
|
|
passwordHash string
|
|
// status ist der Kontozustand.
|
|
status UserStatus
|
|
// mfaEnabled meldet einen bestätigten zweiten Faktor.
|
|
mfaEnabled bool
|
|
// failedLoginAttempts zählt aufeinanderfolgende Fehlversuche.
|
|
failedLoginAttempts int
|
|
// lockedUntil ist der Zeitpunkt, bis zu dem das Konto gesperrt ist.
|
|
lockedUntil *time.Time
|
|
}
|
|
|
|
// findCredentialsByUsername liest die Anmeldedaten zu einem Anmeldenamen.
|
|
func (repository *Repository) findCredentialsByUsername(queryContext context.Context, username string) (credentialRecord, error) {
|
|
const selectStatement = `
|
|
SELECT id, username, coalesce(password_hash, ''), status, mfa_enabled,
|
|
failed_login_attempts, locked_until
|
|
FROM users
|
|
WHERE username = $1 AND deleted_at IS NULL`
|
|
|
|
var record credentialRecord
|
|
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, username).Scan(
|
|
&record.id, &record.username, &record.passwordHash, &record.status,
|
|
&record.mfaEnabled, &record.failedLoginAttempts, &record.lockedUntil,
|
|
)
|
|
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return credentialRecord{}, ErrUserNotFound
|
|
}
|
|
|
|
if scanError != nil {
|
|
return credentialRecord{}, fmt.Errorf("die anmeldedaten konnten nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// FindUserByID liest einen Benutzer samt Rollen und Berechtigungen.
|
|
func (repository *Repository) FindUserByID(queryContext context.Context, userID uuid.UUID) (User, error) {
|
|
const selectStatement = `
|
|
SELECT id, username, coalesce(email, ''), status, mfa_enabled,
|
|
created_at, updated_at, last_login_at
|
|
FROM users
|
|
WHERE id = $1 AND deleted_at IS NULL`
|
|
|
|
var loadedUser User
|
|
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, userID).Scan(
|
|
&loadedUser.ID, &loadedUser.Username, &loadedUser.Email, &loadedUser.Status,
|
|
&loadedUser.MFAEnabled, &loadedUser.CreatedAt, &loadedUser.UpdatedAt, &loadedUser.LastLoginAt,
|
|
)
|
|
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return User{}, ErrUserNotFound
|
|
}
|
|
|
|
if scanError != nil {
|
|
return User{}, fmt.Errorf("der benutzer konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
if loadError := repository.loadRolesAndPermissions(queryContext, &loadedUser); loadError != nil {
|
|
return User{}, loadError
|
|
}
|
|
|
|
return loadedUser, nil
|
|
}
|
|
|
|
// loadRolesAndPermissions ergänzt Rollen und abgeleitete Berechtigungen.
|
|
func (repository *Repository) loadRolesAndPermissions(queryContext context.Context, targetUser *User) error {
|
|
const rolesStatement = `
|
|
SELECT r.name
|
|
FROM user_roles ur
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE ur.user_id = $1
|
|
ORDER BY r.name`
|
|
|
|
roleRows, roleError := repository.connectionPool.Query(queryContext, rolesStatement, targetUser.ID)
|
|
if roleError != nil {
|
|
return fmt.Errorf("die rollen konnten nicht gelesen werden: %w", roleError)
|
|
}
|
|
defer roleRows.Close()
|
|
|
|
targetUser.Roles = []string{}
|
|
for roleRows.Next() {
|
|
var roleName string
|
|
if scanError := roleRows.Scan(&roleName); scanError != nil {
|
|
return fmt.Errorf("eine rolle konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
targetUser.Roles = append(targetUser.Roles, roleName)
|
|
}
|
|
|
|
if rowsError := roleRows.Err(); rowsError != nil {
|
|
return fmt.Errorf("die rollen konnten nicht vollständig gelesen werden: %w", rowsError)
|
|
}
|
|
|
|
// DISTINCT ist nötig, weil mehrere Rollen dieselbe Berechtigung enthalten können.
|
|
const permissionsStatement = `
|
|
SELECT DISTINCT p.name
|
|
FROM user_roles ur
|
|
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
|
JOIN permissions p ON p.id = rp.permission_id
|
|
WHERE ur.user_id = $1
|
|
ORDER BY p.name`
|
|
|
|
permissionRows, permissionError := repository.connectionPool.Query(queryContext, permissionsStatement, targetUser.ID)
|
|
if permissionError != nil {
|
|
return fmt.Errorf("die berechtigungen konnten nicht gelesen werden: %w", permissionError)
|
|
}
|
|
defer permissionRows.Close()
|
|
|
|
targetUser.Permissions = []string{}
|
|
for permissionRows.Next() {
|
|
var permissionName string
|
|
if scanError := permissionRows.Scan(&permissionName); scanError != nil {
|
|
return fmt.Errorf("eine berechtigung konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
targetUser.Permissions = append(targetUser.Permissions, permissionName)
|
|
}
|
|
|
|
if rowsError := permissionRows.Err(); rowsError != nil {
|
|
return fmt.Errorf("die berechtigungen konnten nicht vollständig gelesen werden: %w", rowsError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListUsers liest eine Seite von Benutzern.
|
|
func (repository *Repository) ListUsers(queryContext context.Context, searchTerm string, page int, pageSize int) ([]User, int64, error) {
|
|
whereClause := " WHERE deleted_at IS NULL"
|
|
queryArguments := make([]any, 0, 3)
|
|
|
|
if searchTerm != "" {
|
|
// ILIKE erlaubt eine Suche ohne Beachtung der Groß-/Kleinschreibung.
|
|
queryArguments = append(queryArguments, "%"+searchTerm+"%")
|
|
whereClause += fmt.Sprintf(" AND (username ILIKE $%d OR email ILIKE $%d)", len(queryArguments), len(queryArguments))
|
|
}
|
|
|
|
var totalCount int64
|
|
countError := repository.connectionPool.QueryRow(queryContext,
|
|
"SELECT count(*) FROM users"+whereClause, queryArguments...).Scan(&totalCount)
|
|
if countError != nil {
|
|
return nil, 0, fmt.Errorf("die anzahl der benutzer konnte nicht ermittelt werden: %w", countError)
|
|
}
|
|
|
|
listStatement := `
|
|
SELECT id, username, coalesce(email, ''), status, mfa_enabled, created_at, updated_at, last_login_at
|
|
FROM users` + whereClause + fmt.Sprintf(
|
|
" ORDER BY username LIMIT $%d OFFSET $%d", len(queryArguments)+1, len(queryArguments)+2)
|
|
|
|
queryArguments = append(queryArguments, pageSize, (page-1)*pageSize)
|
|
|
|
userRows, queryError := repository.connectionPool.Query(queryContext, listStatement, queryArguments...)
|
|
if queryError != nil {
|
|
return nil, 0, fmt.Errorf("die benutzer konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
defer userRows.Close()
|
|
|
|
loadedUsers := make([]User, 0, pageSize)
|
|
for userRows.Next() {
|
|
var loadedUser User
|
|
if scanError := userRows.Scan(&loadedUser.ID, &loadedUser.Username, &loadedUser.Email,
|
|
&loadedUser.Status, &loadedUser.MFAEnabled, &loadedUser.CreatedAt,
|
|
&loadedUser.UpdatedAt, &loadedUser.LastLoginAt); scanError != nil {
|
|
return nil, 0, fmt.Errorf("ein benutzer konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
loadedUsers = append(loadedUsers, loadedUser)
|
|
}
|
|
|
|
if rowsError := userRows.Err(); rowsError != nil {
|
|
return nil, 0, fmt.Errorf("die benutzer konnten nicht vollständig gelesen werden: %w", rowsError)
|
|
}
|
|
|
|
// Rollen werden je Benutzer nachgeladen. Bei den hier üblichen Seitengrößen
|
|
// ist das unkritisch; für sehr große Installationen wäre eine Sammelabfrage nötig.
|
|
for userIndex := range loadedUsers {
|
|
if loadError := repository.loadRolesAndPermissions(queryContext, &loadedUsers[userIndex]); loadError != nil {
|
|
return nil, 0, loadError
|
|
}
|
|
}
|
|
|
|
return loadedUsers, totalCount, nil
|
|
}
|
|
|
|
// CreateUser legt einen Benutzer an und weist ihm Rollen zu.
|
|
//
|
|
// Anlage und Rollenzuweisung laufen in einer Transaktion: ein Benutzer ohne
|
|
// Rollen wäre ein halb angelegtes Konto.
|
|
func (repository *Repository) CreateUser(createContext context.Context, username string, email string, passwordHash string, roleNames []string) (uuid.UUID, error) {
|
|
databaseTransaction, transactionError := repository.connectionPool.Begin(createContext)
|
|
if transactionError != nil {
|
|
return uuid.Nil, fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
|
|
}
|
|
// Ein Rollback nach erfolgreichem Commit ist wirkungslos und damit unschädlich.
|
|
defer func() { _ = databaseTransaction.Rollback(createContext) }()
|
|
|
|
const insertStatement = `
|
|
INSERT INTO users (username, email, password_hash, status)
|
|
VALUES ($1, $2, $3, 'active')
|
|
RETURNING id`
|
|
|
|
var createdUserID uuid.UUID
|
|
insertError := databaseTransaction.QueryRow(createContext, insertStatement,
|
|
username, nullIfEmpty(email), nullIfEmpty(passwordHash)).Scan(&createdUserID)
|
|
|
|
if insertError != nil {
|
|
return uuid.Nil, translateUniqueViolation(insertError)
|
|
}
|
|
|
|
if assignError := assignRolesInTransaction(createContext, databaseTransaction, createdUserID, roleNames); assignError != nil {
|
|
return uuid.Nil, assignError
|
|
}
|
|
|
|
if commitError := databaseTransaction.Commit(createContext); commitError != nil {
|
|
return uuid.Nil, fmt.Errorf("der benutzer konnte nicht gespeichert werden: %w", commitError)
|
|
}
|
|
|
|
return createdUserID, nil
|
|
}
|
|
|
|
// assignRolesInTransaction weist Rollen innerhalb einer Transaktion zu.
|
|
func assignRolesInTransaction(assignContext context.Context, databaseTransaction pgx.Tx, userID uuid.UUID, roleNames []string) error {
|
|
for _, roleName := range roleNames {
|
|
const assignStatement = `
|
|
INSERT INTO user_roles (user_id, role_id)
|
|
SELECT $1, id FROM roles WHERE name = $2
|
|
ON CONFLICT DO NOTHING`
|
|
|
|
commandTag, assignError := databaseTransaction.Exec(assignContext, assignStatement, userID, roleName)
|
|
if assignError != nil {
|
|
return fmt.Errorf("die rolle %q konnte nicht zugewiesen werden: %w", roleName, assignError)
|
|
}
|
|
|
|
// Keine betroffene Zeile bedeutet: die Rolle existiert nicht.
|
|
// Das darf nicht stillschweigend übergangen werden (PROMPT.md §140).
|
|
if commandTag.RowsAffected() == 0 {
|
|
return fmt.Errorf("%w: %q", ErrRoleNotFound, roleName)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateUser ändert die veränderlichen Felder eines Benutzers.
|
|
//
|
|
// Ein nil-Zeiger bedeutet "unverändert"; so lässt sich ein Feld gezielt leeren,
|
|
// ohne alle übrigen mitsenden zu müssen.
|
|
func (repository *Repository) UpdateUser(updateContext context.Context, userID uuid.UUID, newEmail *string, newStatus *UserStatus, newPasswordHash *string) error {
|
|
const updateStatement = `
|
|
UPDATE users SET
|
|
email = coalesce($2, email),
|
|
status = coalesce($3, status),
|
|
password_hash = coalesce($4, password_hash),
|
|
updated_at = now()
|
|
WHERE id = $1 AND deleted_at IS NULL`
|
|
|
|
var statusValue *string
|
|
if newStatus != nil {
|
|
statusText := string(*newStatus)
|
|
statusValue = &statusText
|
|
}
|
|
|
|
commandTag, updateError := repository.connectionPool.Exec(updateContext, updateStatement,
|
|
userID, newEmail, statusValue, newPasswordHash)
|
|
|
|
if updateError != nil {
|
|
return translateUniqueViolation(updateError)
|
|
}
|
|
|
|
if commandTag.RowsAffected() == 0 {
|
|
return ErrUserNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SoftDeleteUser markiert einen Benutzer als gelöscht und widerruft seine Sitzungen.
|
|
//
|
|
// Die weiche Löschung erhält die Auditspur: ein hartes DELETE würde die
|
|
// Zuordnung vergangener Handlungen zerstören (SYNCOVA_DATABASE.md §1).
|
|
func (repository *Repository) SoftDeleteUser(deleteContext context.Context, userID uuid.UUID) error {
|
|
databaseTransaction, transactionError := repository.connectionPool.Begin(deleteContext)
|
|
if transactionError != nil {
|
|
return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
|
|
}
|
|
defer func() { _ = databaseTransaction.Rollback(deleteContext) }()
|
|
|
|
// Der Anmeldename wird freigegeben, damit er neu vergeben werden kann;
|
|
// der ursprüngliche Name bleibt im Auditprotokoll erhalten.
|
|
const deleteStatement = `
|
|
UPDATE users
|
|
SET deleted_at = now(),
|
|
status = 'disabled',
|
|
username = username || '.deleted.' || extract(epoch from now())::bigint,
|
|
updated_at = now()
|
|
WHERE id = $1 AND deleted_at IS NULL`
|
|
|
|
commandTag, deleteError := databaseTransaction.Exec(deleteContext, deleteStatement, userID)
|
|
if deleteError != nil {
|
|
return fmt.Errorf("der benutzer konnte nicht gelöscht werden: %w", deleteError)
|
|
}
|
|
|
|
if commandTag.RowsAffected() == 0 {
|
|
return ErrUserNotFound
|
|
}
|
|
|
|
// Ein gelöschtes Konto darf keine gültige Sitzung behalten.
|
|
if _, revokeError := databaseTransaction.Exec(deleteContext,
|
|
"UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", userID); revokeError != nil {
|
|
return fmt.Errorf("die sitzungen konnten nicht widerrufen werden: %w", revokeError)
|
|
}
|
|
|
|
if commitError := databaseTransaction.Commit(deleteContext); commitError != nil {
|
|
return fmt.Errorf("die löschung konnte nicht gespeichert werden: %w", commitError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReplaceUserRoles ersetzt sämtliche Rollen eines Benutzers.
|
|
func (repository *Repository) ReplaceUserRoles(replaceContext context.Context, userID uuid.UUID, roleNames []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_roles WHERE user_id = $1", userID); deleteError != nil {
|
|
return fmt.Errorf("die bisherigen rollen konnten nicht entfernt werden: %w", deleteError)
|
|
}
|
|
|
|
if assignError := assignRolesInTransaction(replaceContext, databaseTransaction, userID, roleNames); assignError != nil {
|
|
return assignError
|
|
}
|
|
|
|
if commitError := databaseTransaction.Commit(replaceContext); commitError != nil {
|
|
return fmt.Errorf("die rollen konnten nicht gespeichert werden: %w", commitError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CountAdministrators zählt die aktiven Konten mit Benutzerverwaltungsrechten.
|
|
//
|
|
// Der Wert verhindert, dass sich die letzte Administration selbst aussperrt.
|
|
func (repository *Repository) CountAdministrators(queryContext context.Context, excludedUserID *uuid.UUID) (int, error) {
|
|
const countStatement = `
|
|
SELECT count(DISTINCT u.id)
|
|
FROM users u
|
|
JOIN user_roles ur ON ur.user_id = u.id
|
|
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
|
JOIN permissions p ON p.id = rp.permission_id
|
|
WHERE p.name = 'users.write'
|
|
AND u.status = 'active'
|
|
AND u.deleted_at IS NULL
|
|
AND ($1::uuid IS NULL OR u.id <> $1)`
|
|
|
|
var administratorCount int
|
|
if scanError := repository.connectionPool.QueryRow(queryContext, countStatement, excludedUserID).Scan(&administratorCount); scanError != nil {
|
|
return 0, fmt.Errorf("die anzahl der administratoren konnte nicht ermittelt werden: %w", scanError)
|
|
}
|
|
|
|
return administratorCount, nil
|
|
}
|
|
|
|
// recordFailedLogin erhöht den Fehlversuchszähler und sperrt bei Bedarf.
|
|
func (repository *Repository) recordFailedLogin(updateContext context.Context, userID uuid.UUID, maxAttempts int, lockoutDuration time.Duration) (bool, error) {
|
|
// Zählen und Sperren erfolgen in einer Anweisung, damit gleichzeitige
|
|
// Anmeldeversuche den Zähler nicht überschreiben.
|
|
const updateStatement = `
|
|
UPDATE users
|
|
SET failed_login_attempts = failed_login_attempts + 1,
|
|
locked_until = CASE
|
|
WHEN failed_login_attempts + 1 >= $2 THEN now() + $3::interval
|
|
ELSE locked_until
|
|
END,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING failed_login_attempts >= $2`
|
|
|
|
var isNowLocked bool
|
|
scanError := repository.connectionPool.QueryRow(updateContext, updateStatement,
|
|
userID, maxAttempts, fmt.Sprintf("%d seconds", int(lockoutDuration.Seconds()))).Scan(&isNowLocked)
|
|
|
|
if scanError != nil {
|
|
return false, fmt.Errorf("der fehlversuch konnte nicht vermerkt werden: %w", scanError)
|
|
}
|
|
|
|
return isNowLocked, nil
|
|
}
|
|
|
|
// recordSuccessfulLogin setzt den Fehlversuchszähler zurück.
|
|
func (repository *Repository) recordSuccessfulLogin(updateContext context.Context, userID uuid.UUID) error {
|
|
const updateStatement = `
|
|
UPDATE users
|
|
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = now(), updated_at = now()
|
|
WHERE id = $1`
|
|
|
|
if _, updateError := repository.connectionPool.Exec(updateContext, updateStatement, userID); updateError != nil {
|
|
return fmt.Errorf("die anmeldung konnte nicht vermerkt werden: %w", updateError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// nullIfEmpty wandelt eine leere Zeichenkette in NULL.
|
|
func nullIfEmpty(textValue string) *string {
|
|
if textValue == "" {
|
|
return nil
|
|
}
|
|
|
|
return &textValue
|
|
}
|
|
|
|
// translateUniqueViolation übersetzt Eindeutigkeitsverletzungen in Domänenfehler.
|
|
//
|
|
// Der Aufrufer soll "Benutzername vergeben" erhalten und nicht eine technische
|
|
// Meldung, die den Aufbau der Datenbank preisgibt.
|
|
func translateUniqueViolation(databaseError error) error {
|
|
var postgresError *pgconn.PgError
|
|
if !errors.As(databaseError, &postgresError) {
|
|
return fmt.Errorf("der benutzer konnte nicht gespeichert werden: %w", databaseError)
|
|
}
|
|
|
|
// 23505 ist der SQLSTATE für unique_violation.
|
|
if postgresError.Code != "23505" {
|
|
return fmt.Errorf("der benutzer konnte nicht gespeichert werden: %w", databaseError)
|
|
}
|
|
|
|
switch postgresError.ConstraintName {
|
|
case "users_username_key":
|
|
return ErrUsernameTaken
|
|
case "users_email_key":
|
|
return ErrEmailTaken
|
|
default:
|
|
return fmt.Errorf("der datensatz existiert bereits: %w", databaseError)
|
|
}
|
|
}
|
|
|
|
// FindUserByUsername liest einen Benutzer anhand seines Anmeldenamens.
|
|
//
|
|
// Die Funktion dient der Kommandozeile; die Anmeldung verwendet stattdessen
|
|
// findCredentialsByUsername, das zusätzlich den Passworthash liefert.
|
|
func (repository *Repository) FindUserByUsername(queryContext context.Context, username string) (User, error) {
|
|
const selectStatement = `SELECT id FROM users WHERE username = $1 AND deleted_at IS NULL`
|
|
|
|
var foundUserID uuid.UUID
|
|
scanError := repository.connectionPool.QueryRow(queryContext, selectStatement, username).Scan(&foundUserID)
|
|
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return User{}, ErrUserNotFound
|
|
}
|
|
|
|
if scanError != nil {
|
|
return User{}, fmt.Errorf("der benutzer konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
return repository.FindUserByID(queryContext, foundUserID)
|
|
}
|