refactor, security updates, cv extraction upgrades

This commit is contained in:
2026-04-11 01:34:32 +02:00
parent 806b200ac5
commit 27fd70a2d7
59 changed files with 6817 additions and 1561 deletions
+116 -23
View File
@@ -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();