Refactor user management: Extract IUserService and UserService, consolidate user registration, authentication, and profile management. Fix memory leaks in ApiClient, enhance connection management, and improve error handling in AuthController and UsersController. Update IRC command handling to utilize IUserService for user operations.

This commit is contained in:
HueByte
2026-02-23 14:51:24 +01:00
parent 27a25b1b43
commit bdcff74ad5
14 changed files with 409 additions and 282 deletions
@@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Microsoft.IdentityModel.Tokens;
@@ -51,6 +52,31 @@ public class JwtTokenService
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(UserProfileDto profile)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var expiresAt = DateTimeOffset.UtcNow.Add(AccessTokenLifetime);
Claim[] claims =
[
new(JwtRegisteredClaimNames.Sub, profile.Id.ToString()),
new("username", profile.Username),
new("display_name", profile.DisplayName ?? profile.Username),
new("role", profile.Role.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
];
var token = new JwtSecurityToken(
issuer: _issuer,
audience: _audience,
claims: claims,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
public static string GenerateRefreshToken()
{
var randomBytes = new byte[64];
@@ -1,4 +1,4 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
@@ -16,94 +16,59 @@ public class AuthController : ControllerBase
{
private readonly EchoHubDbContext _db;
private readonly JwtTokenService _jwt;
private readonly IUserService _userService;
public AuthController(EchoHubDbContext db, JwtTokenService jwt)
public AuthController(EchoHubDbContext db, JwtTokenService jwt, IUserService userService)
{
_db = db;
_jwt = jwt;
_userService = userService;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required."));
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName);
if (!result.IsSuccess)
return MapUserError(result);
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
if (request.Password.Length < 6)
return BadRequest(new ErrorResponse("Password must be at least 6 characters."));
if (request.Password.Length > ValidationConstants.MaxPasswordLength)
return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."));
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
return Conflict(new ErrorResponse("Username is already taken."));
// First registered user on the server becomes the Owner
var isFirstUser = !await _db.Users.AnyAsync();
var user = new User
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
DisplayName = request.DisplayName?.Trim(),
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
var profile = result.User!;
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
var refreshToken = JwtTokenService.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id,
UserId = profile.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await _db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, profile.Username, profile.DisplayName, profile.NicknameColor));
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required."));
var result = await _userService.AuthenticateUserAsync(request.Username, request.Password);
if (!result.IsSuccess)
return MapUserError(result);
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Invalid username or password."));
if (user.IsBanned)
return Unauthorized(new ErrorResponse("Your account has been banned."));
user.LastSeenAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
var profile = result.User!;
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
var refreshToken = JwtTokenService.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id,
UserId = profile.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await _db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, profile.Username, profile.DisplayName, profile.NicknameColor));
}
[HttpPost("refresh")]
@@ -159,4 +124,14 @@ public class AuthController : ControllerBase
return Ok();
}
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
{
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
};
}
@@ -1,12 +1,11 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
@@ -16,25 +15,24 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")]
public class UsersController : ControllerBase
{
private readonly EchoHubDbContext _db;
private readonly IUserService _userService;
private readonly ImageToAsciiService _asciiService;
public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService)
public UsersController(IUserService userService, ImageToAsciiService asciiService)
{
_db = db;
_userService = userService;
_asciiService = asciiService;
}
[HttpGet("{username}/profile")]
public async Task<IActionResult> GetProfile(string username)
{
var normalizedUsername = username.ToLowerInvariant().Trim();
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
var profile = await _userService.GetUserProfileAsync(username);
if (user is null)
if (profile is null)
return NotFound(new ErrorResponse("User not found."));
return Ok(ToProfileDto(user));
return Ok(profile);
}
[HttpPut("profile")]
@@ -44,37 +42,13 @@ public class UsersController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var user = await _db.Users.FindAsync(userId);
var result = await _userService.UpdateProfileAsync(
Guid.Parse(userIdClaim), request.DisplayName, request.Bio, request.NicknameColor);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
if (!result.IsSuccess)
return MapUserError(result);
if (request.DisplayName is not null)
{
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
user.DisplayName = request.DisplayName.Trim();
}
if (request.Bio is not null)
{
if (request.Bio.Length > ValidationConstants.MaxBioLength)
return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters."));
user.Bio = request.Bio.Trim();
}
if (request.NicknameColor is not null)
{
var color = request.NicknameColor.Trim();
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500)."));
user.NicknameColor = color.Length > 0 ? color : null;
}
await _db.SaveChangesAsync();
return Ok(ToProfileDto(user));
return Ok(result.User!);
}
[HttpPost("avatar")]
@@ -85,12 +59,6 @@ public class UsersController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var user = await _db.Users.FindAsync(userId);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded."));
@@ -106,22 +74,20 @@ public class UsersController : ControllerBase
var asciiArt = _asciiService.ConvertToAscii(stream);
user.AvatarAscii = asciiArt;
await _db.SaveChangesAsync();
var result = await _userService.SetAvatarAsync(Guid.Parse(userIdClaim), asciiArt);
if (!result.IsSuccess)
return MapUserError(result);
return Ok(new AvatarUploadResponse(asciiArt));
}
private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
user.Id,
user.Username,
user.DisplayName,
user.Bio,
user.NicknameColor,
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.Role,
user.CreatedAt,
user.LastSeenAt);
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
{
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
};
}
+1
View File
@@ -115,6 +115,7 @@ while (true)
// ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IChannelService, ChannelService>();
builder.Services.AddSingleton<IChatService, ChatService>();
@@ -304,73 +304,9 @@ public class ChatService : IChatService
}
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
username = username.ToLowerInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is null) return null;
return new UserProfileDto(
user.Id, user.Username, user.DisplayName, user.Bio,
user.NicknameColor, user.AvatarAscii, user.Status,
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
}
public Task<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
{
username = username.ToLowerInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is null) return null;
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
return null;
return (user.Id, user.Username);
}
public async Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password)
{
username = username.ToLowerInvariant().Trim();
if (!ValidationConstants.UsernameRegex().IsMatch(username))
return null;
if (password.Length < 6 || password.Length > ValidationConstants.MaxPasswordLength)
return null;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
if (await db.Users.AnyAsync(u => u.Username == username))
return null;
var isFirstUser = !await db.Users.AnyAsync();
var user = new User
{
Id = Guid.NewGuid(),
Username = username,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
};
db.Users.Add(user);
await db.SaveChangesAsync();
return (user.Id, user.Username);
}
/// <summary>
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
/// </summary>
+172
View File
@@ -0,0 +1,172 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace EchoHub.Server.Services;
public class UserService : IUserService
{
private readonly IServiceScopeFactory _scopeFactory;
public UserService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
if (!ValidationConstants.UsernameRegex().IsMatch(username))
return UserOperationResult.Fail(UserError.ValidationFailed,
"Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens.");
if (password.Length < 6)
return UserOperationResult.Fail(UserError.ValidationFailed, "Password must be at least 6 characters.");
if (password.Length > ValidationConstants.MaxPasswordLength)
return UserOperationResult.Fail(UserError.ValidationFailed,
$"Password must not exceed {ValidationConstants.MaxPasswordLength} characters.");
var normalizedUsername = username.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
return UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken.");
var isFirstUser = !await db.Users.AnyAsync();
var user = new User
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
DisplayName = displayName?.Trim(),
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
};
db.Users.Add(user);
await db.SaveChangesAsync();
return UserOperationResult.Success(ToProfileDto(user));
}
public async Task<UserOperationResult> AuthenticateUserAsync(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
var normalizedUsername = username.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null || !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
return UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password.");
if (user.IsBanned)
return UserOperationResult.Fail(UserError.Banned, "Your account has been banned.");
user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return UserOperationResult.Success(ToProfileDto(user));
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
var normalizedUsername = username.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
return user is null ? null : ToProfileDto(user);
}
public async Task<UserProfileDto?> GetUserByIdAsync(Guid userId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId);
return user is null ? null : ToProfileDto(user);
}
public async Task<UserOperationResult> UpdateProfileAsync(
Guid userId, string? displayName, string? bio, string? nicknameColor)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId);
if (user is null)
return UserOperationResult.Fail(UserError.NotFound, "User not found.");
if (displayName is not null)
{
if (displayName.Length > ValidationConstants.MaxDisplayNameLength)
return UserOperationResult.Fail(UserError.ValidationFailed,
$"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters.");
user.DisplayName = displayName.Trim();
}
if (bio is not null)
{
if (bio.Length > ValidationConstants.MaxBioLength)
return UserOperationResult.Fail(UserError.ValidationFailed,
$"Bio must not exceed {ValidationConstants.MaxBioLength} characters.");
user.Bio = bio.Trim();
}
if (nicknameColor is not null)
{
var color = nicknameColor.Trim();
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
return UserOperationResult.Fail(UserError.ValidationFailed,
"Nickname color must be a valid hex color (e.g. #FF5500).");
user.NicknameColor = color.Length > 0 ? color : null;
}
await db.SaveChangesAsync();
return UserOperationResult.Success(ToProfileDto(user));
}
public async Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId);
if (user is null)
return UserOperationResult.Fail(UserError.NotFound, "User not found.");
user.AvatarAscii = asciiArt;
await db.SaveChangesAsync();
return UserOperationResult.Success(ToProfileDto(user));
}
private static UserProfileDto ToProfileDto(User user) => new(
user.Id,
user.Username,
user.DisplayName,
user.Bio,
user.NicknameColor,
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.Role,
user.CreatedAt,
user.LastSeenAt);
}