Compare commits

...

11 commits

Author SHA1 Message Date
de8d07ee02 Merge pull request 'User profile service' (#2) from 250809_UserProfile into main
Reviewed-on: #2
2025-08-11 20:10:49 +00:00
Jiří Vrabec
602496ec08 Add user profile endpoints 2025-08-11 22:08:13 +02:00
Jiří Vrabec
84e16d655d Add exception messages 2025-08-11 20:07:31 +02:00
Jiří Vrabec
1b54a6d6a6 Merge branch '250809_UserProfile' of git.chillplace.beer:chilldev/drinkrate into 250809_UserProfile 2025-08-11 19:34:53 +02:00
Jiří Vrabec
4216535016 Prevent deleted user authentication 2025-08-11 19:33:55 +02:00
1dc37d3282 Handle not found user profile gracefully
Changes from throwing an exception when a user profile is not found to returning null.

This prevents the application from crashing when a user profile is not found, allowing for more graceful error handling.
2025-08-11 18:46:35 +02:00
7bf7f23925 Add exception handling middleware
Implement global exception handling to provide consistent error responses.
Registes custom exceptions with corresponding HTTP status codes and descriptions.
2025-08-11 18:36:12 +02:00
1906d0de0d Remove explicit fail call in admin authorization 2025-08-11 18:18:31 +02:00
Jiří Vrabec
ae723cecaf Add more exceptions 2025-08-11 07:56:11 +02:00
Jiří Vrabec
8058add053 Rename class 2025-08-11 07:46:14 +02:00
9bcf4df149 Merge pull request 'Setup base EF model and authentication' (#1) from EntityFrameworkSetup into main
Reviewed-on: #1
2025-08-09 17:20:47 +00:00
12 changed files with 275 additions and 64 deletions

View file

@ -1,6 +0,0 @@
namespace DrinkRateAPI.ApiModels.UserProfile;
public class ChangeAdminStatusBody
{
public bool ChangeStatusTo { get; set; }
}

View file

@ -2,5 +2,28 @@ namespace DrinkRateAPI.ApiModels.UserProfile;
public class UserProfileGet public class UserProfileGet
{ {
/// <summary>
/// User profile ID
/// </summary>
public string Id { get; set; }
/// <summary>
/// User profile name
/// </summary>
public string UserName { get; set; }
/// <summary>
/// Is user admin
/// </summary>
public bool IsAdmin { get; set; }
/// <summary>
/// Is user deleted
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// Applicaton user ID of the user profile
/// </summary>
public string ApplicationUserId { get; set; }
} }

View file

@ -1,6 +1,9 @@
namespace DrinkRateAPI.ApiModels.UserProfile; namespace DrinkRateAPI.ApiModels.UserProfile;
public class UserProfilePut public class UserProfilePut : UserProfileSelfPut
{ {
public string UserName { get; set; } /// <summary>
/// Is user admin
/// </summary>
public bool? IsAdmin { get; set; }
} }

View file

@ -0,0 +1,9 @@
namespace DrinkRateAPI.ApiModels.UserProfile;
public class UserProfileSelfPut
{
/// <summary>
/// User profile name
/// </summary>
public string? UserName { get; set; }
}

View file

@ -2,6 +2,7 @@ using DrinkRateAPI.DbEntities;
using DrinkRateAPI.Services; using DrinkRateAPI.Services;
namespace DrinkRateAPI.AuthorizationPolicies; namespace DrinkRateAPI.AuthorizationPolicies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
public class AdminOnlyRequirement : IAuthorizationRequirement public class AdminOnlyRequirement : IAuthorizationRequirement
@ -22,28 +23,14 @@ public class AdminOnlyHandler : AuthorizationHandler<AdminOnlyRequirement>
} }
protected override async Task HandleRequirementAsync( protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, AuthorizationHandlerContext context,
AdminOnlyRequirement requirement) AdminOnlyRequirement requirement)
{ {
DbUserProfile userProfile; var userProfile = await _applicationUserService.UserProfileByApplicationUserAsync(context.User);
try
{
userProfile = await _applicationUserService.UserProfileByApplicationUserAsync(context.User);
}
catch (Exception _)
{
context.Fail();
return;
}
if (_userProfileService.IsUserProfileAdmin(userProfile)) if (_userProfileService.IsUserProfileAdmin(userProfile))
{ {
context.Succeed(requirement); context.Succeed(requirement);
} }
else
{
context.Fail();
}
} }
} }

