Compare commits

...

17 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
Martin Velebil
a246764e44 Movesadmin status update endpoint
Relocates the endpoint for updating user admin status from the dedicated AdminController to the UserProfileController.

This consolidates user profile management under a single controller
and leverages existing authorization policies.
2025-08-10 18:33:36 +02:00
Martin Velebil
3230a5ed0f Merge remote-tracking branch 'origin/250809_UserProfile' into 250810_UserProfileAdmin
# Conflicts:
#	DrinkRateAPI/Services/UserProfileService.cs
2025-08-10 18:18:06 +02:00
b59fef222f Implement admin-only authorization policy
Adds an authorization policy to restrict access to admin-only endpoints.
Creates an `AdminOnlyRequirement` and `AdminOnlyHandler` to check if a user has admin privileges.
Applies the "AdminOnly" policy to the AdminController to secure admin functionalities.
Modifies the endpoint for changing user admin status to include the user ID in the route.
2025-08-10 18:07:34 +02:00
Jiří Vrabec
dad144a80f Create user profile service base 2025-08-10 16:35:36 +02:00
b2b8d1e076 Refactor admin endpoint and logic
Moves the ChangeUserAdminStatusRequest to the ApiModels folder.
Updates the admin controller route to "admin" and the admin status
endpoint to "adminStatus".
Makes the ChangeUserAdminStatus method asynchronous.
Uses NotFoundException instead of KeyNotFoundException.
2025-08-10 16:23:35 +02:00
c0860b05d1 Enable admin status management
Adds functionality to allow administrators to modify the admin status of other users.

Introduces an endpoint for changing user admin status, accessible only to existing administrators.
This change includes necessary services and request models to handle the logic.
2025-08-10 13:55:20 +02:00
Jiří Vrabec
76cb56d819 Add user authorization service 2025-08-10 11:20:18 +02:00
12 changed files with 416 additions and 14 deletions

View file

@ -0,0 +1,29 @@
namespace DrinkRateAPI.ApiModels.UserProfile;
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

@ -0,0 +1,9 @@
namespace DrinkRateAPI.ApiModels.UserProfile;
public class UserProfilePut : UserProfileSelfPut
{
/// <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

@ -0,0 +1,36 @@
using DrinkRateAPI.DbEntities;
using DrinkRateAPI.Services;
namespace DrinkRateAPI.AuthorizationPolicies;
using Microsoft.AspNetCore.Authorization;
public class AdminOnlyRequirement : IAuthorizationRequirement
{
}
public class AdminOnlyHandler : AuthorizationHandler<AdminOnlyRequirement>
{
private readonly ApplicationUserService _applicationUserService;
private readonly UserProfileService _userProfileService;
public AdminOnlyHandler(
ApplicationUserService applicationUserService,
UserProfileService userProfileService)
{
_applicationUserService = applicationUserService;
_userProfileService = userProfileService;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AdminOnlyRequirement requirement)
{
var userProfile = await _applicationUserService.UserProfileByApplicationUserAsync(context.User);
if (_userProfileService.IsUserProfileAdmin(userProfile))
{
context.Succeed(requirement);
}
}
}

View file

@ -0,0 +1,48 @@
using DrinkRateAPI.ApiModels.UserProfile;
using DrinkRateAPI.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DrinkRateAPI.Controllers;
[ApiController]
[Route("userProfile")]
public class UserProfileController : ControllerBase
{
private readonly UserProfileService _userProfileService;
public UserProfileController(UserProfileService userProfileService)
{
_userProfileService = userProfileService;
}
[HttpPut]
[Produces("application/json")]
public async Task<UserProfileGet> PutUserProfileSelf([FromBody] UserProfileSelfPut userProfile)
{
return await _userProfileService.PutUserProfileSelfAsync(User, userProfile);
}
[HttpGet]
[Produces("application/json")]
public async Task<UserProfileGet> GetUserProfileSelf()
{
return await _userProfileService.GetUserProfileSelfAsync(User);
}
[HttpPut("{userId}")]
[Authorize(Policy = "AdminOnly")]
[Produces("application/json")]
public async Task<UserProfileGet> PutUserProfile(string userId, [FromBody] UserProfilePut userProfile)
{
return await _userProfileService.PutUserProfileAsync(User, userProfile, userId);
}
[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 Guid ApplicationUserId { get; set; }
public virtual DbApplicationUser ApplicationUser { get; set; }
}

View file

@ -20,6 +20,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="ApiModels\" />
<Folder Include="Migrations\" />
</ItemGroup>

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

@ -0,0 +1,70 @@
namespace DrinkRateAPI.Exceptions;
public class DrinkRateException : Exception
{
public DrinkRateException() : base() { }
public DrinkRateException(string message) : base(message) { }
}
/// <summary>
/// 400 - Bad request
/// </summary>
public class BadRequestException : DrinkRateException
{
public BadRequestException() : base() { }
public BadRequestException(string message) : base(message) { }
}
/// <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,5 +1,9 @@
using DrinkRateAPI.AuthorizationPolicies;
using DrinkRateAPI.Contexts;
using DrinkRateAPI.DbEntities;
using DrinkRateAPI.Exceptions;
using DrinkRateAPI.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.OpenApi.Models;
@ -10,10 +14,13 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AdminOnly", policy =>
policy.Requirements.Add(new AdminOnlyRequirement()));
builder.Services.AddIdentityApiEndpoints<DbApplicationUser>()
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddScoped<UserManager<DbApplicationUser>, UserWithProfileManager>();
builder.Services.AddScoped<IAuthorizationHandler, AdminOnlyHandler>();
builder.Services.AddSwaggerGen(c =>
{
@ -31,25 +38,26 @@ builder.Services.AddSwaggerGen(c =>
c.AddSecurityRequirement(new OpenApiSecurityRequirement()
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
new OpenApiSecurityScheme
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
new List<string>()
new List<string>()
}
});
});
builder.Services.AddDbContext<ApplicationDbContext>();
builder.Services.AddScoped<ApplicationUserService>();
builder.Services.AddScoped<UserProfileService>();
var app = builder.Build();
@ -63,10 +71,12 @@ if (app.Environment.IsDevelopment())
app.MapIdentityApi<DbApplicationUser>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
app.Run();

