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
}