multi-tenant implementierung

This commit is contained in:
MarcWieland
2026-06-24 23:48:04 +02:00
parent d646cfd28d
commit ac06059e2a
41 changed files with 2530 additions and 238 deletions
+164 -10
View File
@@ -1,15 +1,23 @@
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) : IAuthService
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;
@@ -19,7 +27,9 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
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();
}
@@ -27,7 +37,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
public async Task DeleteUserAsync(int userId)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.FindAsync(userId);
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user != null)
{
db.Users.Remove(user);
@@ -43,12 +53,16 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
return "Benutzername muss mindestens 3 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
if (await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId))
return "Benutzername bereits vergeben.";
var user = await db.Users.FindAsync(userId);
// 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();
@@ -61,7 +75,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
return "Passwort muss mindestens 6 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.FindAsync(userId);
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))
@@ -75,24 +89,164 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
return null;
}
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = 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 bereits vergeben.");
return (null, "Benutzername in dieser Firma bereits vergeben.");
var (hash, salt) = HashPassword(password);
var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt };
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);