package models import ( "context" "crypto/rand" "encoding/base64" "errors" "fmt" "regexp" "strings" "slices" "github.com/oklog/ulid/v2" "golang.org/x/crypto/argon2" "gorm.io/gorm" ) // Argon2 Parameter const ( // Empfohlene Werte für Argon2id ArgonTime = 1 ArgonMemory = 64 * 1024 // 64MB ArgonThreads = 4 ArgonKeyLen = 32 SaltLength = 16 ) // Rollen-Konstanten const ( RoleAdmin = "admin" RoleUser = "user" RoleViewer = "viewer" ) // User repräsentiert einen Benutzer im System type User struct { EntityBase Email string `gorm:"column:email;unique;not null"` Salt string `gorm:"column:salt;not null;type:varchar(64)"` // Basis64-codierter Salt Hash string `gorm:"column:hash;not null;type:varchar(128)"` // Basis64-codierter Hash Role string `gorm:"column:role;not null;default:'user'"` CompanyID ulid.ULID `gorm:"column:company_id;type:uuid;not null;index"` HourlyRate float64 `gorm:"column:hourly_rate;not null;default:0"` // Beziehung für Eager Loading Company *Company `gorm:"foreignKey:CompanyID"` } // TableName gibt den Tabellennamen für GORM an func (User) TableName() string { return "users" } // UserCreate enthält die Felder zum Erstellen eines neuen Benutzers type UserCreate struct { Email string Password string Role string CompanyID ulid.ULID HourlyRate float64 } // UserUpdate enthält die aktualisierbaren Felder eines Benutzers type UserUpdate struct { ID ulid.ULID `gorm:"-"` // Ausschließen von Updates Email *string `gorm:"column:email"` Password *string `gorm:"-"` // Nicht direkt in DB speichern Role *string `gorm:"column:role"` CompanyID *ulid.ULID `gorm:"column:company_id"` HourlyRate *float64 `gorm:"column:hourly_rate"` } // PasswordData enthält die Daten für Passwort-Hash und Salt type PasswordData struct { Salt string Hash string } // GenerateSalt erzeugt einen kryptografisch sicheren Salt func GenerateSalt() (string, error) { salt := make([]byte, SaltLength) _, err := rand.Read(salt) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(salt), nil } // HashPassword erstellt einen sicheren Passwort-Hash mit Argon2id und einem zufälligen Salt func HashPassword(password string) (PasswordData, error) { // Erzeugen eines kryptografisch sicheren Salts saltStr, err := GenerateSalt() if err != nil { return PasswordData{}, fmt.Errorf("fehler beim Generieren des Salt: %w", err) } salt, err := base64.StdEncoding.DecodeString(saltStr) if err != nil { return PasswordData{}, fmt.Errorf("fehler beim Dekodieren des Salt: %w", err) } // Hash mit Argon2id erstellen (moderne, sichere Hash-Funktion) hash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen) hashStr := base64.StdEncoding.EncodeToString(hash) return PasswordData{ Salt: saltStr, Hash: hashStr, }, nil } // VerifyPassword prüft, ob ein Passwort mit dem Hash übereinstimmt func VerifyPassword(password, saltStr, hashStr string) (bool, error) { salt, err := base64.StdEncoding.DecodeString(saltStr) if err != nil { return false, fmt.Errorf("fehler beim Dekodieren des Salt: %w", err) } hash, err := base64.StdEncoding.DecodeString(hashStr) if err != nil { return false, fmt.Errorf("fehler beim Dekodieren des Hash: %w", err) } // Hash mit gleichem Salt berechnen computedHash := argon2.IDKey([]byte(password), salt, ArgonTime, ArgonMemory, ArgonThreads, ArgonKeyLen) // Konstante Zeit-Vergleich, um Timing-Angriffe zu vermeiden return hmacEqual(hash, computedHash), nil } // hmacEqual führt einen konstante-Zeit Vergleich durch (verhindert Timing-Attacken) func hmacEqual(a, b []byte) bool { if len(a) != len(b) { return false } var result byte for i := 0; i < len(a); i++ { result |= a[i] ^ b[i] } return result == 0 } // Validate prüft, ob die Create-Struktur gültige Daten enthält func (uc *UserCreate) Validate() error { if uc.Email == "" { return errors.New("email darf nicht leer sein") } // Email-Format prüfen emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) if !emailRegex.MatchString(uc.Email) { return errors.New("ungültiges email-format") } if uc.Password == "" { return errors.New("passwort darf nicht leer sein") } // Passwort-Komplexität prüfen if len(uc.Password) < 10 { return errors.New("passwort muss mindestens 10 Zeichen lang sein") } // Komplexere Passwortvalidierung var ( hasUpper = false hasLower = false hasNumber = false hasSpecial = false ) for _, char := range uc.Password { switch { case 'A' <= char && char <= 'Z': hasUpper = true case 'a' <= char && char <= 'z': hasLower = true case '0' <= char && char <= '9': hasNumber = true case char == '!' || char == '@' || char == '#' || char == '$' || char == '%' || char == '^' || char == '&' || char == '*': hasSpecial = true } } if !hasUpper || !hasLower || !hasNumber || !hasSpecial { return errors.New("passwort muss Großbuchstaben, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten") } // Rolle prüfen if uc.Role == "" { uc.Role = RoleUser // Standardrolle setzen } else { validRoles := []string{RoleAdmin, RoleUser, RoleViewer} isValid := slices.Contains(validRoles, uc.Role) if !isValid { return fmt.Errorf("ungültige rolle: %s, erlaubt sind: %s", uc.Role, strings.Join(validRoles, ", ")) } } if uc.CompanyID.Compare(ulid.ULID{}) == 0 { return errors.New("companyID darf nicht leer sein") } if uc.HourlyRate < 0 { return errors.New("stundensatz darf nicht negativ sein") } return nil } // Validate prüft, ob die Update-Struktur gültige Daten enthält func (uu *UserUpdate) Validate() error { if uu.Email != nil && *uu.Email == "" { return errors.New("email darf nicht leer sein") } // Email-Format prüfen if uu.Email != nil { emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) if !emailRegex.MatchString(*uu.Email) { return errors.New("ungültiges email-format") } } if uu.Password != nil { if *uu.Password == "" { return errors.New("passwort darf nicht leer sein") } // Passwort-Komplexität prüfen if len(*uu.Password) < 10 { return errors.New("passwort muss mindestens 10 Zeichen lang sein") } // Komplexere Passwortvalidierung var ( hasUpper = false hasLower = false hasNumber = false hasSpecial = false ) for _, char := range *uu.Password { switch { case 'A' <= char && char <= 'Z': hasUpper = true case 'a' <= char && char <= 'z': hasLower = true case '0' <= char && char <= '9': hasNumber = true case char == '!' || char == '@' || char == '#' || char == '$' || char == '%' || char == '^' || char == '&' || char == '*': hasSpecial = true } } if !hasUpper || !hasLower || !hasNumber || !hasSpecial { return errors.New("passwort muss Großbuchstaben, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten") } } // Rolle prüfen if uu.Role != nil { validRoles := []string{RoleAdmin, RoleUser, RoleViewer} isValid := false for _, role := range validRoles { if *uu.Role == role { isValid = true break } } if !isValid { return fmt.Errorf("ungültige rolle: %s, erlaubt sind: %s", *uu.Role, strings.Join(validRoles, ", ")) } } if uu.HourlyRate != nil && *uu.HourlyRate < 0 { return errors.New("stundensatz darf nicht negativ sein") } return nil } // GetUserByID sucht einen Benutzer anhand seiner ID func GetUserByID(ctx context.Context, id ulid.ULID) (*User, error) { var user User result := GetEngine(ctx).Where("id = ?", id).First(&user) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, nil } return nil, result.Error } return &user, nil } // GetUserByEmail sucht einen Benutzer anhand seiner Email func GetUserByEmail(ctx context.Context, email string) (*User, error) { var user User result := GetEngine(ctx).Where("email = ?", email).First(&user) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, nil } return nil, result.Error } return &user, nil } // GetUserWithCompany lädt einen Benutzer mit seiner Firma func GetUserWithCompany(ctx context.Context, id ulid.ULID) (*User, error) { var user User result := GetEngine(ctx).Preload("Company").Where("id = ?", id).First(&user) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, nil } return nil, result.Error } return &user, nil } // GetAllUsers gibt alle Benutzer zurück func GetAllUsers(ctx context.Context) ([]User, error) { var users []User result := GetEngine(ctx).Find(&users) if result.Error != nil { return nil, result.Error } return users, nil } // GetUsersByCompanyID gibt alle Benutzer einer Firma zurück func GetUsersByCompanyID(ctx context.Context, companyID ulid.ULID) ([]User, error) { var users []User result := GetEngine(ctx).Where("company_id = ?", companyID).Find(&users) if result.Error != nil { return nil, result.Error } return users, nil } // CreateUser erstellt einen neuen Benutzer mit Validierung und sicherem Passwort-Hashing func CreateUser(ctx context.Context, create UserCreate) (*User, error) { // Validierung if err := create.Validate(); err != nil { return nil, fmt.Errorf("validierungsfehler: %w", err) } // Starten einer Transaktion var user *User err := GetEngine(ctx).Transaction(func(tx *gorm.DB) error { // Prüfen, ob Email bereits existiert var count int64 if err := tx.Model(&User{}).Where("email = ?", create.Email).Count(&count).Error; err != nil { return fmt.Errorf("fehler beim Prüfen der Email: %w", err) } if count > 0 { return errors.New("email wird bereits verwendet") } // Prüfen, ob Company existiert var companyCount int64 if err := tx.Model(&Company{}).Where("id = ?", create.CompanyID).Count(&companyCount).Error; err != nil { return fmt.Errorf("fehler beim Prüfen der Firma: %w", err) } if companyCount == 0 { return errors.New("die angegebene Firma existiert nicht") } // Passwort hashen mit einzigartigem Salt pwData, err := HashPassword(create.Password) if err != nil { return fmt.Errorf("fehler beim Hashen des Passworts: %w", err) } // Benutzer erstellen mit Salt und Hash getrennt gespeichert newUser := User{ Email: create.Email, Salt: pwData.Salt, Hash: pwData.Hash, Role: create.Role, CompanyID: create.CompanyID, HourlyRate: create.HourlyRate, } if err := tx.Create(&newUser).Error; err != nil { return fmt.Errorf("fehler beim Erstellen des Benutzers: %w", err) } user = &newUser return nil }) if err != nil { return nil, err } return user, nil } // UpdateUser aktualisiert einen bestehenden Benutzer func UpdateUser(ctx context.Context, update UserUpdate) (*User, error) { // Validierung if err := update.Validate(); err != nil { return nil, fmt.Errorf("validierungsfehler: %w", err) } // Benutzer suchen user, err := GetUserByID(ctx, update.ID) if err != nil { return nil, err } if user == nil { return nil, errors.New("benutzer nicht gefunden") } // Starten einer Transaktion für das Update err = GetEngine(ctx).Transaction(func(tx *gorm.DB) error { // Wenn Email aktualisiert wird, prüfen ob sie bereits verwendet wird if update.Email != nil && *update.Email != user.Email { var count int64 if err := tx.Model(&User{}).Where("email = ? AND id != ?", *update.Email, update.ID).Count(&count).Error; err != nil { return fmt.Errorf("fehler beim Prüfen der Email: %w", err) } if count > 0 { return errors.New("email wird bereits verwendet") } } // Wenn CompanyID aktualisiert wird, prüfen ob sie existiert if update.CompanyID != nil && update.CompanyID.Compare(user.CompanyID) != 0 { var companyCount int64 if err := tx.Model(&Company{}).Where("id = ?", *update.CompanyID).Count(&companyCount).Error; err != nil { return fmt.Errorf("fehler beim Prüfen der Firma: %w", err) } if companyCount == 0 { return errors.New("die angegebene Firma existiert nicht") } } // Wenn Passwort aktualisiert wird, neu hashen mit neuem Salt if update.Password != nil { pwData, err := HashPassword(*update.Password) if err != nil { return fmt.Errorf("fehler beim Hashen des Passworts: %w", err) } // Salt und Hash direkt im Modell aktualisieren if err := tx.Model(user).Updates(map[string]interface{}{ "salt": pwData.Salt, "hash": pwData.Hash, }).Error; err != nil { return fmt.Errorf("fehler beim Aktualisieren des Passworts: %w", err) } } // Map für generisches Update erstellen updates := make(map[string]interface{}) // Nur nicht-Passwort-Felder dem Update hinzufügen if update.Email != nil { updates["email"] = *update.Email } if update.Role != nil { updates["role"] = *update.Role } if update.CompanyID != nil { updates["company_id"] = *update.CompanyID } if update.HourlyRate != nil { updates["hourly_rate"] = *update.HourlyRate } // Generisches Update nur ausführen, wenn es Änderungen gibt if len(updates) > 0 { if err := tx.Model(user).Updates(updates).Error; err != nil { return fmt.Errorf("fehler beim Aktualisieren des Benutzers: %w", err) } } return nil }) if err != nil { return nil, err } // Aktualisierte Daten aus der Datenbank laden return GetUserByID(ctx, update.ID) } // DeleteUser löscht einen Benutzer anhand seiner ID func DeleteUser(ctx context.Context, id ulid.ULID) error { // Hier könnte man prüfen, ob abhängige Entitäten existieren // z.B. nicht löschen, wenn noch Zeiteinträge vorhanden sind result := GetEngine(ctx).Delete(&User{}, id) if result.Error != nil { return fmt.Errorf("fehler beim Löschen des Benutzers: %w", result.Error) } return nil } // AuthenticateUser authentifiziert einen Benutzer mit Email und Passwort func AuthenticateUser(ctx context.Context, email, password string) (*User, error) { user, err := GetUserByEmail(ctx, email) if err != nil { return nil, err } if user == nil { // Gleiche Fehlermeldung, um keine Informationen über existierende Accounts preiszugeben return nil, errors.New("ungültige Anmeldeinformationen") } // Passwort überprüfen mit dem gespeicherten Salt isValid, err := VerifyPassword(password, user.Salt, user.Hash) if err != nil { return nil, fmt.Errorf("fehler bei der Passwortüberprüfung: %w", err) } if !isValid { return nil, errors.New("ungültige Anmeldeinformationen") } return user, nil }