diff --git a/DrinkRateAPI/ApiModels/UserProfile/UserProfileGet.cs b/DrinkRateAPI/ApiModels/UserProfile/UserProfileGet.cs new file mode 100644 index 0000000..5f21fec --- /dev/null +++ b/DrinkRateAPI/ApiModels/UserProfile/UserProfileGet.cs @@ -0,0 +1,29 @@ +namespace DrinkRateAPI.ApiModels.UserProfile; + +public class UserProfileGet +{ + /// + /// User profile ID + /// + public string Id { get; set; } + + /// + /// User profile name + /// + public string UserName { get; set; } + + /// + /// Is user admin + /// + public bool IsAdmin { get; set; } + + /// + /// Is user deleted + /// + public bool IsDeleted { get; set; } + + /// + /// Applicaton user ID of the user profile + /// + public string ApplicationUserId { get; set; } +} \ No newline at end of file diff --git a/DrinkRateAPI/ApiModels/UserProfile/UserProfilePut.cs b/DrinkRateAPI/ApiModels/UserProfile/UserProfilePut.cs new file mode 100644 index 0000000..ecf2fd4 --- /dev/null +++ b/DrinkRateAPI/ApiModels/UserProfile/UserProfilePut.cs @@ -0,0 +1,9 @@ +namespace DrinkRateAPI.ApiModels.UserProfile; + +public class UserProfilePut : UserProfileSelfPut +{ + /// + /// Is user admin + /// + public bool? IsAdmin { get; set; } +} \ No newline at end of file diff --git a/DrinkRateAPI/ApiModels/UserProfile/UserProfileSelfPut.cs b/DrinkRateAPI/ApiModels/UserProfile/UserProfileSelfPut.cs new file mode 100644 index 0000000..8ca7d1b --- /dev/null +++ b/DrinkRateAPI/ApiModels/UserProfile/UserProfileSelfPut.cs @@ -0,0 +1,9 @@ +namespace DrinkRateAPI.ApiModels.UserProfile; + +public class UserProfileSelfPut +{ + /// + /// User profile name + /// + public string? UserName { get; set; } +} \ No newline at end of file diff --git a/DrinkRateAPI/AuthorizationPolicies/AdminOnlyRequirement.cs b/DrinkRateAPI/AuthorizationPolicies/AdminOnlyRequirement.cs new file mode 100644 index 0000000..f850568 --- /dev/null +++ b/DrinkRateAPI/AuthorizationPolicies/AdminOnlyRequirement.cs @@ -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 +{ + 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); + } + } +} \ No newline at end of file diff --git a/DrinkRateAPI/Controllers/UserProfileController.cs b/DrinkRateAPI/Controllers/UserProfileController.cs new file mode 100644 index 0000000..eb527aa --- /dev/null +++ b/DrinkRateAPI/Controllers/UserProfileController.cs @@ -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 PutUserProfileSelf([FromBody] UserProfileSelfPut userProfile) + { + return await _userProfileService.PutUserProfileSelfAsync(User, userProfile); + } + + [HttpGet] + [Produces("application/json")] + public async Task GetUserProfileSelf() + { + return await _userProfileService.GetUserProfileSelfAsync(User); + } + + [HttpPut("{userId}")] + [Authorize(Policy = "AdminOnly")] + [Produces("application/json")] + public async Task PutUserProfile(string userId, [FromBody] UserProfilePut userProfile) + { + return await _userProfileService.PutUserProfileAsync(User, userProfile, userId); + } + + [HttpGet("{userId}")] + [Authorize(Policy = "AdminOnly")] + [Produces("application/json")] + public async Task GetUserProfile(string userId) + { + return await _userProfileService.GetUserProfileAsync(User, userId); + } +} \ No newline at end of file diff --git a/DrinkRateAPI/DbEntities/DbUserProfile.cs b/DrinkRateAPI/DbEntities/DbUserProfile.cs index bde7fa5..55fa050 100644 --- a/DrinkRateAPI/DbEntities/DbUserProfile.cs +++ b/DrinkRateAPI/DbEntities/DbUserProfile.cs @@ -20,6 +20,5 @@ public class DbUserProfile : DbEntityWithHistory public bool IsDeleted { get; set; } public Guid ApplicationUserId { get; set; } - public virtual DbApplicationUser ApplicationUser { get; set; } } \ No newline at end of file diff --git a/DrinkRateAPI/DrinkRateAPI.csproj b/DrinkRateAPI/DrinkRateAPI.csproj index dacaac5..7cdbba1 100644 --- a/DrinkRateAPI/DrinkRateAPI.csproj +++ b/DrinkRateAPI/DrinkRateAPI.csproj @@ -20,6 +20,7 @@ + diff --git a/DrinkRateAPI/Exceptions/ExceptionHandlingMiddleware.cs b/DrinkRateAPI/Exceptions/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..82fb158 --- /dev/null +++ b/DrinkRateAPI/Exceptions/ExceptionHandlingMiddleware.cs @@ -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 _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger 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); + } +} \ No newline at end of file diff --git a/DrinkRateAPI/Exceptions/Exceptions.cs b/DrinkRateAPI/Exceptions/Exceptions.cs new file mode 100644 index 0000000..0a76e7e --- /dev/null +++ b/DrinkRateAPI/Exceptions/Exceptions.cs @@ -0,0 +1,70 @@ +namespace DrinkRateAPI.Exceptions; + +public class DrinkRateException : Exception +{ + public DrinkRateException() : base() { } + public DrinkRateException(string message) : base(message) { } +} + +/// +/// 400 - Bad request +/// +public class BadRequestException : DrinkRateException +{ + public BadRequestException() : base() { } + public BadRequestException(string message) : base(message) { } +} + +/// +/// 401 - Unauthenticated +/// +public class UnauthenticatedException : DrinkRateException +{ + public UnauthenticatedException() : base() { } + public UnauthenticatedException(string message) : base(message) { } +} + +/// +/// 402 - Payment required +/// +public class PaymentRequiredException : DrinkRateException +{ + public PaymentRequiredException() : base() { } + public PaymentRequiredException(string message) : base(message) { } +} + +/// +/// 403 - Forbidden +/// +public class ForbiddenException : DrinkRateException +{ + public ForbiddenException() : base() { } + public ForbiddenException(string message) : base(message) { } +} + +/// +/// 404 - Not found +/// +public class NotFoundException : DrinkRateException +{ + public NotFoundException() : base() { } + public NotFoundException(string message) : base(message) { } +} + +/// +/// 418 - I'm a teapot +/// +public class IamATeapotException : DrinkRateException +{ + public IamATeapotException() : base() { } + public IamATeapotException(string message) : base(message) { } +} + +/// +/// 451 - Unavailable for lagal reasons +/// +public class UnavailableForLagalReasonsException : DrinkRateException +{ + public UnavailableForLagalReasonsException() : base() { } + public UnavailableForLagalReasonsException(string message) : base(message) { } +} \ No newline at end of file diff --git a/DrinkRateAPI/Program.cs b/DrinkRateAPI/Program.cs index 1faf6d8..9da7e7d 100644 --- a/DrinkRateAPI/Program.cs +++ b/DrinkRateAPI/Program.cs @@ -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() .AddEntityFrameworkStores(); builder.Services.AddScoped, UserWithProfileManager>(); +builder.Services.AddScoped(); 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() + new List() } }); }); builder.Services.AddDbContext(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -63,10 +71,12 @@ if (app.Environment.IsDevelopment()) app.MapIdentityApi(); +app.UseMiddleware(); + app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/DrinkRateAPI/Services/ApplicationUserService.cs b/DrinkRateAPI/Services/ApplicationUserService.cs new file mode 100644 index 0000000..4eb4190 --- /dev/null +++ b/DrinkRateAPI/Services/ApplicationUserService.cs @@ -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 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; + } +} \ No newline at end of file diff --git a/DrinkRateAPI/Services/UserProfileService.cs b/DrinkRateAPI/Services/UserProfileService.cs new file mode 100644 index 0000000..ad8c03d --- /dev/null +++ b/DrinkRateAPI/Services/UserProfileService.cs @@ -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 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 GetUserProfileSelfAsync(ClaimsPrincipal identity) + { + var authenticatedUser = await _applicationUserService.UserProfileByApplicationUserAsync(identity); + + var userId = authenticatedUser.Id.ToString(); + + return await GetUserProfile(userId); + } + + public async Task 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 GetUserProfileAsync(ClaimsPrincipal identity, string userId) + { + var authenticatedUser = await _applicationUserService.UserProfileByApplicationUserAsync(identity); + + return await GetUserProfile(userId); + } + + private async Task PutUserProfile(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 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 GetUserProfileById(string userId) + { + var userProfile = await _context.UserProfiles.FirstOrDefaultAsync(x => x.Id.ToString() == userId); + + return userProfile ?? throw new NotFoundException(); + } + + private async Task TryGetUserProfileByUserName(string userName) + { + var userProfile = await _context.UserProfiles.FirstOrDefaultAsync(x => x.UserName == userName); + + return userProfile; + } +} \ No newline at end of file