feat(Backend): update API gateway and websocket hubs

This commit is contained in:
2026-08-09 21:32:52 +02:00
parent fdf4b6efcb
commit a9553e9fbf
66 changed files with 188878 additions and 0 deletions
+390
View File
@@ -0,0 +1,390 @@
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
};
}
}