diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index 283dce6..46d4ac1 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -243,15 +243,24 @@ public static class Program _commandHandler.OnSetTopic += async (topic) => { - // Topic setting would go through an API endpoint if available. - // For now, show as a system message. - await Task.CompletedTask; - _app.Invoke(() => + if (_apiClient is null) + return; + + var channel = _mainWindow!.CurrentChannel; + if (string.IsNullOrEmpty(channel)) + return; + + try { - var channel = _mainWindow!.CurrentChannel; - if (!string.IsNullOrEmpty(channel)) - _mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}"); - }); + await _apiClient.UpdateChannelTopicAsync(channel, topic); + _app.Invoke(() => + _mainWindow!.AddSystemMessage(channel, $"Topic set to: {topic}")); + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Failed to set topic: {ex.Message}")); + } }; _commandHandler.OnListUsers += async () => @@ -340,7 +349,7 @@ public static class Program await _connection.DisposeAsync(); } - _connection = new EchoHubConnection(result.ServerUrl, _apiClient.Token!); + _connection = new EchoHubConnection(result.ServerUrl, _apiClient); WireConnectionEvents(_connection); await _connection.ConnectAsync(); diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 23d08f2..ba9ee6a 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; @@ -8,9 +9,12 @@ namespace EchoHub.Client.Services; public sealed class ApiClient : IDisposable { private readonly HttpClient _http; - private string? _token; + private string? _accessToken; + private string? _refreshToken; + private DateTimeOffset _expiresAt; - public string? Token => _token; + public string? Token => _accessToken; + public string? RefreshToken => _refreshToken; public string BaseUrl { get; } public ApiClient(string baseUrl) @@ -31,7 +35,7 @@ public sealed class ApiClient : IDisposable var result = await response.Content.ReadFromJsonAsync() ?? throw new InvalidOperationException("Registration returned empty response."); - SetToken(result.Token); + SetTokens(result); return result; } @@ -44,15 +48,77 @@ public sealed class ApiClient : IDisposable var result = await response.Content.ReadFromJsonAsync() ?? throw new InvalidOperationException("Login returned empty response."); - SetToken(result.Token); + SetTokens(result); return result; } + public async Task RefreshTokenAsync() + { + if (string.IsNullOrEmpty(_refreshToken)) + throw new InvalidOperationException("No refresh token available."); + + var request = new RefreshRequest(_refreshToken); + var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); + await EnsureSuccessAsync(response); + + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Token refresh returned empty response."); + + SetTokens(result); + } + + public async Task LogoutAsync() + { + if (!string.IsNullOrEmpty(_refreshToken)) + { + try + { + var request = new RefreshRequest(_refreshToken); + await _http.PostAsJsonAsync("/api/auth/logout", request); + } + catch + { + // Best-effort logout + } + } + + _accessToken = null; + _refreshToken = null; + _http.DefaultRequestHeaders.Authorization = null; + } + + /// + /// Returns a valid access token, refreshing if expired. + /// Used by EchoHubConnection for SignalR token provider. + /// + public async Task GetValidTokenAsync() + { + if (string.IsNullOrEmpty(_accessToken)) + return null; + + // Refresh if token expires within 60 seconds + if (DateTimeOffset.UtcNow >= _expiresAt.AddSeconds(-60) && !string.IsNullOrEmpty(_refreshToken)) + { + try + { + await RefreshTokenAsync(); + } + catch + { + // Return current token and let the caller handle auth failure + } + } + + return _accessToken; + } + public async Task> GetChannelsAsync() { EnsureAuthenticated(); - var channels = await _http.GetFromJsonAsync>("/api/channels"); - return channels ?? []; + var response = await AuthenticatedGetAsync("/api/channels"); + await EnsureSuccessAsync(response); + var paginated = await response.Content.ReadFromJsonAsync>(); + return paginated?.Items ?? []; } public async Task GetServerInfoAsync() @@ -64,15 +130,17 @@ public sealed class ApiClient : IDisposable public async Task GetUserProfileAsync(string username) { EnsureAuthenticated(); - var profile = await _http.GetFromJsonAsync($"/api/users/{Uri.EscapeDataString(username)}/profile"); - return profile; + var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile"); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(); } public async Task UpdateProfileAsync(UpdateProfileRequest request) { EnsureAuthenticated(); - var response = await _http.PutAsJsonAsync("/api/users/profile", request); - response.EnsureSuccessStatusCode(); + var response = await AuthenticatedRequestAsync(() => + _http.PutAsJsonAsync("/api/users/profile", request)); + await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); } @@ -84,10 +152,11 @@ public sealed class ApiClient : IDisposable streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); content.Add(streamContent, "file", fileName); - var response = await _http.PostAsync("/api/users/avatar", content); - response.EnsureSuccessStatusCode(); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsync("/api/users/avatar", content)); + await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync(); - return result?.AsciiArt; + return result?.AvatarAscii; } public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName) @@ -98,15 +167,92 @@ public sealed class ApiClient : IDisposable streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); content.Add(streamContent, "file", fileName); - var response = await _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content); - response.EnsureSuccessStatusCode(); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content)); + await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); } - private void SetToken(string token) + public async Task CreateChannelAsync(string name, string? topic = null) { - _token = token; - _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + EnsureAuthenticated(); + var request = new CreateChannelRequest(name, topic); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync("/api/channels", request)); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task UpdateChannelTopicAsync(string channelName, string? topic) + { + EnsureAuthenticated(); + var request = new UpdateTopicRequest(topic); + var response = await AuthenticatedRequestAsync(() => + _http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request)); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task DeleteChannelAsync(string channelName) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}")); + await EnsureSuccessAsync(response); + } + + private void SetTokens(LoginResponse result) + { + _accessToken = result.Token; + _refreshToken = result.RefreshToken; + _expiresAt = result.ExpiresAt; + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); + } + + /// + /// Performs a GET request with automatic token refresh on 401. + /// + private async Task AuthenticatedGetAsync(string url) + { + var response = await _http.GetAsync(url); + + if (response.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(_refreshToken)) + { + try + { + await RefreshTokenAsync(); + response = await _http.GetAsync(url); + } + catch + { + // Refresh failed, return original 401 + } + } + + return response; + } + + /// + /// Performs a request with automatic token refresh on 401. + /// + private async Task AuthenticatedRequestAsync(Func> requestFactory) + { + var response = await requestFactory(); + + if (response.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(_refreshToken)) + { + try + { + await RefreshTokenAsync(); + response = await requestFactory(); + } + catch + { + // Refresh failed, return original 401 + } + } + + return response; } private static async Task EnsureSuccessAsync(HttpResponseMessage response) @@ -114,14 +260,12 @@ public sealed class ApiClient : IDisposable if (response.IsSuccessStatusCode) return; - // Try to extract a meaningful error message from the response body var errorMessage = $"{(int)response.StatusCode} {response.ReasonPhrase}"; try { var body = await response.Content.ReadAsStringAsync(); if (!string.IsNullOrWhiteSpace(body)) { - // Try to parse {"error": "..."} format using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("error", out var errorProp) || doc.RootElement.TryGetProperty("Error", out errorProp)) @@ -144,7 +288,7 @@ public sealed class ApiClient : IDisposable private void EnsureAuthenticated() { - if (string.IsNullOrEmpty(_token)) + if (string.IsNullOrEmpty(_accessToken)) throw new InvalidOperationException("Not authenticated. Call LoginAsync or RegisterAsync first."); } @@ -168,5 +312,3 @@ public sealed class ApiClient : IDisposable _http.Dispose(); } } - -internal record AvatarUploadResponse(string AsciiArt); diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index f830185..61b965e 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -19,14 +19,14 @@ public sealed class EchoHubConnection : IAsyncDisposable public bool IsConnected => _connection.State == HubConnectionState.Connected; - public EchoHubConnection(string serverUrl, string jwtToken) + public EchoHubConnection(string serverUrl, ApiClient apiClient) { var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath; _connection = new HubConnectionBuilder() .WithUrl(hubUrl, options => { - options.AccessTokenProvider = () => Task.FromResult(jwtToken); + options.AccessTokenProvider = () => apiClient.GetValidTokenAsync(); }) .WithAutomaticReconnect() .Build(); diff --git a/src/EchoHub.Core/Constants/ValidationConstants.cs b/src/EchoHub.Core/Constants/ValidationConstants.cs new file mode 100644 index 0000000..7153149 --- /dev/null +++ b/src/EchoHub.Core/Constants/ValidationConstants.cs @@ -0,0 +1,26 @@ +using System.Text.RegularExpressions; + +namespace EchoHub.Core.Constants; + +public static partial class ValidationConstants +{ + public const string UsernamePattern = @"^[a-zA-Z0-9_-]{3,50}$"; + public const string ChannelNamePattern = @"^[a-zA-Z0-9_-]{2,100}$"; + public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$"; + + public const int MaxPasswordLength = 128; + public const int MaxDisplayNameLength = 100; + public const int MaxBioLength = 500; + public const int MaxStatusMessageLength = 100; + public const int MaxChannelTopicLength = 500; + public const int MaxHistoryCount = 100; + + [GeneratedRegex(UsernamePattern)] + public static partial Regex UsernameRegex(); + + [GeneratedRegex(ChannelNamePattern)] + public static partial Regex ChannelNameRegex(); + + [GeneratedRegex(HexColorPattern)] + public static partial Regex HexColorRegex(); +} diff --git a/src/EchoHub.Core/DTOs/AuthDtos.cs b/src/EchoHub.Core/DTOs/AuthDtos.cs index 30437eb..b5faccb 100644 --- a/src/EchoHub.Core/DTOs/AuthDtos.cs +++ b/src/EchoHub.Core/DTOs/AuthDtos.cs @@ -4,4 +4,12 @@ public record RegisterRequest(string Username, string Password, string? DisplayN public record LoginRequest(string Username, string Password); -public record LoginResponse(string Token, string Username, string? DisplayName, string? NicknameColor); +public record LoginResponse( + string Token, + string RefreshToken, + DateTimeOffset ExpiresAt, + string Username, + string? DisplayName, + string? NicknameColor); + +public record RefreshRequest(string RefreshToken); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index afe8ae6..c13b62c 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -29,3 +29,7 @@ public record UserDto( DateTimeOffset LastSeenAt); public record SendMessageRequest(string ChannelName, string Content); + +public record CreateChannelRequest(string Name, string? Topic = null); + +public record UpdateTopicRequest(string? Topic); diff --git a/src/EchoHub.Core/DTOs/CommonDtos.cs b/src/EchoHub.Core/DTOs/CommonDtos.cs new file mode 100644 index 0000000..67dc868 --- /dev/null +++ b/src/EchoHub.Core/DTOs/CommonDtos.cs @@ -0,0 +1,5 @@ +namespace EchoHub.Core.DTOs; + +public record ErrorResponse(string Error, string? Detail = null); + +public record PaginatedResponse(List Items, int Total, int Offset, int Limit); diff --git a/src/EchoHub.Core/DTOs/ProfileDtos.cs b/src/EchoHub.Core/DTOs/ProfileDtos.cs index 5ad9312..de392c4 100644 --- a/src/EchoHub.Core/DTOs/ProfileDtos.cs +++ b/src/EchoHub.Core/DTOs/ProfileDtos.cs @@ -29,3 +29,5 @@ public record UserPresenceDto( string? NicknameColor, UserStatus Status, string? StatusMessage); + +public record AvatarUploadResponse(string AvatarAscii); diff --git a/src/EchoHub.Core/Models/RefreshToken.cs b/src/EchoHub.Core/Models/RefreshToken.cs new file mode 100644 index 0000000..02bbbb4 --- /dev/null +++ b/src/EchoHub.Core/Models/RefreshToken.cs @@ -0,0 +1,17 @@ +namespace EchoHub.Core.Models; + +public class RefreshToken +{ + public Guid Id { get; set; } + public required string TokenHash { get; set; } + public Guid UserId { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? RevokedAt { get; set; } + + public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; + public bool IsRevoked => RevokedAt is not null; + public bool IsActive => !IsExpired && !IsRevoked; + + public User? User { get; set; } +} diff --git a/src/EchoHub.Server/Auth/JwtTokenService.cs b/src/EchoHub.Server/Auth/JwtTokenService.cs index 6cfb8b3..3c0b15b 100644 --- a/src/EchoHub.Server/Auth/JwtTokenService.cs +++ b/src/EchoHub.Server/Auth/JwtTokenService.cs @@ -1,5 +1,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Cryptography; using System.Text; using EchoHub.Core.Models; using Microsoft.IdentityModel.Tokens; @@ -15,10 +16,14 @@ public class JwtTokenService(IConfiguration configuration) private readonly string _audience = configuration["Jwt:Audience"] ?? throw new InvalidOperationException("Jwt:Audience is not configured."); - public string GenerateToken(User user) + private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15); + public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30); + + public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expiresAt = DateTimeOffset.UtcNow.Add(AccessTokenLifetime); Claim[] claims = [ @@ -32,9 +37,23 @@ public class JwtTokenService(IConfiguration configuration) issuer: _issuer, audience: _audience, claims: claims, - expires: DateTime.UtcNow.AddDays(7), + expires: expiresAt.UtcDateTime, signingCredentials: credentials); - return new JwtSecurityTokenHandler().WriteToken(token); + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); + } + + public static string GenerateRefreshToken() + { + var randomBytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes); + } + + public static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToBase64String(bytes); } } diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs index 5af1ca4..4c4cf78 100644 --- a/src/EchoHub.Server/Controllers/AuthController.cs +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -1,32 +1,38 @@ +using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Data; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Controllers; [ApiController] [Route("api/auth")] +[EnableRateLimiting("auth")] public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase { [HttpPost("register")] public async Task Register([FromBody] RegisterRequest request) { if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - return BadRequest(new { Error = "Username and password are required." }); + return BadRequest(new ErrorResponse("Username and password are required.")); - if (request.Username.Length < 3 || request.Username.Length > 50) - return BadRequest(new { Error = "Username must be between 3 and 50 characters." }); + 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 { Error = "Password must be at least 6 characters." }); + 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 { Error = "Username is already taken." }); + return Conflict(new ErrorResponse("Username is already taken.")); var user = new User { @@ -39,28 +45,102 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll db.Users.Add(user); await db.SaveChangesAsync(); - var token = jwt.GenerateToken(user); + var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); + var refreshToken = JwtTokenService.GenerateRefreshToken(); - return Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor)); + db.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + TokenHash = JwtTokenService.HashToken(refreshToken), + UserId = user.Id, + ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), + }); + await db.SaveChangesAsync(); + + return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); } [HttpPost("login")] public async Task Login([FromBody] LoginRequest request) { if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - return BadRequest(new { Error = "Username and password are required." }); + return BadRequest(new ErrorResponse("Username and password are required.")); 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(); + return Unauthorized(new ErrorResponse("Invalid username or password.")); user.LastSeenAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); - var token = jwt.GenerateToken(user); + var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); + var refreshToken = JwtTokenService.GenerateRefreshToken(); - return Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor)); + db.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + TokenHash = JwtTokenService.HashToken(refreshToken), + UserId = user.Id, + ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), + }); + await db.SaveChangesAsync(); + + return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); + } + + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshRequest request) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + return BadRequest(new ErrorResponse("Refresh token is required.")); + + var tokenHash = JwtTokenService.HashToken(request.RefreshToken); + var storedToken = await db.RefreshTokens + .Include(r => r.User) + .FirstOrDefaultAsync(r => r.TokenHash == tokenHash); + + if (storedToken is null || !storedToken.IsActive || storedToken.User is null) + return Unauthorized(new ErrorResponse("Invalid or expired refresh token.")); + + // Revoke old refresh token (rotation) + storedToken.RevokedAt = DateTimeOffset.UtcNow; + + var user = storedToken.User; + user.LastSeenAt = DateTimeOffset.UtcNow; + + // Issue new token pair + var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); + var newRefreshToken = JwtTokenService.GenerateRefreshToken(); + + db.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + TokenHash = JwtTokenService.HashToken(newRefreshToken), + UserId = user.Id, + ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), + }); + await db.SaveChangesAsync(); + + return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); + } + + [HttpPost("logout")] + public async Task Logout([FromBody] RefreshRequest request) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + return BadRequest(new ErrorResponse("Refresh token is required.")); + + var tokenHash = JwtTokenService.HashToken(request.RefreshToken); + var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash); + + if (storedToken is not null && storedToken.IsActive) + { + storedToken.RevokedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + return Ok(); } } diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 037c4cd..00f839f 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -8,6 +8,7 @@ using EchoHub.Server.Hubs; using EchoHub.Server.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -16,6 +17,7 @@ namespace EchoHub.Server.Controllers; [ApiController] [Route("api/channels")] [Authorize] +[EnableRateLimiting("general")] public class ChannelsController( EchoHubDbContext db, FileStorageService fileStorage, @@ -23,9 +25,17 @@ public class ChannelsController( IHubContext hubContext) : ControllerBase { [HttpGet] - public async Task GetChannels() + public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) { + offset = Math.Max(0, offset); + limit = Math.Clamp(limit, 1, 100); + + var total = await db.Channels.CountAsync(); + var channels = await db.Channels + .OrderBy(c => c.Name) + .Skip(offset) + .Take(limit) .Select(c => new ChannelDto( c.Id, c.Name, @@ -34,45 +44,146 @@ public class ChannelsController( c.CreatedAt)) .ToListAsync(); - return Ok(channels); + return Ok(new PaginatedResponse(channels, total, offset, limit)); + } + + [HttpPost] + public async Task CreateChannel([FromBody] CreateChannelRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new ErrorResponse("Channel name is required.")); + + var channelName = request.Name.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.")); + + if (await db.Channels.AnyAsync(c => c.Name == channelName)) + return Conflict(new ErrorResponse($"Channel '{channelName}' already exists.")); + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Unauthorized(new ErrorResponse("Authentication required.")); + + var channel = new Channel + { + Id = Guid.NewGuid(), + Name = channelName, + Topic = request.Topic?.Trim(), + CreatedByUserId = Guid.Parse(userIdClaim), + }; + + db.Channels.Add(channel); + await db.SaveChangesAsync(); + + var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); + await hubContext.Clients.All.ChannelUpdated(dto); + + return Created($"/api/channels/{channelName}", dto); + } + + [HttpPut("{channel}/topic")] + public async Task UpdateTopic(string channel, [FromBody] UpdateTopicRequest request) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Unauthorized(new ErrorResponse("Authentication required.")); + + var channelName = channel.ToLowerInvariant().Trim(); + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + + if (dbChannel is null) + return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); + + if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) + return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic.")); + + if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength) + return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.")); + + dbChannel.Topic = request.Topic?.Trim(); + await db.SaveChangesAsync(); + + var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); + await hubContext.Clients.Group(channelName).ChannelUpdated(dto); + + return Ok(dto); + } + + [HttpDelete("{channel}")] + public async Task DeleteChannel(string channel) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Unauthorized(new ErrorResponse("Authentication required.")); + + var channelName = channel.ToLowerInvariant().Trim(); + + if (channelName == HubConstants.DefaultChannel) + return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted.")); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + + if (dbChannel is null) + return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); + + if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) + return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel.")); + + db.Channels.Remove(dbChannel); + await db.SaveChangesAsync(); + + return NoContent(); } [HttpPost("{channel}/upload")] + [EnableRateLimiting("upload")] public async Task Upload(string channel) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var usernameClaim = User.FindFirstValue("username"); if (userIdClaim is null || usernameClaim is null) - return Unauthorized(); + return Unauthorized(new ErrorResponse("Authentication required.")); var userId = Guid.Parse(userIdClaim); var channelName = channel.ToLowerInvariant().Trim(); + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + return BadRequest(new ErrorResponse("Invalid channel name format.")); + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (dbChannel is null) - return NotFound(new { Error = $"Channel '{channelName}' does not exist." }); + return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); if (!Request.HasFormContentType || Request.Form.Files.Count == 0) - return BadRequest(new { Error = "No file uploaded." }); + return BadRequest(new ErrorResponse("No file uploaded.")); var file = Request.Form.Files[0]; if (file.Length > HubConstants.MaxFileSizeBytes) - return BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB." }); + return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); + // Detect if file is an image by checking magic bytes using var stream = file.OpenReadStream(); + var isImage = FileValidationHelper.IsValidImage(stream); + var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName); - var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; - var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); - var isImage = imageExtensions.Contains(extension); - var messageType = isImage ? MessageType.Image : MessageType.File; - var content = isImage - ? asciiService.ConvertToAscii(System.IO.File.OpenRead(filePath)) - : file.FileName; - var attachmentUrl = $"/api/files/{fileId}"; + string content; + if (isImage) + { + using var imageStream = System.IO.File.OpenRead(filePath); + content = asciiService.ConvertToAscii(imageStream); + } + else + { + content = file.FileName; + } + + var attachmentUrl = $"/api/files/{fileId}"; var sender = await db.Users.FindAsync(userId); var message = new Message @@ -102,7 +213,6 @@ public class ChannelsController( file.FileName, message.SentAt); - // Broadcast to all clients in the channel via SignalR await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); return Ok(messageDto); diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs index d60927c..1e1f677 100644 --- a/src/EchoHub.Server/Controllers/FilesController.cs +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -1,19 +1,27 @@ +using EchoHub.Core.DTOs; using EchoHub.Server.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; namespace EchoHub.Server.Controllers; [ApiController] [Route("api/files")] +[Authorize] +[EnableRateLimiting("general")] public class FilesController(FileStorageService fileStorage) : ControllerBase { [HttpGet("{fileId}")] public IActionResult GetFile(string fileId) { + if (!Guid.TryParse(fileId, out _)) + return BadRequest(new ErrorResponse("Invalid file identifier.")); + var filePath = fileStorage.GetFilePath(fileId); if (filePath is null) - return NotFound(new { Error = "File not found." }); + return NotFound(new ErrorResponse("File not found.")); var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch { diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 8a8e672..298b272 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -5,12 +5,15 @@ 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; [ApiController] [Route("api/users")] +[Authorize] +[EnableRateLimiting("general")] public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase { [HttpGet("{username}/profile")] @@ -20,33 +23,45 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); if (user is null) - return NotFound(new { Error = "User not found." }); + return NotFound(new ErrorResponse("User not found.")); return Ok(ToProfileDto(user)); } [HttpPut("profile")] - [Authorize] public async Task UpdateProfile([FromBody] UpdateProfileRequest request) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); if (userIdClaim is null) - return Unauthorized(); + 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 { Error = "User not found." }); + return NotFound(new ErrorResponse("User not found.")); 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) - user.NicknameColor = request.NicknameColor.Trim(); + { + 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(); @@ -54,34 +69,38 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi } [HttpPost("avatar")] - [Authorize] + [EnableRateLimiting("upload")] public async Task UploadAvatar() { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); if (userIdClaim is null) - return Unauthorized(); + 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 { Error = "User not found." }); + return NotFound(new ErrorResponse("User not found.")); if (!Request.HasFormContentType || Request.Form.Files.Count == 0) - return BadRequest(new { Error = "No file uploaded." }); + return BadRequest(new ErrorResponse("No file uploaded.")); var file = Request.Form.Files[0]; if (file.Length > HubConstants.MaxAvatarSizeBytes) - return BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB." }); + return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB.")); using var stream = file.OpenReadStream(); + + if (!FileValidationHelper.IsValidImage(stream)) + return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP.")); + var asciiArt = asciiService.ConvertToAscii(stream); user.AvatarAscii = asciiArt; await db.SaveChangesAsync(); - return Ok(new { AvatarAscii = asciiArt }); + return Ok(new AvatarUploadResponse(asciiArt)); } private static UserProfileDto ToProfileDto(Core.Models.User user) => new( diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index fcad6ba..678b048 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -9,6 +9,7 @@ public class EchoHubDbContext(DbContextOptions options) : DbCo public DbSet Users => Set(); public DbSet Channels => Set(); public DbSet Messages => Set(); + public DbSet RefreshTokens => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -36,7 +37,7 @@ public class EchoHubDbContext(DbContextOptions options) : DbCo modelBuilder.Entity(entity => { entity.HasKey(c => c.Id); - entity.HasIndex(c => c.Name); + entity.HasIndex(c => c.Name).IsUnique(); entity.Property(c => c.Name).IsRequired().HasMaxLength(100); entity.Property(c => c.Topic).HasMaxLength(500); @@ -56,6 +57,19 @@ public class EchoHubDbContext(DbContextOptions options) : DbCo entity.Property(m => m.AttachmentFileName).HasMaxLength(255); }); + modelBuilder.Entity(entity => + { + entity.HasKey(r => r.Id); + entity.HasIndex(r => r.TokenHash); + entity.HasIndex(r => r.UserId); + entity.Property(r => r.TokenHash).IsRequired().HasMaxLength(128); + + entity.HasOne(r => r.User) + .WithMany() + .HasForeignKey(r => r.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + // SQLite does not support DateTimeOffset in ORDER BY clauses. // Convert all DateTimeOffset properties to Unix milliseconds (long) for storage. var dateTimeOffsetConverter = new ValueConverter( diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index 2e3694b..24e7d73 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -41,8 +41,6 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack public override async Task OnDisconnectedAsync(Exception? exception) { - // Capture channels before disconnecting, since UserDisconnected clears them - // when the last connection for a user is removed. var preDisconnectUsername = Context.User?.FindFirstValue("username"); var channelsBeforeDisconnect = preDisconnectUsername is not null ? presenceTracker.GetChannelsForUser(preDisconnectUsername) @@ -82,21 +80,18 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { channelName = channelName.ToLowerInvariant().Trim(); + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + { + await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + return []; + } + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) { - channel = new Channel - { - Id = Guid.NewGuid(), - Name = channelName, - CreatedByUserId = CurrentUserId, - }; - - db.Channels.Add(channel); - await db.SaveChangesAsync(); - - logger.LogInformation("Channel '{Channel}' created by {User}", channelName, CurrentUsername); + await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list."); + return []; } presenceTracker.JoinChannel(CurrentUsername, channelName); @@ -126,6 +121,12 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { channelName = channelName.ToLowerInvariant().Trim(); + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + { + await Clients.Caller.Error("Invalid channel name."); + return; + } + if (string.IsNullOrWhiteSpace(content)) { await Clients.Caller.Error("Message content cannot be empty."); @@ -181,13 +182,12 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack public async Task> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount) { channelName = channelName.ToLowerInvariant().Trim(); + count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) - { return []; - } var messages = await db.Messages .Where(m => m.ChannelId == channel.Id) @@ -214,6 +214,12 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack public async Task UpdateStatus(UserStatus status, string? statusMessage) { + if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) + { + await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."); + return; + } + var user = await db.Users.FindAsync(CurrentUserId); if (user is null) @@ -223,7 +229,7 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack } user.Status = status; - user.StatusMessage = statusMessage; + user.StatusMessage = statusMessage?.Trim(); user.LastSeenAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index c9d1009..9d23b6d 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -1,5 +1,7 @@ using System.Text; +using System.Threading.RateLimiting; using EchoHub.Core.Constants; +using Microsoft.AspNetCore.RateLimiting; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Data; @@ -72,15 +74,48 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -// ── CORS (allow all for development) ────────────────────────────────────────── +// ── Rate Limiting ──────────────────────────────────────────────────────────── +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddFixedWindowLimiter("auth", limiter => + { + limiter.PermitLimit = 10; + limiter.Window = TimeSpan.FromMinutes(1); + limiter.QueueLimit = 0; + }); + + options.AddFixedWindowLimiter("upload", limiter => + { + limiter.PermitLimit = 5; + limiter.Window = TimeSpan.FromMinutes(1); + limiter.QueueLimit = 0; + }); + + options.AddFixedWindowLimiter("general", limiter => + { + limiter.PermitLimit = 100; + limiter.Window = TimeSpan.FromMinutes(1); + limiter.QueueLimit = 0; + }); +}); + +// ── CORS ───────────────────────────────────────────────────────────────────── +var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get(); + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { policy.AllowAnyHeader() .AllowAnyMethod() - .AllowCredentials() - .SetIsOriginAllowed(_ => true); + .AllowCredentials(); + + if (allowedOrigins is { Length: > 0 }) + policy.WithOrigins(allowedOrigins); + else + policy.SetIsOriginAllowed(_ => true); }); }); @@ -108,6 +143,7 @@ using (var scope = app.Services.CreateScope()) // ── Middleware ───────────────────────────────────────────────────────────────── app.UseCors(); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/src/EchoHub.Server/Services/FileValidationHelper.cs b/src/EchoHub.Server/Services/FileValidationHelper.cs new file mode 100644 index 0000000..4c300aa --- /dev/null +++ b/src/EchoHub.Server/Services/FileValidationHelper.cs @@ -0,0 +1,68 @@ +namespace EchoHub.Server.Services; + +public static class FileValidationHelper +{ + private static readonly byte[] JpegMagic = [0xFF, 0xD8, 0xFF]; + private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47]; + private static readonly byte[] GifMagic = [0x47, 0x49, 0x46]; + private static readonly byte[] WebpRiff = [0x52, 0x49, 0x46, 0x46]; // "RIFF" + private static readonly byte[] WebpTag = [0x57, 0x45, 0x42, 0x50]; // "WEBP" + + /// + /// Validates that a stream contains a recognized image format by checking magic bytes. + /// The stream position is reset to the beginning after validation. + /// + public static bool IsValidImage(Stream stream) + { + if (!stream.CanSeek) + return false; + + var originalPosition = stream.Position; + try + { + var header = new byte[12]; + var bytesRead = stream.Read(header, 0, header.Length); + + if (bytesRead < 3) + return false; + + // JPEG: FF D8 FF + if (StartsWith(header, bytesRead, JpegMagic)) + return true; + + // PNG: 89 50 4E 47 + if (bytesRead >= 4 && StartsWith(header, bytesRead, PngMagic)) + return true; + + // GIF: 47 49 46 (GIF87a or GIF89a) + if (StartsWith(header, bytesRead, GifMagic)) + return true; + + // WebP: RIFF....WEBP + if (bytesRead >= 12 && StartsWith(header, bytesRead, WebpRiff) + && header[8] == WebpTag[0] && header[9] == WebpTag[1] + && header[10] == WebpTag[2] && header[11] == WebpTag[3]) + return true; + + return false; + } + finally + { + stream.Position = originalPosition; + } + } + + private static bool StartsWith(byte[] buffer, int length, byte[] magic) + { + if (length < magic.Length) + return false; + + for (int i = 0; i < magic.Length; i++) + { + if (buffer[i] != magic[i]) + return false; + } + + return true; + } +}