feat(Backend): update API gateway and websocket hubs
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user