using Lagerbestand.Data; using Lagerbestand.Models; using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; using System.Text; namespace Lagerbestand.Services; public interface IAuthService { Task LoginAsync(string username, string password); Task AuthenticateAsync(string username, string password); Task GetUserByIdAsync(Guid id); Task> GetAllUsersAsync(); Task CreateUserAsync(string username, string password, string? displayName, UserRole role); Task UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive); Task ChangePasswordAsync(Guid id, string newPassword); Task DeleteUserAsync(Guid id); Task EnsureAdminExistsAsync(); Task UsernameExistsAsync(string username, Guid? excludeUserId = null); Task GetActiveAdminCountAsync(); (bool IsValid, string? ErrorMessage) ValidatePassword(string password); (bool IsValid, string? ErrorMessage) ValidateUsername(string username); // 2FA methods Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId); Task Confirm2FaAsync(Guid userId, string code); Task Disable2FaAsync(Guid userId); Task Reset2FaAsync(Guid userId); Task SetEnforce2FaAsync(Guid userId, bool enforce); } public class AuthService : IAuthService { private const string LegacyPasswordSalt = "ReifenSpaethSalt2025"; private const int Pbkdf2Iterations = 100_000; private const int SaltSize = 16; private const int HashSize = 32; private const int MinPasswordLength = 4; private const int MaxPasswordLength = 128; private const int MinUsernameLength = 3; private const int MaxUsernameLength = 50; private const int MaxFailedLoginAttempts = 5; private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(5); private static readonly System.Collections.Concurrent.ConcurrentDictionary FailedLoginAttempts = new(); private readonly AppDbContext _context; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly ITwoFactorService _twoFactorService; private readonly ISiteSettingsService _siteSettingsService; public AuthService(AppDbContext context, IConfiguration configuration, ILogger logger, ITwoFactorService twoFactorService, ISiteSettingsService siteSettingsService) { _context = context; _configuration = configuration; _logger = logger; _twoFactorService = twoFactorService; _siteSettingsService = siteSettingsService; } public async Task LoginAsync(string username, string password) { var lockoutKey = username.Trim().ToLowerInvariant(); if (IsLockedOut(lockoutKey)) { _logger.LogWarning("Login blocked due to too many failed attempts for username: {Username}", username); return null; } var user = await GetActiveUserByUsernameAsync(username); if (user == null || !VerifyPassword(password, user.PasswordHash)) { RegisterFailedAttempt(lockoutKey); _logger.LogWarning("Failed login attempt for username: {Username}", username); return null; } FailedLoginAttempts.TryRemove(lockoutKey, out _); // Re-hash legacy SHA256 passwords to PBKDF2 await ReHashIfLegacyAsync(user, password); user.LastLoginAt = DateTime.UtcNow; await _context.SaveChangesAsync(); _logger.LogInformation("User {Username} logged in successfully", username); return user; } private static bool IsLockedOut(string key) { if (FailedLoginAttempts.TryGetValue(key, out var entry)) { if (entry.Count >= MaxFailedLoginAttempts && DateTime.UtcNow - entry.LastAttempt < LockoutDuration) return true; if (DateTime.UtcNow - entry.LastAttempt >= LockoutDuration) FailedLoginAttempts.TryRemove(key, out _); } return false; } private static void RegisterFailedAttempt(string key) { FailedLoginAttempts.AddOrUpdate(key, _ => (1, DateTime.UtcNow), (_, existing) => (existing.Count + 1, DateTime.UtcNow)); } public async Task AuthenticateAsync(string username, string password) { var user = await GetActiveUserByUsernameAsync(username); if (user == null || !VerifyPassword(password, user.PasswordHash)) return null; return user; } public async Task GetUserByIdAsync(Guid id) { return await _context.Users.FindAsync(id); } public async Task> GetAllUsersAsync() { return await _context.Users .OrderBy(u => u.Username) .ToListAsync(); } public async Task UsernameExistsAsync(string username, Guid? excludeUserId = null) { var normalizedUsername = username.Trim().ToLowerInvariant(); var query = _context.Users.Where(u => u.Username.ToLower() == normalizedUsername); if (excludeUserId.HasValue) query = query.Where(u => u.Id != excludeUserId.Value); return await query.AnyAsync(); } public async Task GetActiveAdminCountAsync() { return await _context.Users .CountAsync(u => u.Role == UserRole.Admin && u.IsActive); } public (bool IsValid, string? ErrorMessage) ValidatePassword(string password) { if (string.IsNullOrWhiteSpace(password)) return (false, "Passwort ist erforderlich."); if (password.Length < MinPasswordLength) return (false, $"Passwort muss mindestens {MinPasswordLength} Zeichen lang sein."); if (password.Length > MaxPasswordLength) return (false, $"Passwort darf maximal {MaxPasswordLength} Zeichen lang sein."); return (true, null); } public (bool IsValid, string? ErrorMessage) ValidateUsername(string username) { if (string.IsNullOrWhiteSpace(username)) return (false, "Benutzername ist erforderlich."); var trimmed = username.Trim(); if (trimmed.Length < MinUsernameLength) return (false, $"Benutzername muss mindestens {MinUsernameLength} Zeichen lang sein."); if (trimmed.Length > MaxUsernameLength) return (false, $"Benutzername darf maximal {MaxUsernameLength} Zeichen lang sein."); // Allow only alphanumeric, dots, underscores, hyphens if (!trimmed.All(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-')) return (false, "Benutzername darf nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche enthalten."); return (true, null); } public async Task CreateUserAsync(string username, string password, string? displayName, UserRole role) { var usernameValidation = ValidateUsername(username); if (!usernameValidation.IsValid) throw new InvalidOperationException(usernameValidation.ErrorMessage); var passwordValidation = ValidatePassword(password); if (!passwordValidation.IsValid) throw new InvalidOperationException(passwordValidation.ErrorMessage); var normalizedUsername = username.Trim(); if (await UsernameExistsAsync(normalizedUsername)) throw new InvalidOperationException($"Benutzer '{normalizedUsername}' existiert bereits."); var user = new AppUser { Username = normalizedUsername, PasswordHash = HashPassword(password), DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), Role = role, IsActive = true, CreatedAt = DateTime.UtcNow }; _context.Users.Add(user); await _context.SaveChangesAsync(); _logger.LogInformation("User {Username} created with role {Role}", normalizedUsername, role); return user; } public async Task UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive) { var user = await _context.Users.FindAsync(id) ?? throw new InvalidOperationException("Benutzer nicht gefunden."); // Prevent deactivating or demoting the last admin if (user.Role == UserRole.Admin && (role != UserRole.Admin || !isActive)) { var adminCount = await GetActiveAdminCountAsync(); if (adminCount <= 1) throw new InvalidOperationException("Der letzte aktive Administrator kann nicht deaktiviert oder zu einer anderen Rolle geändert werden."); } // Update username if provided and different if (!string.IsNullOrWhiteSpace(username)) { var newUsername = username.Trim(); if (!newUsername.Equals(user.Username, StringComparison.OrdinalIgnoreCase)) { var usernameValidation = ValidateUsername(newUsername); if (!usernameValidation.IsValid) throw new InvalidOperationException(usernameValidation.ErrorMessage); if (await UsernameExistsAsync(newUsername, id)) throw new InvalidOperationException($"Benutzer '{newUsername}' existiert bereits."); user.Username = newUsername; } } user.DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(); user.Role = role; user.IsActive = isActive; await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} updated", id); return user; } public async Task ChangePasswordAsync(Guid id, string newPassword) { var passwordValidation = ValidatePassword(newPassword); if (!passwordValidation.IsValid) throw new InvalidOperationException(passwordValidation.ErrorMessage); var user = await _context.Users.FindAsync(id); if (user == null) return false; user.PasswordHash = HashPassword(newPassword); await _context.SaveChangesAsync(); _logger.LogInformation("Password changed for user {UserId}", id); return true; } public async Task DeleteUserAsync(Guid id) { var user = await _context.Users.FindAsync(id); if (user == null) return; // Prevent deleting the last admin if (user.Role == UserRole.Admin && user.IsActive) { var adminCount = await GetActiveAdminCountAsync(); if (adminCount <= 1) throw new InvalidOperationException("Der letzte aktive Administrator kann nicht gelöscht werden."); } // Soft delete user.IsActive = false; await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} ({Username}) deactivated", id, user.Username); } public async Task EnsureAdminExistsAsync() { try { var adminUsername = _configuration["AdminUser:Username"] ?? "admin"; var adminPassword = _configuration["AdminUser:Password"] ?? "admin123"; var adminDisplayName = _configuration["AdminUser:DisplayName"] ?? "Administrator"; var existingAdmin = await _context.Users .FirstOrDefaultAsync(u => u.Username.ToLower() == adminUsername.ToLower()); if (existingAdmin == null) { var admin = new AppUser { Username = adminUsername, PasswordHash = HashPassword(adminPassword), DisplayName = adminDisplayName, Role = UserRole.Admin, IsActive = true, CreatedAt = DateTime.UtcNow }; _context.Users.Add(admin); await _context.SaveChangesAsync(); _logger.LogInformation("Admin user '{Username}' created.", adminUsername); } else { existingAdmin.PasswordHash = HashPassword(adminPassword); existingAdmin.DisplayName = adminDisplayName; existingAdmin.Role = UserRole.Admin; existingAdmin.IsActive = true; await _context.SaveChangesAsync(); _logger.LogInformation("Admin user '{Username}' updated.", adminUsername); } } catch (Exception ex) { _logger.LogError(ex, "Error creating/updating admin user."); } } private async Task GetActiveUserByUsernameAsync(string username) { return await _context.Users .FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower() && u.IsActive); } private static string HashPassword(string password) { var salt = RandomNumberGenerator.GetBytes(SaltSize); var hash = Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(password), salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashSize); return $"{Pbkdf2Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; } private static bool VerifyPassword(string password, string storedHash) { // New PBKDF2 format: "iterations.salt.hash" if (storedHash.Contains('.')) { var parts = storedHash.Split('.', 3); if (parts.Length == 3 && int.TryParse(parts[0], out var iterations)) { var salt = Convert.FromBase64String(parts[1]); var expectedHash = Convert.FromBase64String(parts[2]); var computedHash = Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, expectedHash.Length); return CryptographicOperations.FixedTimeEquals(computedHash, expectedHash); } } // Legacy SHA256 + hardcoded salt fallback var legacyBytes = Encoding.UTF8.GetBytes(password + LegacyPasswordSalt); var legacyHash = SHA256.HashData(legacyBytes); return Convert.ToBase64String(legacyHash) == storedHash; } /// /// Re-hashes a legacy password to the new PBKDF2 format on successful login. /// private async Task ReHashIfLegacyAsync(AppUser user, string password) { // If the stored hash is in legacy format (no dots), upgrade it if (!user.PasswordHash.Contains('.')) { user.PasswordHash = HashPassword(password); await _context.SaveChangesAsync(); _logger.LogInformation("Re-hashed legacy password for user {Username}", user.Username); } } #region Two-Factor Authentication public async Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId) { var user = await _context.Users.FindAsync(userId) ?? throw new InvalidOperationException("Benutzer nicht gefunden."); var tfSettings = await _siteSettingsService.GetTwoFactorSettingsAsync(); if (!tfSettings.IsEnabled) throw new InvalidOperationException("Zwei-Faktor-Authentifizierung ist global deaktiviert."); var secret = _twoFactorService.GenerateSecretKey(); var otpAuthUri = _twoFactorService.GenerateOtpAuthUri(secret, user.Username, tfSettings.Issuer); // Store the secret but don't enable 2FA yet – user must confirm with a code first user.TwoFactorSecret = secret; await _context.SaveChangesAsync(); _logger.LogInformation("2FA setup initiated for user {Username}", user.Username); return (secret, otpAuthUri); } public async Task Confirm2FaAsync(Guid userId, string code) { var user = await _context.Users.FindAsync(userId) ?? throw new InvalidOperationException("Benutzer nicht gefunden."); if (string.IsNullOrEmpty(user.TwoFactorSecret)) throw new InvalidOperationException("Kein 2FA-Secret vorhanden. Bitte 2FA zuerst einrichten."); if (!_twoFactorService.ValidateTotpCode(user.TwoFactorSecret, code)) return false; user.TwoFactorEnabled = true; await _context.SaveChangesAsync(); _logger.LogInformation("2FA confirmed and enabled for user {Username}", user.Username); return true; } public async Task Disable2FaAsync(Guid userId) { var user = await _context.Users.FindAsync(userId) ?? throw new InvalidOperationException("Benutzer nicht gefunden."); if (user.TwoFactorEnforced) throw new InvalidOperationException("2FA wurde vom Administrator erzwungen und kann nicht deaktiviert werden."); user.TwoFactorEnabled = false; user.TwoFactorSecret = null; await _context.SaveChangesAsync(); _logger.LogInformation("2FA disabled for user {Username}", user.Username); } public async Task Reset2FaAsync(Guid userId) { var user = await _context.Users.FindAsync(userId) ?? throw new InvalidOperationException("Benutzer nicht gefunden."); user.TwoFactorEnabled = false; user.TwoFactorSecret = null; user.TwoFactorEnforced = false; await _context.SaveChangesAsync(); _logger.LogInformation("2FA reset by admin for user {Username}", user.Username); } public async Task SetEnforce2FaAsync(Guid userId, bool enforce) { var user = await _context.Users.FindAsync(userId) ?? throw new InvalidOperationException("Benutzer nicht gefunden."); user.TwoFactorEnforced = enforce; await _context.SaveChangesAsync(); _logger.LogInformation("2FA enforce set to {Enforce} for user {Username}", enforce, user.Username); } #endregion }