390 lines
14 KiB
C#
390 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Interface for user management and authentication operations.
|
|
/// </summary>
|
|
public interface IUserService
|
|
{
|
|
/// <summary>Authenticates user credentials and returns JWT response.</summary>
|
|
Task<AuthResponseDto?> AuthenticateAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Registers a new user account.</summary>
|
|
Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Changes the initial password for a user.</summary>
|
|
Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Creates a new user by an admin.</summary>
|
|
Task<UserDto?> CreateUserByAdminAsync(CreateUserRequestDto request, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Updates an existing user account.</summary>
|
|
Task<UserDto?> UpdateUserAsync(Guid userId, UpdateUserRequestDto request,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Resets a user's password by admin.</summary>
|
|
Task<bool> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Registers or updates FCM device token.</summary>
|
|
Task<bool> RegisterOrUpdateFcmTokenAsync(Guid userId, string fcmToken, string deviceName,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Gets all registered users.</summary>
|
|
Task<List<UserDto>> GetAllUsersAsync(CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Deactivates a user account.</summary>
|
|
Task<bool> DeactivateUserAsync(Guid userId, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Seeds default admin account if not present.</summary>
|
|
Task SeedDefaultAdminAsync(string defaultAdminPassword, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public class UserService : IUserService
|
|
{
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly IJwtTokenService _jwtTokenService;
|
|
private readonly ILogger<UserService> _logger;
|
|
|
|
public UserService(
|
|
IServiceScopeFactory scopeFactory,
|
|
IJwtTokenService jwtTokenService,
|
|
ILogger<UserService> logger)
|
|
{
|
|
_scopeFactory = scopeFactory;
|
|
_jwtTokenService = jwtTokenService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<AuthResponseDto?> AuthenticateAsync(LoginRequestDto request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
string normalizedEmail = request.Email.Trim().ToLowerInvariant();
|
|
bool exists = await dbContext.Users.AnyAsync(u => u.Email.ToLower() == normalizedEmail, cancellationToken);
|
|
if (exists)
|
|
{
|
|
_logger.LogWarning("[{Channel}] Registration failed: Email '{Email}' is already taken.", "AuthChannel",
|
|
request.Email);
|
|
return null;
|
|
}
|
|
|
|
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
|
|
|
var newUser = new UserEntity
|
|
{
|
|
Email = normalizedEmail,
|
|
PasswordHash = passwordHash,
|
|
FullName = string.IsNullOrWhiteSpace(request.FullName) ? normalizedEmail.Split('@')[0] : request.FullName,
|
|
Role = "User",
|
|
ThemePreference = "fluent_dark", // Default Theme for FluentAvalonia
|
|
IsActive = true,
|
|
CreatedAt = DateTime.UtcNow,
|
|
LastLoginAt = DateTime.UtcNow
|
|
};
|
|
|
|
dbContext.Users.Add(newUser);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
var (token, expiresAt) = _jwtTokenService.GenerateToken(newUser);
|
|
|
|
return new AuthResponseDto
|
|
{
|
|
Token = token,
|
|
UserId = newUser.Id,
|
|
Email = newUser.Email,
|
|
FullName = newUser.FullName,
|
|
Role = newUser.Role,
|
|
ThemePreference = newUser.ThemePreference,
|
|
FcmTokens = new List<string>(),
|
|
ExpiresAt = expiresAt
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UserDto?> CreateUserByAdminAsync(CreateUserRequestDto request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UserDto?> UpdateUserAsync(Guid userId, UpdateUserRequestDto request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> ResetPasswordAsync(Guid userId, string newPassword,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> 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<BackendDbContext>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<List<UserDto>> GetAllUsersAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
var users = await dbContext.Users
|
|
.AsNoTracking()
|
|
.Include(u => u.DeviceTokens)
|
|
.OrderByDescending(u => u.CreatedAt)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return users.Select(MapToUserDto).ToList();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> DeactivateUserAsync(Guid userId, CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task SeedDefaultAdminAsync(string defaultAdminPassword, CancellationToken cancellationToken = default)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
|
|
|
string adminEmail = "admin@finlytic.com";
|
|
bool exists = await dbContext.Users.AnyAsync(u => u.Email == adminEmail, cancellationToken);
|
|
|
|
if (!exists)
|
|
{
|
|
string password = !string.IsNullOrWhiteSpace(defaultAdminPassword)
|
|
? defaultAdminPassword
|
|
: "AdminDefaultPassword2026!";
|
|
var adminUser = new UserEntity
|
|
{
|
|
Email = adminEmail,
|
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
|
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<string>(),
|
|
CreatedAt = user.CreatedAt,
|
|
LastLoginAt = user.LastLoginAt
|
|
};
|
|
}
|
|
} |