Merge pull request 'Async migration PR 1: UserRepository, auth pipeline & user controllers' (#194) from feature/webapi/async-user-auth into develop

Reviewed-on: #194
This commit was merged in pull request #194.
This commit is contained in:
2026-08-21 14:46:47 +00:00
6 changed files with 95 additions and 69 deletions
@@ -57,7 +57,7 @@ public class RefreshTokenTransformation(
/// </summary>
public const string ClaimType = "LoginValid";
/// <inheritdoc />
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
if (principal.HasClaim(claim => claim.Type == ClaimType)) {
@@ -68,12 +68,12 @@ public class RefreshTokenTransformation(
}
}
var userData = authService.GetUserData(principal);
if (userData == null) return Task.FromResult(principal);
var user = userRepository.Find(userData.Id);
if (user == null) return Task.FromResult(principal);
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return Task.FromResult(principal);
if (userData == null) return principal;
var user = await userRepository.FindAsync(userData.Id, CancellationToken.None);
if (user == null) return principal;
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return principal;
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
principal.AddIdentity(claimsIdentity);
return Task.FromResult(principal);
return principal;
}
}
+3 -2
View File
@@ -366,16 +366,17 @@ public class AssetController(
/// Creates a new asset record.
/// </summary>
/// <param name="dto">The asset creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the new asset ID, or an error response.</returns>
[NonAction]
[HttpPut]
[Authorize(Roles = "Curator,Admin")]
public ActionResult<Guid> Create([FromBody] AssetCreateDto dto) {
public async Task<ActionResult<Guid>> Create([FromBody] AssetCreateDto dto, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
if (dto.UploadedBy.HasValue) {
if (userRepository.Find(dto.UploadedBy.Value) == null) {
if (await userRepository.FindAsync(dto.UploadedBy.Value, cancellationToken) == null) {
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
return BadRequest();
}
+18 -14
View File
@@ -35,17 +35,18 @@ public class AuthController(
/// Authenticates a user by username/email and password, issuing JWT and refresh tokens.
/// </summary>
/// <param name="userDto">The login credentials.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>An authentication result with tokens, or an error.</returns>
[HttpPost("login")]
public ActionResult<AuthResultDto> Login([FromBody] CredentialsDto userDto) {
public async Task<ActionResult<AuthResultDto>> Login([FromBody] CredentialsDto userDto, CancellationToken cancellationToken) {
User? user;
if (userDto.Identifier.Contains('@')) {
//search for user in database (by email)
user = userRepository.FindByEmail(userDto.Identifier);
user = await userRepository.FindByEmailAsync(userDto.Identifier, cancellationToken);
} else {
//search for user in database (by username)
user = userRepository.FindByUsername(userDto.Identifier);
user = await userRepository.FindByUsernameAsync(userDto.Identifier, cancellationToken);
}
if (user == null) {
return NotFound(new AuthResultDto() {
@@ -79,7 +80,7 @@ public class AuthController(
case PasswordVerificationResult.Failed: return NotFound(new AuthResultDto() { Success = false, ErrorMessage = "User or Password was wrong"});
case PasswordVerificationResult.SuccessRehashNeeded:
user.Password = passwordHasher.HashPassword(user, userDto.Password);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
break;
case PasswordVerificationResult.Success: break;
}
@@ -88,7 +89,7 @@ public class AuthController(
var token = authService.GenerateAccessToken(user);
var refreshToken = authService.GenerateRefreshToken(user);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return new AuthResultDto() {
UserId = user.Id,
Token = token,
@@ -111,10 +112,11 @@ public class AuthController(
/// Registers a new user account.
/// </summary>
/// <param name="dto">The registration data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the new user ID, 403 if registration is disabled, or 409 if the user already exists.</returns>
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
[HttpPost("register")]
public ActionResult Register([FromBody] UserRegisterDto dto) {
public async Task<ActionResult> Register([FromBody] UserRegisterDto dto, CancellationToken cancellationToken) {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
if (regSetting?.Value != "true")
return StatusCode(403, "Registration is currently disabled on this server");
@@ -127,7 +129,8 @@ public class AuthController(
"""
);
if (userRepository.FindByEmail(dto.Email) != null || userRepository.FindByUsername(dto.Username) != null) {
if (await userRepository.FindByEmailAsync(dto.Email, cancellationToken) != null
|| await userRepository.FindByUsernameAsync(dto.Username, cancellationToken) != null) {
return Conflict("User already exists");
}
@@ -142,7 +145,7 @@ public class AuthController(
user.Password = passwordHasher.HashPassword(user, dto.Password);
userRepository.Insert(user);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(user.Id);
}
@@ -152,17 +155,17 @@ public class AuthController(
/// <returns>200 on success, 401 if unauthorized.</returns>
[Authorize]
[HttpPost("logout")]
public ActionResult Logout() {
public async Task<ActionResult> Logout(CancellationToken cancellationToken) {
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
if (identity == null) { return Unauthorized(); }
//Reset token from the database
User? user = userRepository.Find(identity.Id);
User? user = await userRepository.FindAsync(identity.Id, cancellationToken);
if (user == null) { return Unauthorized(); }
user.RefreshToken = string.Empty;
user.RefreshTokenExpires = null;
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -172,10 +175,11 @@ public class AuthController(
/// Refreshes an expired JWT access token using a valid refresh token.
/// </summary>
/// <param name="token">The refresh token request data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A new access token, and optionally a new refresh token.</returns>
[HttpPost("refresh")]
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
User? user = userRepository.Find(token.UserId);
public async Task<ActionResult<AuthResultDto>> RefreshToken([FromBody] RefreshDto token, CancellationToken cancellationToken) {
User? user = await userRepository.FindAsync(token.UserId, cancellationToken);
if (user == null) {
return NotFound(
@@ -225,7 +229,7 @@ public class AuthController(
authDto.RefreshToken = refreshToken;
user.RefreshToken = refreshToken;
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
}
return authDto;
+22 -15
View File
@@ -29,10 +29,11 @@ namespace Lactose.Controllers {
/// Creates a new user (admin only).
/// </summary>
/// <param name="userCreateDto">The user creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the new user ID, or conflict if the email/username already exists.</returns>
[Authorize(Roles = "Admin")]
[HttpPut]
public ActionResult<UserInfoDto> Create([FromBody] UserCreateDto userCreateDto) {
public async Task<ActionResult<UserInfoDto>> Create([FromBody] UserCreateDto userCreateDto, CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser != null) {
@@ -50,8 +51,9 @@ namespace Lactose.Controllers {
"""
);
if (userRepository.GetAll().Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
if (userRepository.GetAll().Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
List<User> users = await userRepository.GetAllAsync(cancellationToken);
if (users.Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
if (users.Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
var user = new User {
Username = userCreateDto.Username,
@@ -63,7 +65,7 @@ namespace Lactose.Controllers {
user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
userRepository.Insert(user);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(user.Id);
}
@@ -73,9 +75,10 @@ namespace Lactose.Controllers {
/// Gets a user by their ID.
/// </summary>
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The user info, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<UserInfoDto> Get(Guid id) {
public async Task<ActionResult<UserInfoDto>> Get(Guid id, CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
@@ -85,7 +88,7 @@ namespace Lactose.Controllers {
logger.LogDebug($"Sending user with id {id}");
User? user = userRepository.FindWithMaintainedPersons(id);
User? user = await userRepository.FindWithMaintainedPersonsAsync(id, cancellationToken);
if (user is null) return NotFound();
@@ -98,13 +101,15 @@ namespace Lactose.Controllers {
/// <returns>A list of all users.</returns>
[Authorize(Roles = "Admin,Curator")]
[HttpGet]
public ActionResult<List<UserInfoDto>> GetAll() {
public async Task<ActionResult<List<UserInfoDto>>> GetAll(CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
logger.LogDebug("Sending Users list");
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
List<User> users = await userRepository.GetAllAsync(cancellationToken);
return Ok(users.Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
}
@@ -112,9 +117,10 @@ namespace Lactose.Controllers {
/// Updates an existing user's profile.
/// </summary>
/// <param name="dto">The user update data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The updated user info, or 403/404 if unauthorized or not found.</returns>
[HttpPost("update")]
public ActionResult<UserInfoDto> Update([FromBody] UserUpdateDto dto) {
public async Task<ActionResult<UserInfoDto>> Update([FromBody] UserUpdateDto dto, CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
@@ -136,7 +142,7 @@ namespace Lactose.Controllers {
if (authenticatedUser.AccessLevel != EAccessLevel.Admin && authenticatedUser.Id != dto.Id) return Forbid();
User? user = userRepository.Find(dto.Id);
User? user = await userRepository.FindAsync(dto.Id, cancellationToken);
if (user == null) return NotFound();
if (dto.Username != null) { user.Username = dto.Username; }
@@ -172,11 +178,11 @@ namespace Lactose.Controllers {
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
if (dto.MaintainedPersonIds != null) {
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
await userRepository.SetMaintainedPersonsAsync(user.Id, dto.MaintainedPersonIds, cancellationToken);
}
}
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
}
@@ -184,10 +190,11 @@ namespace Lactose.Controllers {
/// Soft-deletes a user by their ID (admin only).
/// </summary>
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the deleted user ID, or 404 if not found.</returns>
[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public ActionResult<Guid> Delete([FromRoute] Guid id) {
public async Task<ActionResult<Guid>> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
@@ -197,14 +204,14 @@ namespace Lactose.Controllers {
logger.LogDebug($"Deleting user: {id}");
User? user = userRepository.Find(id);
User? user = await userRepository.FindAsync(id, cancellationToken);
if (user == null) return NotFound();
user.DeletedAt = DateTime.UtcNow;
//TODO: eventually remove/hide all of the user content if required
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(id);
}
}
+27 -20
View File
@@ -7,49 +7,55 @@ namespace Lactose.Repositories;
/// </summary>
public interface IUserRepository : IDisposable {
/// <summary>
/// Return the list of all users
/// Return the list of all users.
/// </summary>
/// <returns></returns>
IEnumerable<User> GetAll();
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The list of all users.</returns>
Task<List<User>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Insert a new uer
/// Insert a new user.
/// </summary>
/// <param name="user"></param>
/// <param name="user">The user to insert.</param>
void Insert(User user);
/// <summary>
/// Saves all the changes to the Model
/// Saves all the changes to the Model.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
/// <summary>
/// Get the user from the database.
/// </summary>
/// <param name="id"></param>
/// <returns>Returns the user if exist</returns>
User? Find(Guid id);
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Returns the user if exist.</returns>
Task<User?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Find a user by email, expects that the email is unique
/// Find a user by email, expects that the email is unique.
/// </summary>
/// <param name="email"></param>
/// <returns>Returns the user if found </returns>
User? FindByEmail(string email);
/// <param name="email">The email address.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Returns the user if found.</returns>
Task<User?> FindByEmailAsync(string email, CancellationToken cancellationToken);
/// <summary>
/// Find a user by username, expects that the username is unique
/// Find a user by username, expects that the username is unique.
/// </summary>
/// <param name="username"></param>
/// <returns>Returns the user if found </returns>
User? FindByUsername(string username);
/// <param name="username">The username.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Returns the user if found.</returns>
Task<User?> FindByUsernameAsync(string username, CancellationToken cancellationToken);
/// <summary>
/// Get the user with their maintained persons eagerly loaded.
/// </summary>
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Returns the user with maintained persons if found.</returns>
User? FindWithMaintainedPersons(Guid id);
Task<User?> FindWithMaintainedPersonsAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Sets the list of person (cosplayer) IDs that a user maintains.
@@ -57,5 +63,6 @@ public interface IUserRepository : IDisposable {
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="personIds">The person IDs to assign.</param>
void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SetMaintainedPersonsAsync(Guid userId, IEnumerable<Guid> personIds, CancellationToken cancellationToken);
}
+16 -9
View File
@@ -1,5 +1,6 @@
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -9,30 +10,36 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
public void Dispose() => context.Dispose();
/// <inheritdoc />
public IEnumerable<User> GetAll() => context.Users.Include(u => u.MaintainedPersons).AsEnumerable();
public Task<List<User>> GetAllAsync(CancellationToken cancellationToken) =>
context.Users.Include(u => u.MaintainedPersons).ToListAsync(cancellationToken);
/// <inheritdoc />
public void Insert(User user) => context.Users.Add(user);
/// <inheritdoc />
public void Save() => context.SaveChanges();
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
/// <inheritdoc />
public User? Find(Guid id) => context.Users.Find(id);
public async Task<User?> FindAsync(Guid id, CancellationToken cancellationToken) =>
await context.Users.FindAsync([id], cancellationToken);
/// <inheritdoc />
public User? FindByEmail(string email) => context.Users.FirstOrDefault(u => u.Email == email);
public Task<User?> FindByEmailAsync(string email, CancellationToken cancellationToken) =>
context.Users.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
/// <inheritdoc />
public User? FindByUsername(string username) => context.Users.FirstOrDefault(u => u.Username == username);
public Task<User?> FindByUsernameAsync(string username, CancellationToken cancellationToken) =>
context.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken);
/// <inheritdoc />
public User? FindWithMaintainedPersons(Guid id) =>
context.Users.Include(u => u.MaintainedPersons).FirstOrDefault(u => u.Id == id);
public Task<User?> FindWithMaintainedPersonsAsync(Guid id, CancellationToken cancellationToken) =>
context.Users.Include(u => u.MaintainedPersons).FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
/// <inheritdoc />
public void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds) {
var existing = context.PersonMaintainers.Where(pm => pm.UserId == userId);
public async Task SetMaintainedPersonsAsync(Guid userId, IEnumerable<Guid> personIds, CancellationToken cancellationToken) {
var existing = await context.PersonMaintainers
.Where(pm => pm.UserId == userId)
.ToListAsync(cancellationToken);
context.PersonMaintainers.RemoveRange(existing);
foreach (var personId in personIds) {