Files

478 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}