478 lines
18 KiB
C#
478 lines
18 KiB
C#
using Lagerbestand.Data;
|
||
using Lagerbestand.Models;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
|
||
namespace Lagerbestand.Services;
|
||
|
||
public interface IAuthService
|
||
{
|
||
Task<AppUser?> LoginAsync(string username, string password);
|
||
Task<AppUser?> AuthenticateAsync(string username, string password);
|
||
Task<AppUser?> GetUserByIdAsync(Guid id);
|
||
Task<List<AppUser>> GetAllUsersAsync();
|
||
Task<AppUser> CreateUserAsync(string username, string password, string? displayName, UserRole role);
|
||
Task<AppUser> UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive);
|
||
Task<bool> ChangePasswordAsync(Guid id, string newPassword);
|
||
Task DeleteUserAsync(Guid id);
|
||
Task EnsureAdminExistsAsync();
|
||
Task<bool> UsernameExistsAsync(string username, Guid? excludeUserId = null);
|
||
Task<int> 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<bool> 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<string, (int Count, DateTime LastAttempt)> FailedLoginAttempts = new();
|
||
|
||
private readonly AppDbContext _context;
|
||
private readonly IConfiguration _configuration;
|
||
private readonly ILogger<AuthService> _logger;
|
||
private readonly ITwoFactorService _twoFactorService;
|
||
private readonly ISiteSettingsService _siteSettingsService;
|
||
|
||
public AuthService(AppDbContext context, IConfiguration configuration, ILogger<AuthService> logger,
|
||
ITwoFactorService twoFactorService, ISiteSettingsService siteSettingsService)
|
||
{
|
||
_context = context;
|
||
_configuration = configuration;
|
||
_logger = logger;
|
||
_twoFactorService = twoFactorService;
|
||
_siteSettingsService = siteSettingsService;
|
||
}
|
||
|
||
public async Task<AppUser?> 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<AppUser?> AuthenticateAsync(string username, string password)
|
||
{
|
||
var user = await GetActiveUserByUsernameAsync(username);
|
||
if (user == null || !VerifyPassword(password, user.PasswordHash))
|
||
return null;
|
||
|
||
return user;
|
||
}
|
||
|
||
public async Task<AppUser?> GetUserByIdAsync(Guid id)
|
||
{
|
||
return await _context.Users.FindAsync(id);
|
||
}
|
||
|
||
public async Task<List<AppUser>> GetAllUsersAsync()
|
||
{
|
||
return await _context.Users
|
||
.OrderBy(u => u.Username)
|
||
.ToListAsync();
|
||
}
|
||
|
||
public async Task<bool> 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<int> 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<AppUser> 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<AppUser> 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<bool> 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<AppUser?> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Re-hashes a legacy password to the new PBKDF2 format on successful login.
|
||
/// </summary>
|
||
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<bool> 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
|
||
}
|