feat: Refactor authentication and token management

- Updated EchoHubConnection to use ApiClient for token retrieval.
- Introduced ValidationConstants for common validation patterns and limits.
- Enhanced AuthDtos with refresh token support and expiration details.
- Added new ChatDtos for channel creation and topic updates.
- Created CommonDtos for error and paginated responses.
- Implemented RefreshToken model for managing refresh tokens.
- Modified JwtTokenService to generate and hash refresh tokens.
- Updated AuthController to handle registration, login, token refresh, and logout with improved error handling.
- Enhanced ChannelsController with channel creation, topic updates, and pagination for channel retrieval.
- Added FilesController for file management with rate limiting.
- Improved UsersController for profile updates and avatar uploads with validation.
- Integrated rate limiting across controllers to manage request load.
- Introduced FileValidationHelper for validating uploaded image files.
- Updated database context to include RefreshToken and enforce unique constraints.
- Enhanced ChatHub for improved channel and message handling with validation.
- Updated Program.cs to configure rate limiting and CORS policies.
This commit is contained in:
HueByte
2026-02-19 03:25:01 +01:00
parent ed1bf13302
commit 287270b26d
18 changed files with 669 additions and 96 deletions
+22 -3
View File
@@ -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);
}
}
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}
@@ -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<ChatHub, IEchoHubClient> hubContext) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetChannels()
public async Task<IActionResult> 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<ChannelDto>(channels, total, offset, limit));
}
[HttpPost]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
@@ -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
{
@@ -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<IActionResult> 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<IActionResult> 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(
+15 -1
View File
@@ -9,6 +9,7 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
public DbSet<User> Users => Set<User>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<Message> Messages => Set<Message>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -36,7 +37,7 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
modelBuilder.Entity<Channel>(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<EchoHubDbContext> options) : DbCo
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
});
modelBuilder.Entity<RefreshToken>(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<DateTimeOffset, long>(
+22 -16
View File
@@ -41,8 +41,6 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> 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<ChatHub> 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<ChatHub> 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<ChatHub> logger, PresenceTrack
public async Task<List<MessageDto>> 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<ChatHub> 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<ChatHub> logger, PresenceTrack
}
user.Status = status;
user.StatusMessage = statusMessage;
user.StatusMessage = statusMessage?.Trim();
user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
+39 -3
View File
@@ -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<PresenceTracker>();
builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>();
// ── 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<string[]>();
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();
@@ -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"
/// <summary>
/// Validates that a stream contains a recognized image format by checking magic bytes.
/// The stream position is reset to the beginning after validation.
/// </summary>
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;
}
}