Files
jobtrackingapp/JobTrackerApi/Controllers/AuthController.cs
T
2026-03-21 11:55:27 +01:00

234 lines
8.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Security.Claims;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
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;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email)
{
_cfg = cfg;
_users = users;
_tokens = tokens;
_email = email;
}
[HttpGet("config")]
[AllowAnonymous]
public IActionResult Config()
{
var requireAuth = _cfg.GetValue("Auth:Require", false);
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? "").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);
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? "").Trim();
var password = request.Password ?? "";
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 ?? "").Trim();
var password = request.Password ?? "";
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"));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me(CancellationToken cancellationToken)
{
var u = await _users.GetUserAsync(User);
if (u is not null)
{
var roles = await _users.GetRolesAsync(u);
return Ok(new
{
provider = "local",
id = u.Id,
email = u.Email,
userName = u.UserName,
roles
});
}
// Google direct bearer tokens (or any other external JWT) wont map to an Identity user.
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var iss = User.FindFirstValue("iss") ?? "";
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
return Ok(new
{
provider,
id = sub,
email,
roles = Array.Empty<string>()
});
}
public sealed record UpdateProfileRequest(string? Email, string? UserName);
[HttpPut("profile")]
[Authorize]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{
var u = await _users.GetUserAsync(User);
if (u is null)
{
// Google bearer tokens are accepted, but they don't map to Identity users in this build.
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
}
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
var userName = string.IsNullOrWhiteSpace(request.UserName) ? null : request.UserName.Trim();
if (email is not null) u.Email = email;
if (userName is not null) u.UserName = userName;
var res = await _users.UpdateAsync(u);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return NoContent();
}
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[HttpPost("change-password")]
[Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var u = await _users.GetUserAsync(User);
if (u 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(u, 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 ?? "").Trim();
if (email.Length == 0) return NoContent();
var user = await _users.FindByEmailAsync(email);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
{
// Avoid user enumeration.
return NoContent();
}
var token = await _users.GeneratePasswordResetTokenAsync(user);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").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)}";
await _email.SendAsync(
user.Email,
"Password reset",
$"You requested a password reset for Job Tracker.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.",
cancellationToken
);
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 ?? "").Trim();
var token = request.Token ?? "";
var newPassword = request.NewPassword ?? "";
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();
}
}