View file

@ -1,4 +1,3 @@
using System.Security.Claims;
using DrinkRateAPI.ApiModels.UserProfile; using DrinkRateAPI.ApiModels.UserProfile;
using DrinkRateAPI.Services; using DrinkRateAPI.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -10,30 +9,40 @@ namespace DrinkRateAPI.Controllers;
[Route("userProfile")] [Route("userProfile")]
public class UserProfileController : ControllerBase public class UserProfileController : ControllerBase
{ {
private readonly ILogger<UserProfileController> _logger;
private readonly UserProfileService _userProfileService; private readonly UserProfileService _userProfileService;
public UserProfileController(ILogger<UserProfileController> logger, UserProfileService userProfileService) public UserProfileController(UserProfileService userProfileService)
{ {
_logger = logger;
_userProfileService = userProfileService; _userProfileService = userProfileService;
} }
[HttpPut(Name = "user_profile")] [HttpPut]
public UserProfileGet PutUserProfile(UserProfilePut userProfile) [Produces("application/json")]
public async Task<UserProfileGet> PutUserProfileSelf([FromBody] UserProfileSelfPut userProfile)
{ {
throw new ApplicationException(); return await _userProfileService.PutUserProfileSelfAsync(User, userProfile);
var x = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; //HttpContext.User.Identities.First();
return new();
} }
[HttpPut("{userId}/adminStatus")] [HttpGet]
[Produces("application/json")]
public async Task<UserProfileGet> GetUserProfileSelf()
{
return await _userProfileService.GetUserProfileSelfAsync(User);
}
[HttpPut("{userId}")]
[Authorize(Policy = "AdminOnly")] [Authorize(Policy = "AdminOnly")]
[Produces("application/json")] [Produces("application/json")]
public async Task<IActionResult> PutUserAdminStatus(string userId, [FromBody] ChangeAdminStatusBody body) public async Task<UserProfileGet> PutUserProfile(string userId, [FromBody] UserProfilePut userProfile)
{ {
var changedProfile = await _userProfileService.PutUserProfileAdminStatusAsync(userId, body.ChangeStatusTo); return await _userProfileService.PutUserProfileAsync(User, userProfile, userId);
}
return Ok(changedProfile); [HttpGet("{userId}")]
[Authorize(Policy = "AdminOnly")]
[Produces("application/json")]
public async Task<UserProfileGet> GetUserProfile(string userId)
{
return await _userProfileService.GetUserProfileAsync(User, userId);
} }
} }

View file

@ -20,6 +20,5 @@ public class DbUserProfile : DbEntityWithHistory
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
public Guid ApplicationUserId { get; set; } public Guid ApplicationUserId { get; set; }
public virtual DbApplicationUser ApplicationUser { get; set; } public virtual DbApplicationUser ApplicationUser { get; set; }
} }

View file

@ -0,0 +1,52 @@
using System.Net;
using Microsoft.AspNetCore.Http.HttpResults;
namespace DrinkRateAPI.Exceptions;
public record ExceptionResponse(int StatusCode, string Description);
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
_logger.LogError(exception, "An unexpected error occurred.");
var response = exception switch
{
BadRequestException _ => new ExceptionResponse(StatusCodes.Status400BadRequest, "Application exception occurred."),
NotFoundException _ => new ExceptionResponse(StatusCodes.Status404NotFound, "The request key not found."),
UnauthenticatedException _ => new ExceptionResponse(StatusCodes.Status401Unauthorized, "Unauthorized."),
PaymentRequiredException _ => new ExceptionResponse(StatusCodes.Status402PaymentRequired, "Payment required."),
ForbiddenException _ => new ExceptionResponse(StatusCodes.Status403Forbidden, "Forbidden."),
IamATeapotException _ => new ExceptionResponse(StatusCodes.Status418ImATeapot, "I am a teapot."),
UnavailableForLagalReasonsException _ => new ExceptionResponse(StatusCodes.Status451UnavailableForLegalReasons, "Unavailable for legal reasons."),
_ => new ExceptionResponse(StatusCodes.Status500InternalServerError, "Internal server error. Please retry later.")
};
context.Response.ContentType = "application/json";
context.Response.StatusCode = response.StatusCode;
await context.Response.WriteAsJsonAsync(response);
}
}

