refactor, security updates, cv extraction upgrades
This commit is contained in:
@@ -5,6 +5,7 @@ using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
@@ -47,9 +48,9 @@ public sealed class AuthController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record LoginRequest(string Email, string Password);
|
||||
public sealed record RegisterRequest(string Email, string Password);
|
||||
public sealed record AuthResult(string AccessToken, string TokenType);
|
||||
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true);
|
||||
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
|
||||
public sealed record AuthSessionResult(bool Authenticated, string Provider);
|
||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MeResult(
|
||||
string Provider,
|
||||
@@ -64,12 +65,18 @@ public sealed class AuthController : ControllerBase
|
||||
string? AvatarImageDataUrl,
|
||||
IList<string> Roles,
|
||||
GoogleLinkDto? GoogleLink);
|
||||
private const int MaxAvatarBytes = 1_000_000;
|
||||
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".png", ".jpg", ".jpeg", ".webp"
|
||||
};
|
||||
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
|
||||
public sealed record GoogleTokenRequest(string Token);
|
||||
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<ActionResult<AuthSessionResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
var password = request.Password ?? string.Empty;
|
||||
@@ -83,13 +90,14 @@ public sealed class AuthController : ControllerBase
|
||||
var ok = await _users.CheckPasswordAsync(user, password);
|
||||
if (!ok) return Unauthorized();
|
||||
|
||||
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
|
||||
return Ok(new AuthResult(token, "Bearer"));
|
||||
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, "local"));
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<ActionResult<AuthSessionResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var allow = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
if (!allow) return StatusCode(403, "Registration is disabled.");
|
||||
@@ -110,13 +118,14 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
|
||||
return Ok(new AuthResult(token, "Bearer"));
|
||||
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, "local"));
|
||||
}
|
||||
|
||||
[HttpPost("google/exchange")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
|
||||
[EnableRateLimiting("auth-login")]
|
||||
public async Task<ActionResult<AuthSessionResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = (request.Token ?? string.Empty).Trim();
|
||||
if (token.Length == 0) return BadRequest("Google token is required.");
|
||||
@@ -160,8 +169,23 @@ public sealed class AuthController : ControllerBase
|
||||
await _users.UpdateAsync(user);
|
||||
}
|
||||
|
||||
var appToken = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
|
||||
return Ok(new AuthResult(appToken, "Bearer"));
|
||||
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
|
||||
return Ok(new AuthSessionResult(true, "google"));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
ClearSessionCookies();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("csrf")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult EnsureCsrfCookie()
|
||||
{
|
||||
EnsureCsrfCookie(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
@@ -300,7 +324,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
[HttpPost("avatar")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
[RequestSizeLimit(5_000_000)]
|
||||
[RequestSizeLimit(MaxAvatarBytes)]
|
||||
public async Task<IActionResult> UploadAvatar([FromForm] IFormFile? file)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
@@ -314,24 +338,30 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest("Image file is required.");
|
||||
}
|
||||
|
||||
if (!string.Equals(file.ContentType, "image/png", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(file.ContentType, "image/webp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
|
||||
}
|
||||
|
||||
if (file.Length > 5_000_000)
|
||||
if (file.Length > MaxAvatarBytes)
|
||||
{
|
||||
return BadRequest("Avatar image is too large.");
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
||||
if (!AllowedAvatarExtensions.Contains(extension))
|
||||
{
|
||||
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
|
||||
}
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
using var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory);
|
||||
var bytes = memory.ToArray();
|
||||
|
||||
var detectedContentType = DetectAvatarContentType(bytes);
|
||||
if (detectedContentType is null)
|
||||
{
|
||||
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
|
||||
}
|
||||
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
user.AvatarImageDataUrl = $"data:{file.ContentType};base64,{base64}";
|
||||
user.AvatarImageDataUrl = $"data:{detectedContentType};base64,{base64}";
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
@@ -388,6 +418,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
[HttpPost("request-password-reset")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
@@ -431,6 +462,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
[HttpPost("reset-password")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
|
||||
{
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
@@ -456,6 +488,67 @@ public sealed class AuthController : ControllerBase
|
||||
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail);
|
||||
}
|
||||
|
||||
private async Task SignInWithAppSessionAsync(ApplicationUser user, bool rememberMe, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
|
||||
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||
Response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure));
|
||||
EnsureCsrfCookie(rememberMe, secure);
|
||||
}
|
||||
|
||||
private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null)
|
||||
{
|
||||
var secure = secureOverride ?? Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||
var csrf = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||
Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, secure));
|
||||
}
|
||||
|
||||
private void ClearSessionCookies()
|
||||
{
|
||||
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
|
||||
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(secure));
|
||||
}
|
||||
|
||||
private static string? DetectAvatarContentType(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length >= 8
|
||||
&& bytes[0] == 0x89
|
||||
&& bytes[1] == 0x50
|
||||
&& bytes[2] == 0x4E
|
||||
&& bytes[3] == 0x47
|
||||
&& bytes[4] == 0x0D
|
||||
&& bytes[5] == 0x0A
|
||||
&& bytes[6] == 0x1A
|
||||
&& bytes[7] == 0x0A)
|
||||
{
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
if (bytes.Length >= 3
|
||||
&& bytes[0] == 0xFF
|
||||
&& bytes[1] == 0xD8
|
||||
&& bytes[2] == 0xFF)
|
||||
{
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
if (bytes.Length >= 12
|
||||
&& bytes[0] == 0x52
|
||||
&& bytes[1] == 0x49
|
||||
&& bytes[2] == 0x46
|
||||
&& bytes[3] == 0x46
|
||||
&& bytes[8] == 0x57
|
||||
&& bytes[9] == 0x45
|
||||
&& bytes[10] == 0x42
|
||||
&& bytes[11] == 0x50)
|
||||
{
|
||||
return "image/webp";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
Reference in New Issue
Block a user