Add full profiles and latency tests

This commit is contained in:
cesnimda
2026-03-22 12:06:25 +01:00
parent 91f6361055
commit 0fa481cab6
11 changed files with 704 additions and 103 deletions
+172 -25
View File
@@ -1,9 +1,10 @@
using System.Security.Claims;
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;
@@ -15,12 +16,15 @@ public sealed class AuthController : ControllerBase
private readonly UserManager<ApplicationUser> _users;
private readonly ITokenService _tokens;
private readonly IAppEmailSender _email;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email)
private readonly IGoogleTokenValidator _googleTokens;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens)
{
_cfg = cfg;
_users = users;
_tokens = tokens;
_email = email;
_googleTokens = googleTokens;
}
[HttpGet("config")]
@@ -43,6 +47,19 @@ 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 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,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName);
public sealed record GoogleTokenRequest(string Token);
[HttpPost("login")]
[AllowAnonymous]
@@ -91,6 +108,44 @@ public sealed class AuthController : ControllerBase
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 ?? "").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)
{
return Unauthorized("This Google account is not linked to a Job Tracker 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;
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)
@@ -99,48 +154,47 @@ public sealed class AuthController : ControllerBase
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
});
return Ok(ToMeResult(u, 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>()
});
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"),
Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
}
public sealed record UpdateProfileRequest(string? Email, string? UserName);
[HttpPut("profile")]
[Authorize]
[Authorize(AuthenticationSchemes = "local")]
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();
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);
if (email is not null) u.Email = email;
if (userName is not null) u.UserName = userName;
u.FirstName = firstName;
u.LastName = lastName;
u.DisplayName = displayName;
var res = await _users.UpdateAsync(u);
if (!res.Succeeded)
@@ -149,10 +203,80 @@ public sealed class AuthController : ControllerBase
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 ?? "").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 Job Tracker 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();
}
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[HttpPost("change-password")]
[Authorize]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var u = await _users.GetUserAsync(User);
@@ -170,6 +294,7 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
public sealed record RequestPasswordResetRequest(string Email);
[HttpPost("request-password-reset")]
@@ -182,7 +307,6 @@ public sealed class AuthController : ControllerBase
var user = await _users.FindByEmailAsync(email);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
{
// Avoid user enumeration.
return NoContent();
}
@@ -229,5 +353,28 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
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,
Roles: roles,
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt));
}
}
+43 -5
View File
@@ -25,7 +25,17 @@ public sealed class UsersController : ControllerBase
_cfg = cfg;
}
public sealed record UserDto(string Id, string? Email, string? UserName, bool EmailConfirmed, List<string> Roles);
public sealed record UserDto(
string Id,
string? Email,
string? UserName,
string? FirstName,
string? LastName,
string? DisplayName,
bool EmailConfirmed,
string? GoogleEmail,
DateTimeOffset? GoogleLinkedAt,
List<string> Roles);
[HttpGet]
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
@@ -38,13 +48,13 @@ public sealed class UsersController : ControllerBase
foreach (var u in items)
{
var rs = await _users.GetRolesAsync(u);
outList.Add(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList()));
outList.Add(ToDto(u, rs.ToList()));
}
return Ok(outList);
}
public sealed record CreateUserRequest(string Email, string Password, string[]? Roles);
public sealed record CreateUserRequest(string Email, string Password, string? UserName, string? FirstName, string? LastName, string? DisplayName, string[]? Roles);
[HttpPost]
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken)
@@ -58,7 +68,15 @@ public sealed class UsersController : ControllerBase
var existing = await _users.FindByEmailAsync(email);
if (existing is not null) return BadRequest("User already exists.");
var u = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true };
var u = new ApplicationUser
{
UserName = string.IsNullOrWhiteSpace(request.UserName) ? email : request.UserName.Trim(),
Email = email,
EmailConfirmed = true,
FirstName = TrimOrNull(request.FirstName),
LastName = TrimOrNull(request.LastName),
DisplayName = TrimOrNull(request.DisplayName)
};
var res = await _users.CreateAsync(u, password);
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
@@ -72,7 +90,7 @@ public sealed class UsersController : ControllerBase
}
var rs = await _users.GetRolesAsync(u);
return Ok(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList()));
return Ok(ToDto(u, rs.ToList()));
}
public sealed record SetRolesRequest(string[] Roles);
@@ -167,4 +185,24 @@ public sealed class UsersController : ControllerBase
return NoContent();
}
private static UserDto ToDto(ApplicationUser user, List<string> roles)
{
return new UserDto(
user.Id,
user.Email,
user.UserName,
user.FirstName,
user.LastName,
user.DisplayName,
user.EmailConfirmed,
user.GoogleEmail,
user.GoogleLinkedAt,
roles);
}
private static string? TrimOrNull(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}