535 lines
14 KiB
Go
535 lines
14 KiB
Go
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
|
|
}
|