Files

484 lines
18 KiB
C#

using System.Text.Json;
using System.Security.Claims;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private readonly IConfiguration _cfg;
private readonly UserManager<ApplicationUser> _users;
private readonly ITokenService _tokens;
private readonly IAppEmailSender _email;
private readonly IGoogleTokenValidator _googleTokens;
private readonly ILogger<AuthController> _logger;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger<AuthController> logger)
{
_cfg = cfg;
_users = users;
_tokens = tokens;
_email = email;
_googleTokens = googleTokens;
_logger = logger;
}
[HttpGet("config")]
[AllowAnonymous]
public IActionResult Config()
{
var requireAuth = _cfg.GetValue("Auth:Require", false);
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
return Ok(new
{
requireAuth,
googleEnabled,
localEnabled = true,
allowRegistration,
});
}
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 GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult(
string Provider,
string? Id,
string? Email,
string? UserName,
string? FirstName,
string? LastName,
string? DisplayName,
string? ProfileCvText,
string? ProfileCvStructureJson,
string? AvatarImageDataUrl,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
public sealed record GoogleTokenRequest(string Token);
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? string.Empty).Trim();
var password = request.Password ?? string.Empty;
if (email.Length == 0) return BadRequest("Email is required.");
if (password.Length == 0) return BadRequest("Password is required.");
var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
if (user is null) return Unauthorized();
var ok = await _users.CheckPasswordAsync(user, password);
if (!ok) return Unauthorized();
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(token, "Bearer"));
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
var allow = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allow) return StatusCode(403, "Registration is disabled.");
var email = (request.Email ?? string.Empty).Trim();
var password = request.Password ?? string.Empty;
if (email.Length == 0) return BadRequest("Email is required.");
if (password.Length == 0) return BadRequest("Password is required.");
var existing = await _users.FindByEmailAsync(email);
if (existing is not null) return BadRequest("User already exists.");
var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true };
var res = await _users.CreateAsync(user, password);
if (!res.Succeeded)
{
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
}
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(token, "Bearer"));
}
[HttpPost("google/exchange")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Google token is required.");
GoogleTokenPrincipal google;
try
{
google = await _googleTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return Unauthorized(ex.Message);
}
var user = await _users.Users.FirstOrDefaultAsync(
x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email),
cancellationToken);
if (user is null && google.EmailVerified && !string.IsNullOrWhiteSpace(google.Email))
{
user = await _users.FindByEmailAsync(google.Email);
if (user is not null)
{
_logger.LogInformation("Auto-linking Google sign-in for existing local account {Email}", google.Email);
}
}
if (user is null)
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
}
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
{
user.GoogleSubject = google.Subject;
user.GoogleEmail = google.Email;
user.GoogleLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(google.Name);
user.FirstName ??= TrimOrNull(google.GivenName);
user.LastName ??= TrimOrNull(google.FamilyName);
await _users.UpdateAsync(user);
}
var appToken = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(appToken, "Bearer"));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is not null)
{
var roles = await _users.GetRolesAsync(user);
return Ok(ToMeResult(user, roles));
}
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var iss = User.FindFirstValue("iss") ?? string.Empty;
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
return Ok(new MeResult(
Provider: provider,
Id: sub,
Email: email,
UserName: null,
FirstName: User.FindFirstValue("given_name"),
LastName: User.FindFirstValue("family_name"),
DisplayName: User.FindFirstValue("name"),
ProfileCvText: null,
ProfileCvStructureJson: null,
AvatarImageDataUrl: null,
Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
}
[HttpPut("profile")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
}
var email = TrimOrNull(request.Email);
var userName = TrimOrNull(request.UserName);
var firstName = TrimOrNull(request.FirstName);
var lastName = TrimOrNull(request.LastName);
var displayName = TrimOrNull(request.DisplayName);
var profileCvText = TrimOrNull(request.ProfileCvText);
var profileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson);
if (email is not null) user.Email = email;
if (userName is not null) user.UserName = userName;
user.FirstName = firstName;
user.LastName = lastName;
user.DisplayName = displayName;
user.ProfileCvText = profileCvText;
user.ProfileCvStructureJson = profileCvStructureJson;
var res = await _users.UpdateAsync(user);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
}
[HttpPost("google/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<GoogleLinkDto>> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Google token is required.");
GoogleTokenPrincipal google;
try
{
google = await _googleTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
var conflict = await _users.Users
.Where(x => x.Id != user.Id)
.FirstOrDefaultAsync(x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken);
if (conflict is not null)
{
return Conflict("That Google account is already linked to another Jobbjakt user.");
}
user.GoogleSubject = google.Subject;
user.GoogleEmail = google.Email;
user.GoogleLinkedAt = DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(google.Name);
user.FirstName ??= TrimOrNull(google.GivenName);
user.LastName ??= TrimOrNull(google.FamilyName);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new GoogleLinkDto(true, user.GoogleEmail, user.GoogleLinkedAt));
}
[HttpDelete("google/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UnlinkGoogle()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.GoogleSubject = null;
user.GoogleEmail = null;
user.GoogleLinkedAt = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
[HttpPost("avatar")]
[Authorize(AuthenticationSchemes = "local")]
[RequestSizeLimit(5_000_000)]
public async Task<IActionResult> UploadAvatar([FromForm] IFormFile? file)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
if (file is null || file.Length == 0)
{
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)
{
return BadRequest("Avatar image is too large.");
}
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var bytes = memory.ToArray();
var base64 = Convert.ToBase64String(bytes);
user.AvatarImageDataUrl = $"data:{file.ContentType};base64,{base64}";
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new { avatarImageDataUrl = user.AvatarImageDataUrl });
}
[HttpDelete("avatar")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> DeleteAvatar()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.AvatarImageDataUrl = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[HttpPost("change-password")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return StatusCode(501, "Password changes are only supported for local username/password accounts.");
}
if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return BadRequest("CurrentPassword is required.");
if (string.IsNullOrWhiteSpace(request.NewPassword)) return BadRequest("NewPassword is required.");
var res = await _users.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
}
public sealed record RequestPasswordResetRequest(string Email);
[HttpPost("request-password-reset")]
[AllowAnonymous]
public async Task<IActionResult> RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? string.Empty).Trim();
if (email.Length == 0) return NoContent();
var user = await _users.FindByEmailAsync(email);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
{
return NoContent();
}
var token = await _users.GeneratePasswordResetTokenAsync(user);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}";
try
{
await _email.SendAsync(
user.Email,
"Password reset",
$"You requested a password reset for Jobbjakt.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.",
cancellationToken
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send password reset email to {Email}", user.Email);
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Password reset email could not be sent right now. Please try again later.");
}
return NoContent();
}
public sealed record ResetPasswordRequest(string Email, string Token, string NewPassword);
[HttpPost("reset-password")]
[AllowAnonymous]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{
var email = (request.Email ?? string.Empty).Trim();
var token = request.Token ?? string.Empty;
var newPassword = request.NewPassword ?? string.Empty;
if (email.Length == 0) return BadRequest("Email is required.");
if (token.Length == 0) return BadRequest("Token is required.");
if (newPassword.Length == 0) return BadRequest("NewPassword is required.");
var user = await _users.FindByEmailAsync(email);
if (user is null) return BadRequest("Invalid email or token.");
var res = await _users.ResetPasswordAsync(user, token, newPassword);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
}
private IActionResult EmailDeliveryUnavailable(string detail)
{
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail);
}
private static string? TrimOrNull(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
{
return new MeResult(
Provider: "local",
Id: user.Id,
Email: user.Email,
UserName: user.UserName,
FirstName: user.FirstName,
LastName: user.LastName,
DisplayName: user.DisplayName,
ProfileCvText: user.ProfileCvText,
ProfileCvStructureJson: user.ProfileCvStructureJson,
AvatarImageDataUrl: user.AvatarImageDataUrl,
Roles: roles,
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt));
}
}