using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BCrypt.Net;
using FinlyticBackend.Database;
using FinlyticBackend.Entities;
using FinlyticCore.Models.Auth;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
///
/// Interface for user management and authentication operations.
///
public interface IUserService
{
/// Authenticates user credentials and returns JWT response.
Task AuthenticateAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
/// Changes the initial password for a user.
Task ChangeInitialPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default);
/// Creates a new user by an admin.
Task CreateUserByAdminAsync(CreateUserRequestDto request, CancellationToken cancellationToken = default);
/// Updates an existing user account.
Task UpdateUserAsync(Guid userId, UpdateUserRequestDto request,
CancellationToken cancellationToken = default);
/// Resets a user's password by admin.
Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken = default);
/// Registers or updates FCM device token.
Task RegisterOrUpdateFcmTokenAsync(Guid userId, string fcmToken, string deviceName,
CancellationToken cancellationToken = default);
/// Gets all registered users.
Task> GetAllUsersAsync(CancellationToken cancellationToken = default);
/// Deactivates a user account.
Task DeactivateUserAsync(Guid userId, CancellationToken cancellationToken = default);
/// Seeds default admin account if not present.
Task SeedDefaultAdminAsync(string defaultAdminPassword, CancellationToken cancellationToken = default);
}
public class UserService : IUserService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IJwtTokenService _jwtTokenService;
private readonly ILogger _logger;
public UserService(
IServiceScopeFactory scopeFactory,
IJwtTokenService jwtTokenService,
ILogger logger)
{
_scopeFactory = scopeFactory;
_jwtTokenService = jwtTokenService;
_logger = logger;
}
///
public async Task AuthenticateAsync(LoginRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
var user = await dbContext.Users
.Include(u => u.DeviceTokens)
.FirstOrDefaultAsync(u => u.Email.ToLower() == request.Email.ToLower(), cancellationToken);
if (user == null || !user.IsActive)
{
_logger.LogWarning("[{Channel}] Authentication failed: User '{Email}' not found or inactive.",
"AuthChannel", request.Email);
return null;
}
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
{
_logger.LogWarning("[{Channel}] Authentication failed: Invalid password for '{Email}'.", "AuthChannel",
request.Email);
return null;
}
user.LastLoginAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
var (token, expiresAt) = _jwtTokenService.GenerateToken(user);
return new AuthResponseDto
{
Token = token,
UserId = user.Id,
Email = user.Email,
FullName = user.FullName,
Role = user.Role,
ThemePreference =
string.IsNullOrWhiteSpace(user.ThemePreference)
? "fluent_dark"
: user.ThemePreference,
FcmTokens = user.DeviceTokens.Select(t => t.FcmToken).ToList(),
ExpiresAt = expiresAt,
RequiresPasswordChange = user.RequiresPasswordChange
};
}
///
public async Task CreateUserByAdminAsync(CreateUserRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
string normalizedEmail = request.Email.Trim().ToLowerInvariant();
bool exists = await dbContext.Users.AnyAsync(u => u.Email.ToLower() == normalizedEmail, cancellationToken);
if (exists)
{
_logger.LogWarning("[{Channel}] Cannot create user: Email '{Email}' already exists.", "AuthChannel",
request.Email);
return null;
}
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
var newUser = new UserEntity
{
Email = normalizedEmail,
PasswordHash = passwordHash,
FullName = request.FullName,
Role = string.Equals(request.Role, "Admin", StringComparison.OrdinalIgnoreCase) ? "Admin" : "User",
ThemePreference = "fluent_dark",
IsActive = true,
RequiresPasswordChange = true,
CreatedAt = DateTime.UtcNow
};
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Admin created user '{Email}' with Role '{Role}'.", "AuthChannel",
newUser.Email, newUser.Role);
return MapToUserDto(newUser);
}
///
public async Task UpdateUserAsync(Guid userId, UpdateUserRequestDto request,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
var user = await dbContext.Users
.Include(u => u.DeviceTokens)
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) return null;
if (!string.IsNullOrWhiteSpace(request.Role))
{
user.Role = request.Role;
}
if (request.IsActive.HasValue)
{
user.IsActive = request.IsActive.Value;
}
if (!string.IsNullOrWhiteSpace(request.FullName))
{
user.FullName = request.FullName;
}
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Updated user '{UserId}' details.", "AuthChannel", userId);
return MapToUserDto(user);
}
///
public async Task ChangeInitialPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null || !user.RequiresPasswordChange) return false;
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
user.RequiresPasswordChange = false;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] User '{UserId}' changed their initial password.", "AuthChannel", userId);
return true;
}
///
public async Task ResetPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) return false;
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
user.RequiresPasswordChange = true;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Admin reset password for user '{UserId}'.", "AuthChannel", userId);
return true;
}
///
public async Task RegisterOrUpdateFcmTokenAsync(Guid userId, string fcmToken, string deviceName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(fcmToken)) return false;
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
var user = await dbContext.Users
.Include(u => u.DeviceTokens)
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null || !user.IsActive) return false;
var existingToken = user.DeviceTokens.FirstOrDefault(t => t.FcmToken == fcmToken);
if (existingToken != null)
{
existingToken.DeviceName = deviceName;
existingToken.LastUsedAt = DateTime.UtcNow;
}
else
{
user.DeviceTokens.Add(new UserDeviceTokenEntity
{
UserId = user.Id,
FcmToken = fcmToken,
DeviceName = deviceName,
RegisteredAt = DateTime.UtcNow,
LastUsedAt = DateTime.UtcNow
});
}
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
///
public async Task> GetAllUsersAsync(CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
var users = await dbContext.Users
.AsNoTracking()
.Include(u => u.DeviceTokens)
.OrderByDescending(u => u.CreatedAt)
.ToListAsync(cancellationToken);
return users.Select(MapToUserDto).ToList();
}
///
public async Task DeactivateUserAsync(Guid userId, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null) return false;
user.IsActive = false;
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
///
public async Task SeedDefaultAdminAsync(string defaultAdminPassword, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
string adminEmail = "admin@finlytic.com";
bool exists = await dbContext.Users.AnyAsync(u => u.Email == adminEmail, cancellationToken);
if (!exists)
{
// Fail-fast statt Fallback auf einen im Repo öffentlichen Literal (Rules.md §12): ein Gateway,
// das den Default-Admin mit einem repo-öffentlichen Passwort anlegt, ist schlimmer als eines,
// das nicht startet. Program.cs validiert dies bereits vor dem Aufruf; dieser Guard dient als
// Verteidigung in der Tiefe, falls die Methode von anderer Stelle aufgerufen wird.
if (string.IsNullOrWhiteSpace(defaultAdminPassword))
{
throw new InvalidOperationException(
"ADMIN:DefaultPassword (bzw. ADMIN__DefaultPassword) ist nicht konfiguriert. " +
"Das Seeding des Default-Admin-Accounts kann ohne explizit konfiguriertes Passwort nicht durchgeführt werden.");
}
var adminUser = new UserEntity
{
Email = adminEmail,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(defaultAdminPassword),
FullName = "System Administrator",
Role = "Admin",
ThemePreference = "fluent_dark",
IsActive = true,
CreatedAt = DateTime.UtcNow
};
dbContext.Users.Add(adminUser);
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("[{Channel}] Seeded default Admin user: {Email}", "AuthChannel", adminEmail);
}
}
private static UserDto MapToUserDto(UserEntity user)
{
return new UserDto
{
Id = user.Id,
Email = user.Email,
FullName = user.FullName,
Role = user.Role,
IsActive = user.IsActive,
ThemePreference = string.IsNullOrWhiteSpace(user.ThemePreference) ? "fluent_dark" : user.ThemePreference,
FcmTokens = user.DeviceTokens?.Select(t => t.FcmToken).ToList() ?? new List(),
CreatedAt = user.CreatedAt,
LastLoginAt = user.LastLoginAt
};
}
}