Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

+477
View File
@@ -0,0 +1,477 @@
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
}