0
0
mirror of https://github.com/alex289/CleanArchitecture.git synced 2025-06-30 02:31:08 +00:00

Implement new user commands

This commit is contained in:
Alexander Konietzko 2023-03-20 22:26:12 +01:00
parent d89e44b8df
commit 983c63b38e
No known key found for this signature in database
GPG Key ID: BA6905F37AEC2B5B
14 changed files with 432 additions and 16 deletions

View File

@ -9,6 +9,10 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>

View File

@ -29,6 +29,19 @@ public sealed class ApiUser : IUser
throw new ArgumentException("Could not parse user id to guid");
}
public string GetUserEmail()
{
var claim = _httpContextAccessor.HttpContext?.User.Claims
.FirstOrDefault(x => string.Equals(x.Type, ClaimTypes.Email));
if (!string.IsNullOrWhiteSpace(claim?.Value))
{
return claim?.Value!;
}
throw new ArgumentException("Could not parse user email");
}
public UserRole GetUserRole()
{
var claim = _httpContextAccessor.HttpContext?.User.Claims

View File

@ -10,6 +10,8 @@
<PackageReference Include="FluentValidation" Version="11.5.1" />
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.27.0" />
</ItemGroup>
</Project>

View File

@ -1,6 +1,23 @@
using System;
namespace CleanArchitecture.Domain.Commands.Users.ChangePassword;
public sealed class ChangePasswordCommand
public sealed class ChangePasswordCommand : CommandBase
{
private readonly ChangePasswordCommandValidation _validation = new();
public string Password { get; }
public string NewPassword { get; }
public ChangePasswordCommand(string password, string newPassword) : base(Guid.NewGuid())
{
Password = password;
NewPassword = newPassword;
}
public override bool IsValid()
{
ValidationResult = _validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -0,0 +1,70 @@
using System.Threading;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Errors;
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.ChangePassword;
public sealed class ChangePasswordCommandHandler : CommandHandlerBase,
IRequestHandler<ChangePasswordCommand>
{
private readonly IUserRepository _userRepository;
private readonly IUser _user;
public ChangePasswordCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
IUserRepository userRepository,
IUser user) : base(bus, unitOfWork, notifications)
{
_userRepository = userRepository;
_user = user;
}
public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken)
{
if (!await TestValidityAsync(request))
{
return;
}
var user = await _userRepository.GetByIdAsync(_user.GetUserId());
if (user == null)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no User with Id {_user.GetUserId()}",
ErrorCodes.ObjectNotFound));
return;
}
if (!BC.Verify(request.Password, user.Password))
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
"The password is incorrect",
DomainErrorCodes.UserPasswordIncorrect));
return;
}
string passwordHash = BC.HashPassword(request.NewPassword);
user.SetPassword(passwordHash);
_userRepository.Update(user);
if (await CommitAsync())
{
await _bus.RaiseEventAsync(new User(user.Id));
}
}
}

View File

@ -0,0 +1,7 @@
using FluentValidation;
namespace CleanArchitecture.Domain.Commands.Users.ChangePassword;
public sealed class ChangePasswordCommandValidation : AbstractValidator<ChangePasswordCommand>
{
}

View File

