First Commit
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
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) won’t 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user