Merge pull request 'User profile service' (#2) from 250809_UserProfile into main

Reviewed-on: #2
This commit is contained in:
Jiri 2025-08-11 20:10:49 +00:00
commit de8d07ee02
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,6 +71,8 @@ if (app.Environment.IsDevelopment())
app.MapIdentityApi<DbApplicationUser>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthorization();

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;
}
}