View file

@ -0,0 +1,21 @@
using System.Security.Claims;
using DrinkRateAPI.Contexts;
using DrinkRateAPI.DbEntities;
using DrinkRateAPI.Exceptions;
using Microsoft.EntityFrameworkCore;
namespace DrinkRateAPI.Services;
public class ApplicationUserService(ApplicationDbContext context)
{
private ApplicationDbContext _context = context;
public async Task<DbUserProfile> UserProfileByApplicationUserAsync(ClaimsPrincipal identity)
{
var appUserId = identity.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var profile = await _context.UserProfiles
.FirstOrDefaultAsync(x => x.ApplicationUserId.ToString() == appUserId && !x.IsDeleted)
?? throw new NotFoundException();
return profile;
}
}

View file

@ -0,0 +1,118 @@
using System.Security.Claims;
using DrinkRateAPI.ApiModels.UserProfile;
using DrinkRateAPI.Contexts;
using DrinkRateAPI.DbEntities;
using DrinkRateAPI.Exceptions;
using Microsoft.EntityFrameworkCore;
namespace DrinkRateAPI.Services;
public class UserProfileService(ApplicationDbContext context, ApplicationUserService applicationUserService)
{
private ApplicationDbContext _context = context;
private ApplicationUserService _applicationUserService = applicationUserService;
public bool IsUserProfileAdmin(DbUserProfile userProfile)
{
return userProfile.IsAdmin;
}
public async Task<UserProfileGet> PutUserProfileSelfAsync(ClaimsPrincipal identity, UserProfileSelfPut userProfileSelfPut)
{
var authenticatedUser = await _applicationUserService.UserProfileByApplicationUserAsync(identity);
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);
await _context.SaveChangesAsync();
}
private async Task<UserProfileGet> GetUserProfile(string 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();
}
private async Task<DbUserProfile?> TryGetUserProfileByUserName(string userName)
{
var userProfile = await _context.UserProfiles.FirstOrDefaultAsync(x => x.UserName == userName);
return userProfile;
}
}