View file

@ -1,7 +1,70 @@
namespace DrinkRateAPI.Exceptions; namespace DrinkRateAPI.Exceptions;
public class DrinkRateException : Exception; public class DrinkRateException : Exception
{
public DrinkRateException() : base() { }
public DrinkRateException(string message) : base(message) { }
}
public class NotFoundException : DrinkRateException; /// <summary>
/// 400 - Bad request
/// </summary>
public class BadRequestException : DrinkRateException
{
public BadRequestException() : base() { }
public BadRequestException(string message) : base(message) { }
}
public class UnauthorizedException : DrinkRateException; /// <summary>
/// 401 - Unauthenticated
/// </summary>
public class UnauthenticatedException : DrinkRateException
{
public UnauthenticatedException() : base() { }
public UnauthenticatedException(string message) : base(message) { }
}
/// <summary>
/// 402 - Payment required
/// </summary>
public class PaymentRequiredException : DrinkRateException
{
public PaymentRequiredException() : base() { }
public PaymentRequiredException(string message) : base(message) { }
}
/// <summary>
/// 403 - Forbidden
/// </summary>
public class ForbiddenException : DrinkRateException
{
public ForbiddenException() : base() { }
public ForbiddenException(string message) : base(message) { }
}
/// <summary>
/// 404 - Not found
/// </summary>
public class NotFoundException : DrinkRateException
{
public NotFoundException() : base() { }
public NotFoundException(string message) : base(message) { }
}
/// <summary>
/// 418 - I'm a teapot
/// </summary>
public class IamATeapotException : DrinkRateException
{
public IamATeapotException() : base() { }
public IamATeapotException(string message) : base(message) { }
}
/// <summary>
/// 451 - Unavailable for lagal reasons
/// </summary>
public class UnavailableForLagalReasonsException : DrinkRateException
{
public UnavailableForLagalReasonsException() : base() { }
public UnavailableForLagalReasonsException(string message) : base(message) { }
}

View file

@ -1,6 +1,7 @@
using DrinkRateAPI.AuthorizationPolicies; using DrinkRateAPI.AuthorizationPolicies;
using DrinkRateAPI.Contexts; using DrinkRateAPI.Contexts;
using DrinkRateAPI.DbEntities; using DrinkRateAPI.DbEntities;
using DrinkRateAPI.Exceptions;
using DrinkRateAPI.Services; using DrinkRateAPI.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -70,6 +71,8 @@ if (app.Environment.IsDevelopment())
app.MapIdentityApi<DbApplicationUser>(); app.MapIdentityApi<DbApplicationUser>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthorization(); app.UseAuthorization();

View file