@ -1,6 +1,28 @@
using System;
using MediatR;
namespace CleanArchitecture.Domain.Commands.Users.LoginUser;
public sealed class LoginCommand
public sealed class LoginUserCommand : CommandBase,
IRequest<string>
{
private readonly LoginUserCommandValidation _validation = new();
public string Email { get; set; }
public string Password { get; set; }
public LoginUserCommand(
string email,
string password) : base(Guid.NewGuid())
{
Email = email;
Password = password;
}
public override bool IsValid()
{
ValidationResult = _validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -0,0 +1,104 @@
using System.Security.Claims;
using System.Text;
using System;
using System.Threading;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Domain.Interfaces.Repositories;
using CleanArchitecture.Domain.Notifications;
using CleanArchitecture.Domain.Settings;
using MediatR;
using BC = BCrypt.Net.BCrypt;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
namespace CleanArchitecture.Domain.Commands.Users.LoginUser;
public sealed class LoginUserCommandHandler : CommandHandlerBase,
IRequestHandler<LoginUserCommand, string>
{
private const double EXPIRY_DURATION_MINUTES = 30;
private readonly IUserRepository _userRepository;
private readonly TokenSettings _tokenSettings;
public LoginUserCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
IUserRepository userRepository,
IOptions<TokenSettings> tokenSettings) : base(bus, unitOfWork, notifications)
{
_userRepository = userRepository;
_tokenSettings = tokenSettings.Value;
}
public async Task<string> Handle(LoginUserCommand request, CancellationToken cancellationToken)
{
if (!await TestValidityAsync(request))
{
return "";
}
var user = await _userRepository.GetByEmailAsync(request.Email);
if (user == null)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no User with Email {request.Email}",
ErrorCodes.ObjectNotFound));
return "";
}
var passwordVerified = BC.Verify(request.Password, user.Password);
if (!passwordVerified)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
"The password is incorrect",
DomainErrorCodes.UserPasswordIncorrect));
return "";
}
return BuildToken(
user.Email,
user.Role,
user.Id,
_tokenSettings);
}
public static string BuildToken(string email, UserRole role, Guid Id, TokenSettings tokenSettings)
{
var claims = new[]
{
new Claim(ClaimTypes.Email, email),
new Claim(ClaimTypes.Role, role.ToString()),
new Claim(ClaimTypes.NameIdentifier, Id.ToString())
};
var securityKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(tokenSettings.Secret));
var credentials = new SigningCredentials(
securityKey,
SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new JwtSecurityToken(
tokenSettings.Issuer,
tokenSettings.Audience,
claims,
expires: DateTime.Now.AddMinutes(EXPIRY_DURATION_MINUTES),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
}
}

View File

@ -0,0 +1,7 @@
using FluentValidation;
namespace CleanArchitecture.Domain.Commands.Users.LoginUser;
public sealed class LoginUserCommandValidation : AbstractValidator<LoginUserCommand>
{
}

View File

@ -22,4 +22,5 @@ public static class DomainErrorCodes
// User
public const string UserAlreadyExists = "USER_ALREADY_EXISTS";
public const string UserPasswordIncorrect = "USER_PASSWORD_INCORRECT";
}

View File

@ -0,0 +1,82 @@
// <auto-generated />
using System;
using CleanArchitecture.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CleanArchitecture.Infrastructure.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20230320204057_AddUserRoleAndPassword")]
partial class AddUserRoleAndPassword
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.4")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CleanArchitecture.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("Deleted")
.HasColumnType("bit");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("nvarchar(320)");
b.Property<string>("GivenName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<string>("Surname")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.ToTable("Users");
b.HasData(
new
{
Id = new Guid("3fc7aacd-41cc-4ca2-b842-32edcd0782d5"),
Deleted = false,
Email = "admin@email.com",
GivenName = "User",
Password = "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2",
Role = 0,
Surname = "Admin"
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CleanArchitecture.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddUserRoleAndPassword : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Password",
table: "Users",
type: "nvarchar(128)",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<int>(
name: "Role",
table: "Users",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "Deleted", "Email", "GivenName", "Password", "Role", "Surname" },
values: new object[] { new Guid("3fc7aacd-41cc-4ca2-b842-32edcd0782d5"), false, "admin@email.com", "User", "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", 0, "Admin" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Users",
keyColumn: "Id",
keyValue: new Guid("3fc7aacd-41cc-4ca2-b842-32edcd0782d5"));
migrationBuilder.DropColumn(
name: "Password",
table: "Users");
migrationBuilder.DropColumn(
name: "Role",
table: "Users");
}
}
}

View File

@ -17,7 +17,7 @@ namespace CleanArchitecture.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.3")
.HasAnnotation("ProductVersion", "7.0.4")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
@ -44,6 +44,14 @@ namespace CleanArchitecture.Infrastructure.Migrations
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Password")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<string>("Surname")
.IsRequired()
.HasMaxLength(100)
@ -52,6 +60,18 @@ namespace CleanArchitecture.Infrastructure.Migrations
b.HasKey("Id");
b.ToTable("Users");
b.HasData(
new
{
Id = new Guid("3fc7aacd-41cc-4ca2-b842-32edcd0782d5"),
Deleted = false,
Email = "admin@email.com",
GivenName = "User",
Password = "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2",
Role = 0,
Surname = "Admin"
});
});
#pragma warning restore 612, 618
}

