using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using timetracker.Shared; namespace timetracker.Data; public class AuthService( IDbContextFactory factory, UserNotificationService notifier, ITenantProvider tenantProvider) : IAuthService { private const string ReservedSuperAdminUsername = "marc"; public async Task LoginAsync(string username, string password) { await using var db = await factory.CreateDbContextAsync(); // Global query filter automatically restricts lookup to current tenant context var user = await db.Users .FirstOrDefaultAsync(u => u.Username == username); if (user == null) return null; return VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null; } public async Task> GetAllUsersAsync() { await using var db = await factory.CreateDbContextAsync(); // Superadmin bypasses query filters to manage all users globally return await db.Users .IgnoreQueryFilters() .OrderBy(u => u.Username) .ToListAsync(); } public async Task DeleteUserAsync(int userId) { await using var db = await factory.CreateDbContextAsync(); var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId); if (user != null) { db.Users.Remove(user); await db.SaveChangesAsync(); await notifier.NotifyUserDeletedAsync(userId); await notifier.NotifyUsersChangedAsync(); } } public async Task RenameUserAsync(int userId, string newUsername) { if (string.IsNullOrWhiteSpace(newUsername) || newUsername.Length < 3) return "Benutzername muss mindestens 3 Zeichen lang sein."; await using var db = await factory.CreateDbContextAsync(); // Find user ignoring filters so we can rename global admin too if needed var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId); if (user == null) return "Benutzer nicht gefunden."; if (!CanUseReservedUsername(user.TenantId, newUsername, allowExistingReservedUser: user.TenantId == null && user.Username.Equals(ReservedSuperAdminUsername, StringComparison.OrdinalIgnoreCase))) return "Dieser Benutzername ist reserviert."; // Check if name is taken within the same tenant context var isTaken = await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId && u.TenantId == user.TenantId); if (isTaken) return "Benutzername in dieser Firma bereits vergeben."; user.Username = newUsername; await db.SaveChangesAsync(); await notifier.NotifyUsersChangedAsync(); return null; } public async Task ChangePasswordAsync(int userId, string currentPassword, string newPassword) { if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6) return "Passwort muss mindestens 6 Zeichen lang sein."; await using var db = await factory.CreateDbContextAsync(); var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId); if (user == null) return "Benutzer nicht gefunden."; if (!VerifyPassword(currentPassword, user.PasswordHash, user.PasswordSalt)) return "Das aktuelle Passwort ist nicht korrekt."; var (hash, salt) = HashPassword(newPassword); user.PasswordHash = hash; user.PasswordSalt = salt; await db.SaveChangesAsync(); return null; } public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null) { if (string.IsNullOrWhiteSpace(username) || username.Length < 3) return (null, "Benutzername muss mindestens 3 Zeichen lang sein."); if (string.IsNullOrWhiteSpace(password) || password.Length < 6) return (null, "Passwort muss mindestens 6 Zeichen lang sein."); var tenantId = tenantProvider.TenantId; if (!CanUseReservedUsername(tenantId, username)) { return (null, "Dieser Benutzername ist reserviert."); } await using var db = await factory.CreateDbContextAsync(); // Filter handles checking uniqueness within the same tenant context if (await db.Users.AnyAsync(u => u.Username == username)) return (null, tenantId.HasValue ? "Benutzername in dieser Firma bereits vergeben." : "Benutzername bereits vergeben."); var (hash, salt) = HashPassword(password); var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt, TenantId = tenantId }; db.Users.Add(user); await db.SaveChangesAsync(); await notifier.NotifyUsersChangedAsync(); return (user, null); } public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req) { if (string.IsNullOrWhiteSpace(req.CompanyName) || req.CompanyName.Length < 3) return (null, "Firmenname muss mindestens 3 Zeichen lang sein."); if (string.IsNullOrWhiteSpace(req.Subdomain) || req.Subdomain.Length < 3) return (null, "Subdomain muss mindestens 3 Zeichen lang sein."); if (string.IsNullOrWhiteSpace(req.AdminUsername) || req.AdminUsername.Length < 3) return (null, "Administrator-Benutzername muss mindestens 3 Zeichen lang sein."); if (string.IsNullOrWhiteSpace(req.AdminPassword) || req.AdminPassword.Length < 6) return (null, "Passwort muss mindestens 6 Zeichen lang sein."); if (!CanUseReservedUsername(tenantId: 0, req.AdminUsername)) return (null, "Dieser Benutzername ist reserviert."); var subdomain = req.Subdomain.ToLower().Trim(); if (!subdomain.All(c => char.IsLetterOrDigit(c) || c == '-')) { return (null, "Subdomain darf nur Buchstaben, Zahlen und Bindestriche enthalten."); } var reservedSubdomains = new[] { "www", "api", "admin", "superadmin", "localhost", "mail", "portal", "app", "dev", "test" }; if (reservedSubdomains.Contains(subdomain)) { return (null, "Diese Subdomain ist reserviert und kann nicht verwendet werden."); } await using var db = await factory.CreateDbContextAsync(); // Tenant checks are global, no filters applied to Tenants table var exists = await db.Tenants.AnyAsync(t => t.Subdomain == subdomain); if (exists) { return (null, "Diese Subdomain ist bereits vergeben."); } await using var transaction = await db.Database.BeginTransactionAsync(); try { var tenant = new Tenant { Name = req.CompanyName, Subdomain = subdomain, IsApproved = false, CreatedAt = DateTime.UtcNow }; db.Tenants.Add(tenant); await db.SaveChangesAsync(); var (hash, salt) = HashPassword(req.AdminPassword); var user = new User { Username = req.AdminUsername, PasswordHash = hash, PasswordSalt = salt, TenantId = tenant.Id, IsTenantAdmin = true }; db.Users.Add(user); await db.SaveChangesAsync(); await transaction.CommitAsync(); await notifier.NotifyUsersChangedAsync(); return (tenant, null); } catch (Exception ex) { await transaction.RollbackAsync(); return (null, $"Registrierungsfehler: {ex.Message}"); } } public async Task AssignTenantAsync(int userId, int? tenantId) { await using var db = await factory.CreateDbContextAsync(); var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId); if (user == null) return "Benutzer nicht gefunden."; if (tenantId.HasValue) { var tenantExists = await db.Tenants.AnyAsync(t => t.Id == tenantId.Value); if (!tenantExists) return "Firma existiert nicht."; } await using var transaction = await db.Database.BeginTransactionAsync(); try { user.TenantId = tenantId; var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.UserId == userId).ToListAsync(); foreach (var s in settings) s.TenantId = tenantId; var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.UserId == userId).ToListAsync(); foreach (var w in workDays) w.TenantId = tenantId; var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.UserId == userId).ToListAsync(); foreach (var v in vacationDays) v.TenantId = tenantId; await db.SaveChangesAsync(); await transaction.CommitAsync(); await notifier.NotifyUsersChangedAsync(); return null; } catch (Exception ex) { await transaction.RollbackAsync(); return $"Fehler beim Verschieben des Benutzers: {ex.Message}"; } } public async Task ResetPasswordAsync(int userId, string newPassword) { if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6) return "Passwort muss mindestens 6 Zeichen lang sein."; await using var db = await factory.CreateDbContextAsync(); var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId); if (user == null) return "Benutzer nicht gefunden."; var (hash, salt) = HashPassword(newPassword); user.PasswordHash = hash; user.PasswordSalt = salt; await db.SaveChangesAsync(); return null; } private static (string hash, string salt) HashPassword(string password) { var saltBytes = RandomNumberGenerator.GetBytes(32); var salt = Convert.ToBase64String(saltBytes); var hash = ComputeHash(password, salt); return (hash, salt); } private static bool VerifyPassword(string password, string hash, string salt) => ComputeHash(password, salt) == hash; private static bool CanUseReservedUsername(int? tenantId, string username, bool allowExistingReservedUser = false) { if (!username.Equals(ReservedSuperAdminUsername, StringComparison.OrdinalIgnoreCase)) return true; return allowExistingReservedUser || tenantId == null; } private static string ComputeHash(string password, string salt) { var hash = Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(password), Convert.FromBase64String(salt), iterations: 200_000, HashAlgorithmName.SHA256, outputLength: 32); return Convert.ToBase64String(hash); } }