@ -13,7 +13,7 @@ public class ApplicationUserService(ApplicationDbContext context)
{ {
var appUserId = identity.FindFirst(ClaimTypes.NameIdentifier)?.Value; var appUserId = identity.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var profile = await _context.UserProfiles var profile = await _context.UserProfiles
.FirstAsync(x => x.ApplicationUserId.ToString() == appUserId) .FirstOrDefaultAsync(x => x.ApplicationUserId.ToString() == appUserId && !x.IsDeleted)
?? throw new NotFoundException(); ?? throw new NotFoundException();
return profile; return profile;

View file

@ -2,12 +2,8 @@ using System.Security.Claims;
using DrinkRateAPI.ApiModels.UserProfile; using DrinkRateAPI.ApiModels.UserProfile;
using DrinkRateAPI.Contexts; using DrinkRateAPI.Contexts;
using DrinkRateAPI.DbEntities; using DrinkRateAPI.DbEntities;
using DrinkRateAPI.DbEntities;
using DrinkRateAPI.Exceptions; using DrinkRateAPI.Exceptions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DrinkRateAPI.Services; namespace DrinkRateAPI.Services;
@ -21,29 +17,102 @@ public class UserProfileService(ApplicationDbContext context, ApplicationUserSer
return userProfile.IsAdmin; return userProfile.IsAdmin;
} }
public async Task<DbUserProfile> PutUserProfileAdminStatusAsync(string userId, bool changeStatusTo) public async Task<UserProfileGet> PutUserProfileSelfAsync(ClaimsPrincipal identity, UserProfileSelfPut userProfileSelfPut)
{ {
var userProfile = GetUserProfileById(userId); var authenticatedUser = await _applicationUserService.UserProfileByApplicationUserAsync(identity);
userProfile.IsAdmin = changeStatusTo;
var userId = authenticatedUser.Id.ToString();
await PutUserProfile(userProfileSelfPut, userId, false);
return await GetUserProfile(userId);
}
public async Task<UserProfileGet> GetUserProfileSelfAsync(ClaimsPrincipal identity)
{
var authenticatedUser = await _applicationUserService.UserProfileByApplicationUserAsync(identity);
var userId = authenticatedUser.Id.ToString();
return await GetUserProfile(userId);
}
public async Task<UserProfileGet> PutUserProfileAsync(ClaimsPrincipal identity, UserProfilePut userProfilePut, string userId)
{
var authenticatedUser = await _applicationUserService.UserProfileByApplicationUserAsync(identity);
if (authenticatedUser.Id.ToString() == userId)
{
// Prevent admin de-admining him/herself
await PutUserProfile(userProfilePut, userId, false);
}
else
{
await PutUserProfile(userProfilePut, userId, IsUserProfileAdmin(authenticatedUser));
}
return await GetUserProfile(userId);
}
public async Task<UserProfileGet> GetUserProfileAsync(ClaimsPrincipal identity, string userId)
{
var authenticatedUser = await _applicationUserService.UserProfileByApplicationUserAsync(identity);
return await GetUserProfile(userId);
}
private async Task PutUserProfile<TUserProfilePut>(TUserProfilePut userProfilePut, string userId, bool byAdmin) where TUserProfilePut : UserProfileSelfPut
{
var userProfile = await GetUserProfileById(userId);
if (!string.IsNullOrEmpty(userProfilePut.UserName) && userProfile.UserName != userProfilePut.UserName)
{
var userByName = await TryGetUserProfileByUserName(userProfilePut.UserName);
if (userByName == null)
{
userProfile.UserName = userProfilePut.UserName;
}
else
{
throw new BadRequestException($"User with username {userProfilePut.UserName} already exists");
}
}
if (byAdmin && userProfilePut is UserProfilePut adminPut && adminPut.IsAdmin != null)
{
userProfile.IsAdmin = (bool)adminPut.IsAdmin;
}
_context.UserProfiles.Update(userProfile); _context.UserProfiles.Update(userProfile);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return userProfile;
}
public async Task<UserProfileGet> PutUserProfileAsync(UserProfilePut userProfile, ClaimsPrincipal identity)
{
var profile = _applicationUserService.UserProfileByApplicationUserAsync(identity);
return new();
} }
public DbUserProfile GetUserProfileById(string userId) private async Task<UserProfileGet> GetUserProfile(string userId)
{ {
var userProfile = _context.UserProfiles.FirstOrDefault(x => x.Id.ToString() == userId); var userProfile = await GetUserProfileById(userId);
var userProfileGet = new UserProfileGet
{
Id = userProfile.Id.ToString(),
UserName = userProfile.UserName,
IsAdmin = userProfile.IsAdmin,
IsDeleted = userProfile.IsDeleted,
ApplicationUserId = userProfile.ApplicationUserId.ToString(),
};
return userProfileGet;
}
private async Task<DbUserProfile> GetUserProfileById(string userId)
{
var userProfile = await _context.UserProfiles.FirstOrDefaultAsync(x => x.Id.ToString() == userId);
return userProfile ?? throw new NotFoundException(); return userProfile ?? throw new NotFoundException();
} }
private async Task<DbUserProfile?> TryGetUserProfileByUserName(string userName)
{
var userProfile = await _context.UserProfiles.FirstOrDefaultAsync(x => x.UserName == userName);
return userProfile;
}
} }