From b36aaff112cbf8e245dff11f74b8e64d80e32986 Mon Sep 17 00:00:00 2001 From: Alexander Konietzko Date: Fri, 17 Mar 2023 08:44:37 +0100 Subject: [PATCH] Add User auth --- .../CleanArchitecture.Api.csproj | 2 + .../Controllers/UserController.cs | 11 +++-- CleanArchitecture.Api/Program.cs | 36 +++++++++++++++ .../appsettings.Development.json | 8 ++++ CleanArchitecture.Api/appsettings.json | 5 ++ .../viewmodels/Users/CreateUserViewModel.cs | 3 +- .../viewmodels/Users/UserViewModel.cs | 5 +- CleanArchitecture.Domain/ApiUser.cs | 46 +++++++++++++++++++ .../CleanArchitecture.Domain.csproj | 2 + .../Users/CreateUser/CreateUserCommand.cs | 7 ++- .../CreateUser/CreateUserCommandHandler.cs | 8 +++- .../DeleteUser/DeleteUserCommandHandler.cs | 13 +++++- .../Users/UpdateUser/UpdateUserCommand.cs | 8 +++- .../UpdateUser/UpdateUserCommandHandler.cs | 23 ++++++++-- CleanArchitecture.Domain/Entities/User.cs | 35 ++++++++++++-- CleanArchitecture.Domain/Enums/UserRole.cs | 7 +++ .../Extensions/ServiceCollectionExtension.cs | 9 ++++ CleanArchitecture.Domain/Interfaces/IUser.cs | 12 +++++ 18 files changed, 221 insertions(+), 19 deletions(-) create mode 100644 CleanArchitecture.Domain/ApiUser.cs create mode 100644 CleanArchitecture.Domain/Enums/UserRole.cs create mode 100644 CleanArchitecture.Domain/Interfaces/IUser.cs diff --git a/CleanArchitecture.Api/CleanArchitecture.Api.csproj b/CleanArchitecture.Api/CleanArchitecture.Api.csproj index 66b0cf6..7b9dbb2 100644 --- a/CleanArchitecture.Api/CleanArchitecture.Api.csproj +++ b/CleanArchitecture.Api/CleanArchitecture.Api.csproj @@ -3,9 +3,11 @@ net7.0 enable + 64377c40-44d6-4989-9662-5d778f8b3b92 + diff --git a/CleanArchitecture.Api/Controllers/UserController.cs b/CleanArchitecture.Api/Controllers/UserController.cs index 5d6cfc6..ff9aa64 100644 --- a/CleanArchitecture.Api/Controllers/UserController.cs +++ b/CleanArchitecture.Api/Controllers/UserController.cs @@ -4,6 +4,7 @@ using CleanArchitecture.Application.Interfaces; using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Domain.Notifications; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace CleanArchitecture.Api.Controllers; @@ -21,13 +22,15 @@ public class UserController : ApiController _userService = userService; } + [Authorize] [HttpGet] public async Task GetAllUsersAsync() { var users = await _userService.GetAllUsersAsync(); return Response(users); } - + + [Authorize] [HttpGet("{id}")] public async Task GetUserByIdAsync( [FromRoute] Guid id, @@ -43,14 +46,16 @@ public class UserController : ApiController var userId = await _userService.CreateUserAsync(viewModel); return Response(userId); } - + + [Authorize] [HttpDelete("{id}")] public async Task DeleteUserAsync([FromRoute] Guid id) { await _userService.DeleteUserAsync(id); return Response(id); } - + + [Authorize] [HttpPut] public async Task UpdateUserAsync([FromBody] UpdateUserViewModel viewModel) { diff --git a/CleanArchitecture.Api/Program.cs b/CleanArchitecture.Api/Program.cs index 4aee2bc..87dc4ee 100644 --- a/CleanArchitecture.Api/Program.cs +++ b/CleanArchitecture.Api/Program.cs @@ -1,12 +1,16 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text; using CleanArchitecture.Application.Extensions; using CleanArchitecture.Domain.Extensions; using CleanArchitecture.gRPC; using CleanArchitecture.Infrastructure.Database; using CleanArchitecture.Infrastructure.Extensions; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -24,11 +28,23 @@ builder.Services.AddDbContext(options => b => b.MigrationsAssembly("CleanArchitecture.Infrastructure")); }); +builder.Services.AddAuthentication( + options => + { + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer( + jwtOptions => + { + jwtOptions.TokenValidationParameters = CreateTokenValidationParameters(); + }); + builder.Services.AddInfrastructure(); builder.Services.AddQueryHandlers(); builder.Services.AddServices(); builder.Services.AddCommandHandlers(); builder.Services.AddNotificationHandlers(); +builder.Services.AddApiUser(); builder.Services.AddMediatR(cfg => { @@ -42,6 +58,7 @@ app.UseSwaggerUI(); app.UseHttpsRedirection(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); @@ -57,5 +74,24 @@ using (IServiceScope scope = app.Services.CreateScope()) app.Run(); +TokenValidationParameters CreateTokenValidationParameters() +{ + var result = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Auth:Issuer"], + ValidAudience = builder.Configuration["Auth:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes( + builder.Configuration["Auth:Secret"]!)), + RequireSignedTokens = false + }; + + return result; +} + // Needed for integration tests webapplication factory public partial class Program { } \ No newline at end of file diff --git a/CleanArchitecture.Api/appsettings.Development.json b/CleanArchitecture.Api/appsettings.Development.json index 0c208ae..c183fbb 100644 --- a/CleanArchitecture.Api/appsettings.Development.json +++ b/CleanArchitecture.Api/appsettings.Development.json @@ -4,5 +4,13 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=clean-architecture;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "Auth": { + "Issuer": "CleanArchitectureServer", + "Audience": "CleanArchitectureClient", + "Secret": "sD3v061gf8BxXgmxcHss" } } diff --git a/CleanArchitecture.Api/appsettings.json b/CleanArchitecture.Api/appsettings.json index 26b0156..c481317 100644 --- a/CleanArchitecture.Api/appsettings.json +++ b/CleanArchitecture.Api/appsettings.json @@ -8,5 +8,10 @@ "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=clean-architecture;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "Auth": { + "Issuer": "CleanArchitectureServer", + "Audience": "CleanArchitectureClient", + "Secret": "sD3v061gf8BxXgmxcHss" } } diff --git a/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs b/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs index c1a01d4..3efbb36 100644 --- a/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs +++ b/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs @@ -3,4 +3,5 @@ namespace CleanArchitecture.Application.ViewModels.Users; public sealed record CreateUserViewModel( string Email, string Surname, - string GivenName); \ No newline at end of file + string GivenName, + string Password); \ No newline at end of file diff --git a/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs b/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs index 9ecc82f..cc0ac67 100644 --- a/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs +++ b/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs @@ -1,5 +1,6 @@ using System; using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.Application.ViewModels.Users; @@ -9,6 +10,7 @@ public sealed class UserViewModel public string Email { get; set; } = string.Empty; public string GivenName { get; set; } = string.Empty; public string Surname { get; set; } = string.Empty; + public UserRole Role { get; set; } public static UserViewModel FromUser(User user) { @@ -17,7 +19,8 @@ public sealed class UserViewModel Id = user.Id, Email = user.Email, GivenName = user.GivenName, - Surname = user.Surname + Surname = user.Surname, + Role = user.Role }; } } diff --git a/CleanArchitecture.Domain/ApiUser.cs b/CleanArchitecture.Domain/ApiUser.cs new file mode 100644 index 0000000..705bd4e --- /dev/null +++ b/CleanArchitecture.Domain/ApiUser.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using System.Security.Claims; +using CleanArchitecture.Domain.Enums; +using CleanArchitecture.Domain.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace CleanArchitecture.Domain; + +public sealed class ApiUser : IUser +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public ApiUser(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Guid GetUserId() + { + var claim = _httpContextAccessor.HttpContext?.User.Claims + .FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.NameIdentifier)); + + if (Guid.TryParse(claim?.Value, out var userId)) + { + return userId; + } + + throw new ArgumentException("Could not parse user id to guid"); + } + + public UserRole GetUserRole() + { + var claim = _httpContextAccessor.HttpContext?.User.Claims + .FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.Role)); + + if (Enum.TryParse(claim?.Value, out UserRole userRole)) + { + return userRole; + } + + throw new ArgumentException("Could not parse user role"); + } + + public string Name => _httpContextAccessor.HttpContext?.User.Identity?.Name ?? string.Empty; +} diff --git a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj index 8c0626a..496b495 100644 --- a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj +++ b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj @@ -6,8 +6,10 @@ + + diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs index 6106289..7185a16 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs @@ -10,17 +10,20 @@ public sealed class CreateUserCommand : CommandBase public string Email { get; } public string Surname { get; } public string GivenName { get; } - + public string Password { get; } + public CreateUserCommand( Guid userId, string email, string surname, - string givenName) : base(userId) + string givenName, + string password) : base(userId) { UserId = userId; Email = email; Surname = surname; GivenName = givenName; + Password = password; } public override bool IsValid() diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs index 0ebbd00..234364c 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs @@ -1,12 +1,14 @@ using System.Threading; using System.Threading.Tasks; using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Notifications; using MediatR; +using BC = BCrypt.Net.BCrypt; namespace CleanArchitecture.Domain.Commands.Users.CreateUser; @@ -43,11 +45,15 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase, return; } + var passwordHash = BC.HashPassword(request.Password); + var user = new User( request.UserId, request.Email, request.Surname, - request.GivenName); + request.GivenName, + passwordHash, + UserRole.User); _userRepository.Add(user); diff --git a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs index edf9b0c..e912010 100644 --- a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Interfaces; @@ -13,14 +14,17 @@ public sealed class DeleteUserCommandHandler : CommandHandlerBase, IRequestHandler { private readonly IUserRepository _userRepository; - + private readonly IUser _user; + public DeleteUserCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler notifications, - IUserRepository userRepository) : base(bus, unitOfWork, notifications) + IUserRepository userRepository, + IUser user) : base(bus, unitOfWork, notifications) { _userRepository = userRepository; + _user = user; } public async Task Handle(DeleteUserCommand request, CancellationToken cancellationToken) @@ -43,6 +47,11 @@ public sealed class DeleteUserCommandHandler : CommandHandlerBase, return; } + if (_user.GetUserId() != request.UserId || _user.GetUserRole() != UserRole.Admin) + { + return; + } + _userRepository.Remove(user); if (await CommitAsync()) diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs index 44e3617..d561e47 100644 --- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs @@ -1,4 +1,5 @@ using System; +using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.Domain.Commands.Users.UpdateUser; @@ -10,17 +11,20 @@ public sealed class UpdateUserCommand : CommandBase public string Email { get; } public string Surname { get; } public string GivenName { get; } - + public UserRole Role { get; } + public UpdateUserCommand( Guid userId, string email, string surname, - string givenName) : base(userId) + string givenName, + UserRole role) : base(userId) { UserId = userId; Email = email; Surname = surname; GivenName = givenName; + Role = role; } public override bool IsValid() diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs index a94c5b5..99c6820 100644 --- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -1,5 +1,7 @@ using System.Threading; using System.Threading.Tasks; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Interfaces; @@ -13,14 +15,17 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase, IRequestHandler { private readonly IUserRepository _userRepository; - + private readonly IUser _user; + public UpdateUserCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler notifications, - IUserRepository userRepository) : base(bus, unitOfWork, notifications) + IUserRepository userRepository, + IUser user) : base(bus, unitOfWork, notifications) { _userRepository = userRepository; + _user = user; } public async Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) @@ -41,11 +46,21 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase, ErrorCodes.ObjectNotFound)); return; } - + + if (_user.GetUserId() != request.UserId || _user.GetUserRole() != UserRole.Admin) + { + return; + } + + if (_user.GetUserRole() == UserRole.Admin) + { + user.SetRole(request.Role); + } + user.SetEmail(request.Email); user.SetSurname(request.Surname); user.SetGivenName(request.GivenName); - + _userRepository.Update(user); if (await CommitAsync()) diff --git a/CleanArchitecture.Domain/Entities/User.cs b/CleanArchitecture.Domain/Entities/User.cs index e2c56e9..2d14858 100644 --- a/CleanArchitecture.Domain/Entities/User.cs +++ b/CleanArchitecture.Domain/Entities/User.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.Domain.Entities; @@ -8,20 +9,26 @@ public class User : Entity public string Email { get; private set; } public string GivenName { get; private set; } public string Surname { get; private set; } + public string Password { get; private set; } + public UserRole Role { get; private set; } public string FullName => $"{Surname}, {GivenName}"; - + public User( Guid id, string email, string surname, - string givenName) : base(id) + string givenName, + string password, + UserRole role) : base(id) { Email = email; GivenName = givenName; Surname = surname; + Password = password; + Role = role; } - + [MemberNotNull(nameof(Email))] public void SetEmail(string email) { @@ -72,4 +79,26 @@ public class User : Entity Surname = surname; } + + [MemberNotNull(nameof(Password))] + public void SetPassword(string password) + { + if (password == null) + { + throw new ArgumentNullException(nameof(password)); + } + + if (password.Length > 100) + { + throw new ArgumentException( + "Password may not be longer than 100 characters"); + } + + Password = password; + } + + public void SetRole(UserRole role) + { + Role = role; + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Enums/UserRole.cs b/CleanArchitecture.Domain/Enums/UserRole.cs new file mode 100644 index 0000000..95c9259 --- /dev/null +++ b/CleanArchitecture.Domain/Enums/UserRole.cs @@ -0,0 +1,7 @@ +namespace CleanArchitecture.Domain.Enums; + +public enum UserRole +{ + Admin, + User +} diff --git a/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs index 64cd05d..f827036 100644 --- a/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs +++ b/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs @@ -3,6 +3,7 @@ using CleanArchitecture.Domain.Commands.Users.DeleteUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.EventHandler; using CleanArchitecture.Domain.Events.User; +using CleanArchitecture.Domain.Interfaces; using MediatR; using Microsoft.Extensions.DependencyInjection; @@ -29,4 +30,12 @@ public static class ServiceCollectionExtension return services; } + + public static IServiceCollection AddApiUser(this IServiceCollection services) + { + // User + services.AddScoped(); + + return services; + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Interfaces/IUser.cs b/CleanArchitecture.Domain/Interfaces/IUser.cs new file mode 100644 index 0000000..55e953c --- /dev/null +++ b/CleanArchitecture.Domain/Interfaces/IUser.cs @@ -0,0 +1,12 @@ +using System; +using CleanArchitecture.Domain.Enums; + +namespace CleanArchitecture.Domain.Interfaces; + +public interface IUser +{ + Guid GetUserId(); + UserRole GetUserRole(); + + string Name { get; } +}