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
@@ -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();
}
}