272 lines
10 KiB
C#
272 lines
10 KiB
C#
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<TimetrackerDbContext> factory,
|
|
UserNotificationService notifier,
|
|
ITenantProvider tenantProvider) : IAuthService
|
|
{
|
|
public async Task<User?> 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<List<User>> 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<string?> 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.";
|
|
|
|
// 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<string?> 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 (tenantId == null)
|
|
{
|
|
return (null, "Mitarbeiter-Registrierung ist nur über die Firmen-Subdomain möglich.");
|
|
}
|
|
|
|
await using var db = await factory.CreateDbContextAsync();
|
|
// Filter handles checking uniqueness within the same tenant
|
|
if (await db.Users.AnyAsync(u => u.Username == username))
|
|
return (null, "Benutzername in dieser Firma bereits vergeben.");
|
|
|
|
var (hash, salt) = HashPassword(password);
|
|
var user = new User
|
|
{
|
|
Username = username,
|
|
PasswordHash = hash,
|
|
PasswordSalt = salt,
|
|
TenantId = tenantId.Value
|
|
};
|
|
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.");
|
|
|
|
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<string?> 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<string?> 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 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);
|
|
}
|
|
}
|