From 7d9eac498aeff88e9d0fb5496bd4454d3dfd5405 Mon Sep 17 00:00:00 2001 From: "Wieland, Marc" Date: Fri, 26 Jun 2026 14:49:30 +0200 Subject: [PATCH] Updates --- run-local.cmd | 16 ++++++++ timetracker.Server/Data/AuthService.cs | 18 ++++++++- timetracker.Server/Program.cs | 56 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 run-local.cmd diff --git a/run-local.cmd b/run-local.cmd new file mode 100644 index 0000000..dacc5f2 --- /dev/null +++ b/run-local.cmd @@ -0,0 +1,16 @@ +@echo off +setlocal + +echo === 1. Bereinige alte Build-Dateien === +dotnet clean +if errorlevel 1 exit /b %errorlevel% + +echo === 2. Baue die gesamte Solution === +dotnet build +if errorlevel 1 exit /b %errorlevel% + +echo === 3. Starte den Server ^& oeffne Browser === +start "timetracker-browser" powershell -NoProfile -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:5065'" + +dotnet run --project timetracker.Server\timetracker.Server.csproj +exit /b %errorlevel% \ No newline at end of file diff --git a/timetracker.Server/Data/AuthService.cs b/timetracker.Server/Data/AuthService.cs index c7b6574..d93c4aa 100644 --- a/timetracker.Server/Data/AuthService.cs +++ b/timetracker.Server/Data/AuthService.cs @@ -14,6 +14,8 @@ public class AuthService( 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(); @@ -58,6 +60,9 @@ public class AuthService( 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) @@ -97,7 +102,7 @@ public class AuthService( return (null, "Passwort muss mindestens 6 Zeichen lang sein."); var tenantId = tenantProvider.TenantId; - if (tenantId == null && username.Equals("marc", StringComparison.OrdinalIgnoreCase)) + if (!CanUseReservedUsername(tenantId, username)) { return (null, "Dieser Benutzername ist reserviert."); } @@ -132,6 +137,9 @@ public class AuthService( 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 == '-')) { @@ -258,6 +266,14 @@ public class AuthService( 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( diff --git a/timetracker.Server/Program.cs b/timetracker.Server/Program.cs index 5ad3903..31e795b 100644 --- a/timetracker.Server/Program.cs +++ b/timetracker.Server/Program.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -11,6 +13,8 @@ using timetracker.Data; using timetracker.Shared; var builder = WebApplication.CreateBuilder(args); +const string SuperAdminUsername = "marc"; +const string SuperAdminPassword = "123456"; // Add Authentication builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) @@ -277,6 +281,32 @@ using (var scope = app.Services.CreateScope()) Console.WriteLine($"Fehler beim Konvertieren der PostgreSQL-Spaltentypen (IsTenantAdmin/IsApproved/CreatedAt): {ex.Message}"); } } + + var superAdmin = await db.Users + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.TenantId == null && u.Username == SuperAdminUsername); + + if (superAdmin == null) + { + var (passwordHash, passwordSalt) = HashPassword(SuperAdminPassword); + db.Users.Add(new User + { + Username = SuperAdminUsername, + PasswordHash = passwordHash, + PasswordSalt = passwordSalt, + TenantId = null, + IsTenantAdmin = true + }); + await db.SaveChangesAsync(); + } + else if (IsLegacySeedPassword(superAdmin.PasswordHash, superAdmin.PasswordSalt, SuperAdminPassword)) + { + var (passwordHash, passwordSalt) = HashPassword(SuperAdminPassword); + superAdmin.PasswordHash = passwordHash; + superAdmin.PasswordSalt = passwordSalt; + superAdmin.IsTenantAdmin = true; + await db.SaveChangesAsync(); + } } var forwardedHeadersOptions = new ForwardedHeadersOptions @@ -821,6 +851,32 @@ superAdminApi.MapDelete("/tenants/{id:int}", async (int id, TimetrackerDbContext app.Run(); +static (string Hash, string Salt) HashPassword(string password) +{ + var saltBytes = RandomNumberGenerator.GetBytes(32); + var salt = Convert.ToBase64String(saltBytes); + var hash = ComputePasswordHash(password, salt); + return (hash, salt); +} + +static bool IsLegacySeedPassword(string hash, string salt, string password) +{ + var passwordBytes = Encoding.UTF8.GetBytes(password + salt); + var legacyHash = Convert.ToBase64String(SHA256.HashData(passwordBytes)); + return hash == legacyHash; +} + +static string ComputePasswordHash(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); +} + // ── Models for Request Bodies ────────────────────────────────────────────────── public record LoginRequest(string Username, string Password); public record RegisterRequest(string Username, string Password, string? Honeypot = null);