View File

@ -1,26 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Api", "CleanArchitecture.Api\CleanArchitecture.Api.csproj", "{CD720672-0ED9-4FDD-AD69-A416CB394318}"
# Visual Studio Version 17
VisualStudioVersion = 17.5.33502.453
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Api", "CleanArchitecture.Api\CleanArchitecture.Api.csproj", "{CD720672-0ED9-4FDD-AD69-A416CB394318}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Application", "CleanArchitecture.Application\CleanArchitecture.Application.csproj", "{859B50AF-9C8D-4489-B64A-EEBDF756A012}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Application", "CleanArchitecture.Application\CleanArchitecture.Application.csproj", "{859B50AF-9C8D-4489-B64A-EEBDF756A012}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Domain", "CleanArchitecture.Domain\CleanArchitecture.Domain.csproj", "{12C5BEEF-9BFD-450A-8627-6205702CA32B}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Domain", "CleanArchitecture.Domain\CleanArchitecture.Domain.csproj", "{12C5BEEF-9BFD-450A-8627-6205702CA32B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Infrastructure", "CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj", "{B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Infrastructure", "CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj", "{B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Application.Tests", "CleanArchitecture.Application.Tests\CleanArchitecture.Application.Tests.csproj", "{6794B922-2AFD-4187-944D-7984B9973259}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Application.Tests", "CleanArchitecture.Application.Tests\CleanArchitecture.Application.Tests.csproj", "{6794B922-2AFD-4187-944D-7984B9973259}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Domain.Tests", "CleanArchitecture.Domain.Tests\CleanArchitecture.Domain.Tests.csproj", "{E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Domain.Tests", "CleanArchitecture.Domain.Tests\CleanArchitecture.Domain.Tests.csproj", "{E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Infrastructure.Tests", "CleanArchitecture.Infrastructure.Tests\CleanArchitecture.Infrastructure.Tests.csproj", "{EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Infrastructure.Tests", "CleanArchitecture.Infrastructure.Tests\CleanArchitecture.Infrastructure.Tests.csproj", "{EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.IntegrationTests", "CleanArchitecture.IntegrationTests\CleanArchitecture.IntegrationTests.csproj", "{39732BD4-909F-410C-8737-1F9FE3E269A7}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.IntegrationTests", "CleanArchitecture.IntegrationTests\CleanArchitecture.IntegrationTests.csproj", "{39732BD4-909F-410C-8737-1F9FE3E269A7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.gRPC", "CleanArchitecture.gRPC\CleanArchitecture.gRPC.csproj", "{7A6353A9-B60C-4B13-A849-D21B315047EE}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.gRPC", "CleanArchitecture.gRPC\CleanArchitecture.gRPC.csproj", "{7A6353A9-B60C-4B13-A849-D21B315047EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Proto", "CleanArchitecture.Proto\CleanArchitecture.Proto.csproj", "{5F978903-7A7A-45C2-ABE0-C2906ECD326B}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.Proto", "CleanArchitecture.Proto\CleanArchitecture.Proto.csproj", "{5F978903-7A7A-45C2-ABE0-C2906ECD326B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.gRPC.Tests", "CleanArchitecture.gRPC.Tests\CleanArchitecture.gRPC.Tests.csproj", "{E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CleanArchitecture.gRPC.Tests", "CleanArchitecture.gRPC.Tests\CleanArchitecture.gRPC.Tests.csproj", "{E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -73,4 +78,14 @@ Global
{E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E3A836DD-85DB-44FD-BC19-DDFE111D9EB0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{6794B922-2AFD-4187-944D-7984B9973259} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45}
{E1F25916-EBBE-4CBD-99A2-1EB2F604D55C} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45}
{EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45}
{39732BD4-909F-410C-8737-1F9FE3E269A7} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45}
{E3A836DD-85DB-44FD-BC19-DDFE111D9EB0} = {D3DF9DF5-BD7D-48BC-8BE6-DBD0FABB8B45}
EndGlobalSection
EndGlobal