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