diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index 7ffff4b..d147319 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -2,6 +2,9 @@ ## Bug Fixes +- Fix memory leak — `HttpResponseMessage` objects never disposed in `ApiClient`, leaking TCP connections and content buffers on every API call (especially on failed connection attempts) +- Fix 401 retry leak — `AuthenticatedGetAsync`/`AuthenticatedRequestAsync` leaked the original response when retrying after token refresh +- Fix connection failure cleanup — `ConnectionManager.ConnectAsync` now properly disposes `ApiClient` and `EchoHubConnection` on any failure path (previously only cleaned up on saved-token auth failures) - Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients - Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64 @@ -10,6 +13,11 @@ - Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs - IRC account creation — connecting with a new username auto-registers the account (PASS and SASL PLAIN) +## Refactoring + +- Extract `IUserService`/`UserService` — consolidate user registration, authentication, and profile management into a dedicated service, eliminating duplicated logic between `AuthController` and `ChatService` +- IRC gateway now checks ban status during authentication (previously skipped) + ## CI - Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index fea3b2a..2d3d351 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -32,7 +32,7 @@ public sealed class ApiClient : IDisposable public async Task RegisterAsync(string username, string password, string? displayName = null) { var request = new RegisterRequest(username, password, displayName); - var response = await _http.PostAsJsonAsync("/api/auth/register", request); + using var response = await _http.PostAsJsonAsync("/api/auth/register", request); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() @@ -45,7 +45,7 @@ public sealed class ApiClient : IDisposable public async Task LoginAsync(string username, string password) { var request = new LoginRequest(username, password); - var response = await _http.PostAsJsonAsync("/api/auth/login", request); + using var response = await _http.PostAsJsonAsync("/api/auth/login", request); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() @@ -61,7 +61,7 @@ public sealed class ApiClient : IDisposable throw new InvalidOperationException("No refresh token available."); var request = new RefreshRequest(_refreshToken); - var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); + using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() @@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable public async Task LoginWithRefreshTokenAsync(string refreshToken) { var request = new RefreshRequest(refreshToken); - var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); + using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() @@ -90,7 +90,7 @@ public sealed class ApiClient : IDisposable try { var request = new RefreshRequest(_refreshToken); - await _http.PostAsJsonAsync("/api/auth/logout", request); + using var response = await _http.PostAsJsonAsync("/api/auth/logout", request); } catch { @@ -131,7 +131,7 @@ public sealed class ApiClient : IDisposable public async Task> GetChannelsAsync() { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync("/api/channels"); + using var response = await AuthenticatedGetAsync("/api/channels"); await EnsureSuccessAsync(response); var paginated = await response.Content.ReadFromJsonAsync>(); return paginated?.Items ?? []; @@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable public async Task GetEncryptionKeyAsync() { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync("/api/server/encryption-key"); + using var response = await AuthenticatedGetAsync("/api/server/encryption-key"); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() ?? throw new InvalidOperationException("Server returned empty encryption key response."); @@ -156,7 +156,7 @@ public sealed class ApiClient : IDisposable public async Task GetUserProfileAsync(string username) { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile"); + using var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile"); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); } @@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable public async Task UpdateProfileAsync(UpdateProfileRequest request) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PutAsJsonAsync("/api/users/profile", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -178,7 +178,7 @@ public sealed class ApiClient : IDisposable streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); content.Add(streamContent, "file", fileName); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsync("/api/users/avatar", content)); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync(); @@ -194,7 +194,7 @@ public sealed class ApiClient : IDisposable content.Add(streamContent, "file", fileName); var sizeQuery = size is not null ? $"?size={size}" : ""; - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -205,7 +205,7 @@ public sealed class ApiClient : IDisposable EnsureAuthenticated(); var request = new SendUrlRequest(url); var sizeQuery = size is not null ? $"?size={size}" : ""; - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -214,7 +214,7 @@ public sealed class ApiClient : IDisposable public async Task DownloadFileToTempAsync(string relativeUrl, string fileName) { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync(relativeUrl); + using var response = await AuthenticatedGetAsync(relativeUrl); await EnsureSuccessAsync(response); var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub"); @@ -232,7 +232,7 @@ public sealed class ApiClient : IDisposable { EnsureAuthenticated(); var request = new CreateChannelRequest(name, topic, isPublic); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/channels", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -242,7 +242,7 @@ public sealed class ApiClient : IDisposable { EnsureAuthenticated(); var request = new UpdateTopicRequest(topic); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -251,7 +251,7 @@ public sealed class ApiClient : IDisposable public async Task DeleteChannelAsync(string channelName) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}")); await EnsureSuccessAsync(response); } @@ -261,7 +261,7 @@ public sealed class ApiClient : IDisposable public async Task AssignRoleAsync(string username, ServerRole role) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role))); await EnsureSuccessAsync(response); } @@ -269,7 +269,7 @@ public sealed class ApiClient : IDisposable public async Task KickUserAsync(string username, string? reason = null) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason))); await EnsureSuccessAsync(response); } @@ -277,7 +277,7 @@ public sealed class ApiClient : IDisposable public async Task BanUserAsync(string username, string? reason = null) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason))); await EnsureSuccessAsync(response); } @@ -285,7 +285,7 @@ public sealed class ApiClient : IDisposable public async Task UnbanUserAsync(string username) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { })); await EnsureSuccessAsync(response); } @@ -293,7 +293,7 @@ public sealed class ApiClient : IDisposable public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes))); await EnsureSuccessAsync(response); } @@ -301,7 +301,7 @@ public sealed class ApiClient : IDisposable public async Task UnmuteUserAsync(string username) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { })); await EnsureSuccessAsync(response); } @@ -309,7 +309,7 @@ public sealed class ApiClient : IDisposable public async Task DeleteMessageAsync(Guid messageId) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.DeleteAsync($"/api/moderation/messages/{messageId}")); await EnsureSuccessAsync(response); } @@ -317,7 +317,7 @@ public sealed class ApiClient : IDisposable public async Task NukeChannelAsync(string channelName) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke")); await EnsureSuccessAsync(response); } @@ -333,6 +333,7 @@ public sealed class ApiClient : IDisposable /// /// Performs a GET request with automatic token refresh on 401. + /// Caller is responsible for disposing the returned response. /// private async Task AuthenticatedGetAsync(string url) { @@ -343,7 +344,9 @@ public sealed class ApiClient : IDisposable try { await RefreshTokenAsync(); - response = await _http.GetAsync(url); + var retryResponse = await _http.GetAsync(url); + response.Dispose(); + response = retryResponse; } catch { @@ -356,6 +359,7 @@ public sealed class ApiClient : IDisposable /// /// Performs a request with automatic token refresh on 401. + /// Caller is responsible for disposing the returned response. /// private async Task AuthenticatedRequestAsync(Func> requestFactory) { @@ -366,7 +370,9 @@ public sealed class ApiClient : IDisposable try { await RefreshTokenAsync(); - response = await requestFactory(); + var retryResponse = await requestFactory(); + response.Dispose(); + response = retryResponse; } catch { diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 50cca1c..6a45d5f 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -60,77 +60,83 @@ internal sealed class ConnectionManager : IAsyncDisposable _apiClient?.Dispose(); _apiClient = new ApiClient(info.ServerUrl); - onStatus("Authenticating..."); - - LoginResponse loginResponse; - - if (info.SavedRefreshToken is not null) + try { - try + onStatus("Authenticating..."); + + LoginResponse loginResponse; + + if (info.SavedRefreshToken is not null) { loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken); Log.Information("Authenticated via saved session for {User}", loginResponse.Username); } + else if (info.IsRegister) + { + loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password); + } + else + { + loginResponse = await _apiClient.LoginAsync(info.Username, info.Password); + } + + // Auto-persist rotated refresh tokens for Remember Me + _apiClient.OnTokensRefreshed += HandleTokensRefreshed; + + // E2E encryption key + onStatus("Fetching encryption key..."); + try + { + var encryptionKey = await _apiClient.GetEncryptionKeyAsync(); + _encryption.SetKey(encryptionKey); + Log.Information("E2E encryption key established"); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted"); + } + + onStatus("Authenticated, connecting..."); + + if (_connection is not null) + await _connection.DisposeAsync(); + + _connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption); + WireConnectionEvents(_connection); + await _connection.ConnectAsync(); + + var channels = await _apiClient.GetChannelsAsync(); + onStatus("Connected"); + + // Join default channel + fetch history + _joinedChannels.Clear(); + _joinedChannels.Add(HubConstants.DefaultChannel); + await _connection.JoinChannelAsync(HubConstants.DefaultChannel); + + List history = []; + try + { + history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); + } catch { - _apiClient.Dispose(); - _apiClient = null; - throw; // Caller handles saved-session expiry + // History might not be available } - } - else if (info.IsRegister) - { - loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password); - } - else - { - loginResponse = await _apiClient.LoginAsync(info.Username, info.Password); - } - // Auto-persist rotated refresh tokens for Remember Me - _apiClient.OnTokensRefreshed += HandleTokensRefreshed; - - // E2E encryption key - onStatus("Fetching encryption key..."); - try - { - var encryptionKey = await _apiClient.GetEncryptionKeyAsync(); - _encryption.SetKey(encryptionKey); - Log.Information("E2E encryption key established"); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted"); - } - - onStatus("Authenticated, connecting..."); - - if (_connection is not null) - await _connection.DisposeAsync(); - - _connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption); - WireConnectionEvents(_connection); - await _connection.ConnectAsync(); - - var channels = await _apiClient.GetChannelsAsync(); - onStatus("Connected"); - - // Join default channel + fetch history - _joinedChannels.Clear(); - _joinedChannels.Add(HubConstants.DefaultChannel); - await _connection.JoinChannelAsync(HubConstants.DefaultChannel); - - List history = []; - try - { - history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); + return new ConnectResult(loginResponse, channels, history); } catch { - // History might not be available - } + if (_connection is not null) + { + await _connection.DisposeAsync(); + _connection = null; + } - return new ConnectResult(loginResponse, channels, history); + _apiClient.Dispose(); + _apiClient = null; + throw; + } } // ── Cleanup ─────────────────────────────────────────────────────────── diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 97d880a..2c1e0cb 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -25,9 +25,6 @@ public interface IChatService Task BroadcastMessageAsync(string channelName, MessageDto message); Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null); - // Query operations (used by IRC gateway for WHOIS, AUTH) - Task GetUserProfileAsync(string username); + // Query operations (used by IRC gateway for WHOIS) Task> GetChannelsForUserAsync(string username); - Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); - Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password); } diff --git a/src/EchoHub.Core/Contracts/IUserService.cs b/src/EchoHub.Core/Contracts/IUserService.cs new file mode 100644 index 0000000..9043e3d --- /dev/null +++ b/src/EchoHub.Core/Contracts/IUserService.cs @@ -0,0 +1,13 @@ +using EchoHub.Core.DTOs; + +namespace EchoHub.Core.Contracts; + +public interface IUserService +{ + Task RegisterUserAsync(string username, string password, string? displayName = null); + Task AuthenticateUserAsync(string username, string password); + Task GetUserProfileAsync(string username); + Task GetUserByIdAsync(Guid userId); + Task UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor); + Task SetAvatarAsync(Guid userId, string asciiArt); +} diff --git a/src/EchoHub.Core/DTOs/CommonDtos.cs b/src/EchoHub.Core/DTOs/CommonDtos.cs index d41c4f6..5781546 100644 --- a/src/EchoHub.Core/DTOs/CommonDtos.cs +++ b/src/EchoHub.Core/DTOs/CommonDtos.cs @@ -24,3 +24,20 @@ public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, s public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null); public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message); } + +public enum UserError +{ + ValidationFailed, + AlreadyExists, + NotFound, + InvalidCredentials, + Banned +} + +public record UserOperationResult(UserProfileDto? User, UserError? Error, string? ErrorMessage) +{ + public bool IsSuccess => Error is null; + + public static UserOperationResult Success(UserProfileDto user) => new(user, null, null); + public static UserOperationResult Fail(UserError error, string message) => new(null, error, message); +} diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 00b4370..4346181 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -12,6 +12,7 @@ public sealed class IrcCommandHandler private readonly IrcClientConnection _conn; private readonly IrcOptions _options; private readonly IChatService _chatService; + private readonly IUserService _userService; private readonly IChannelService _channelService; private readonly IMessageEncryptionService _encryption; private readonly ILogger _logger; @@ -22,6 +23,7 @@ public sealed class IrcCommandHandler IrcClientConnection conn, IrcOptions options, IChatService chatService, + IUserService userService, IChannelService channelService, IMessageEncryptionService encryption, ILogger logger) @@ -29,6 +31,7 @@ public sealed class IrcCommandHandler _conn = conn; _options = options; _chatService = chatService; + _userService = userService; _channelService = channelService; _encryption = encryption; _logger = logger; @@ -168,23 +171,23 @@ public sealed class IrcCommandHandler _logger.LogDebug("SASL PLAIN auth attempt for user '{Username}' (connection {Id})", username, _conn.ConnectionId); - var result = await _chatService.AuthenticateUserAsync(username, password); + var result = await _userService.AuthenticateUserAsync(username, password); // Auth failed — try registering a new account - if (result is null) - result = await _chatService.RegisterUserAsync(username, password); + if (!result.IsSuccess) + result = await _userService.RegisterUserAsync(username, password); - if (result is null) + if (!result.IsSuccess) { - _logger.LogWarning("SASL auth/register failed for user '{Username}' (connection {Id})", - username, _conn.ConnectionId); + _logger.LogWarning("SASL auth/register failed for user '{Username}': {Error} (connection {Id})", + username, result.ErrorMessage, _conn.ConnectionId); await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL, - ":SASL authentication failed"); + $":SASL authentication failed — {result.ErrorMessage}"); return; } - _conn.Nickname = result.Value.Username; - _conn.UserId = result.Value.UserId; + _conn.Nickname = result.User!.Username; + _conn.UserId = result.User!.Id; _conn.IsAuthenticated = true; _logger.LogInformation("SASL auth succeeded for user '{Username}' (connection {Id})", @@ -288,26 +291,26 @@ public sealed class IrcCommandHandler return; } - var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password); + var result = await _userService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password); // Auth failed — try registering a new account - if (result is null) - result = await _chatService.RegisterUserAsync(_conn.Nickname!, _conn.Password); + if (!result.IsSuccess) + result = await _userService.RegisterUserAsync(_conn.Nickname!, _conn.Password); - if (result is null) + if (!result.IsSuccess) { await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH, - ":Password incorrect."); + $":{result.ErrorMessage}"); await _conn.SendAsync("ERROR :Authentication failed"); return; } - _conn.UserId = result.Value.UserId; - _conn.Nickname = result.Value.Username; + _conn.UserId = result.User!.Id; + _conn.Nickname = result.User!.Username; _conn.IsAuthenticated = true; _conn.IsRegistered = true; - await _chatService.UserConnectedAsync(_conn.ConnectionId, result.Value.UserId, result.Value.Username); + await _chatService.UserConnectedAsync(_conn.ConnectionId, result.User!.Id, result.User!.Username); await SendWelcomeBurstAsync(); } @@ -559,7 +562,7 @@ public sealed class IrcCommandHandler if (msg.Parameters.Count < 1) return; var nick = msg.Parameters[^1].ToLowerInvariant(); - var profile = await _chatService.GetUserProfileAsync(nick); + var profile = await _userService.GetUserProfileAsync(nick); if (profile is null) { diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index d9f555c..357125b 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -120,10 +120,11 @@ public sealed class IrcGatewayService : BackgroundService try { chatService = _services.GetRequiredService(); + var userService = _services.GetRequiredService(); var channelService = _services.GetRequiredService(); var encryption = _services.GetRequiredService(); var handler = new IrcCommandHandler( - connection, _options, chatService, channelService, encryption, _logger); + connection, _options, chatService, userService, channelService, encryption, _logger); await handler.RunAsync(ct); } diff --git a/src/EchoHub.Server/Auth/JwtTokenService.cs b/src/EchoHub.Server/Auth/JwtTokenService.cs index d0e9477..5906999 100644 --- a/src/EchoHub.Server/Auth/JwtTokenService.cs +++ b/src/EchoHub.Server/Auth/JwtTokenService.cs @@ -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]; diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs index ce61426..9317e8c 100644 --- a/src/EchoHub.Server/Controllers/AuthController.cs +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -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 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 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.")), + }; } diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 9c0d4c8..8621454 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -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 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.")), + }; } diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 2ab4dcc..f1ac5fb 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -115,6 +115,7 @@ while (true) // ── Chat Service + Broadcasters ───────────────────────────────────── builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 63954c1..0f41271 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -304,73 +304,9 @@ public class ChatService : IChatService } } - public async Task GetUserProfileAsync(string username) - { - username = username.ToLowerInvariant(); - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - 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> 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(); - - 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(); - - 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); - } - /// /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// diff --git a/src/EchoHub.Server/Services/UserService.cs b/src/EchoHub.Server/Services/UserService.cs new file mode 100644 index 0000000..2ab8eab --- /dev/null +++ b/src/EchoHub.Server/Services/UserService.cs @@ -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 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(); + + 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 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(); + + 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 GetUserProfileAsync(string username) + { + var normalizedUsername = username.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + return user is null ? null : ToProfileDto(user); + } + + public async Task GetUserByIdAsync(Guid userId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync(userId); + return user is null ? null : ToProfileDto(user); + } + + public async Task UpdateProfileAsync( + Guid userId, string? displayName, string? bio, string? nicknameColor) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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 SetAvatarAsync(Guid userId, string asciiArt) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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); +}