From e35eb89b378c3e85b159362a69b145e61644a9cc Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 15:43:35 +0700 Subject: [PATCH 01/10] add: reset password token entity, configuration, and added to app db context also renamed the parameter of OnModelCreating in ApplicationDbContext.cs to modelBuilder to satisfy SonarLint --- src/Domain/Entities/ResetPasswordToken.cs | 12 ++++++++++ .../Persistence/ApplicationDbContext.cs | 7 +++--- .../ResetPasswordTokenConfiguration.cs | 24 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 src/Domain/Entities/ResetPasswordToken.cs create mode 100644 src/Infrastructure/Persistence/Configurations/ResetPasswordTokenConfiguration.cs diff --git a/src/Domain/Entities/ResetPasswordToken.cs b/src/Domain/Entities/ResetPasswordToken.cs new file mode 100644 index 00000000..0ae14f1a --- /dev/null +++ b/src/Domain/Entities/ResetPasswordToken.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using NodaTime; + +namespace Domain.Entities; + +public class ResetPasswordToken +{ + [Key] public string TokenHash { get; set; } = null!; + public User User { get; set; } = null!; + public LocalDateTime ExpirationDate { get; set; } + public bool IsInvalidated { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/ApplicationDbContext.cs b/src/Infrastructure/Persistence/ApplicationDbContext.cs index dbae579c..155c4cde 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContext.cs @@ -28,13 +28,14 @@ public ApplicationDbContext( public DbSet Borrows => Set(); public DbSet RefreshTokens => Set(); + public DbSet ResetPasswordTokens => Set(); - protected override void OnModelCreating(ModelBuilder builder) + protected override void OnModelCreating(ModelBuilder modelBuilder) { // Scan for entity configurations using FluentAPI - builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); - base.OnModelCreating(builder); + base.OnModelCreating(modelBuilder); } public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/src/Infrastructure/Persistence/Configurations/ResetPasswordTokenConfiguration.cs b/src/Infrastructure/Persistence/Configurations/ResetPasswordTokenConfiguration.cs new file mode 100644 index 00000000..fde3e1e6 --- /dev/null +++ b/src/Infrastructure/Persistence/Configurations/ResetPasswordTokenConfiguration.cs @@ -0,0 +1,24 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Persistence.Configurations; + +public class ResetPasswordTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.TokenHash); + + builder.Property(x => x.ExpirationDate) + .IsRequired(); + + builder.Property(x => x.IsInvalidated) + .HasDefaultValue(false); + + builder.HasOne(x => x.User) + .WithMany() + .HasForeignKey() + .IsRequired(); + } +} \ No newline at end of file From ac3005e6f5cbd1781f5a993599b0d194011bd59f Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 15:49:55 +0700 Subject: [PATCH 02/10] chore: refactored SendResetPasswordHtmlMail method of IMailService.cs also: - Pass a reset password token hash so frontend can call the right link - Change one template variable name to ResetPasswordTokenHash and send the token along - Renamed HTMLMailData.cs to HtmlMailData.cs to satisfy sonarlint --- src/Application/Common/Interfaces/IMailService.cs | 2 +- .../Common/Models/{HTMLMailData.cs => HtmlMailData.cs} | 6 +++--- .../Users/EventHandlers/UserCreatedEventHandler.cs | 2 +- src/Infrastructure/Services/MailService.cs | 8 ++++---- tests/Application.Tests.Integration/CustomMailService.cs | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) rename src/Application/Common/Models/{HTMLMailData.cs => HtmlMailData.cs} (86%) diff --git a/src/Application/Common/Interfaces/IMailService.cs b/src/Application/Common/Interfaces/IMailService.cs index e7fe9d67..0dbc58c7 100644 --- a/src/Application/Common/Interfaces/IMailService.cs +++ b/src/Application/Common/Interfaces/IMailService.cs @@ -4,5 +4,5 @@ namespace Application.Common.Interfaces; public interface IMailService { - bool SendResetPasswordHtmlMail(string userEmail, string password); + bool SendResetPasswordHtmlMail(string userEmail, string temporaryPassword, string resetPasswordTokenHash); } \ No newline at end of file diff --git a/src/Application/Common/Models/HTMLMailData.cs b/src/Application/Common/Models/HtmlMailData.cs similarity index 86% rename from src/Application/Common/Models/HTMLMailData.cs rename to src/Application/Common/Models/HtmlMailData.cs index edc8dae0..9fa0959b 100644 --- a/src/Application/Common/Models/HTMLMailData.cs +++ b/src/Application/Common/Models/HtmlMailData.cs @@ -2,7 +2,7 @@ namespace Application.Common.Models; -public class HTMLMailData +public class HtmlMailData { [JsonPropertyName("from")] public From From { get; set; } @@ -32,8 +32,8 @@ public class TemplateVariables { [JsonPropertyName("user_email")] public string UserEmail { get; set; } - [JsonPropertyName("pass_reset_link")] - public string PassResetLink { get; set; } + [JsonPropertyName("reset_password_token_hash")] + public string ResetPasswordTokenHash { get; set; } [JsonPropertyName("user_password")] public string UserPassword { get; set; } } \ No newline at end of file diff --git a/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs b/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs index 7c2efe9b..253b3334 100644 --- a/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs +++ b/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs @@ -15,6 +15,6 @@ public UserCreatedEventHandler(IMailService mailService) public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) { - _mailService.SendResetPasswordHtmlMail(notification.Email, notification.Password); + _mailService.SendResetPasswordHtmlMail(notification.Email, notification.Password, ""); } } \ No newline at end of file diff --git a/src/Infrastructure/Services/MailService.cs b/src/Infrastructure/Services/MailService.cs index 32feba59..8515cad5 100644 --- a/src/Infrastructure/Services/MailService.cs +++ b/src/Infrastructure/Services/MailService.cs @@ -17,9 +17,9 @@ public MailService(IOptions mailSettingsOptions) _mailSettings = mailSettingsOptions.Value; } - public bool SendResetPasswordHtmlMail(string userEmail, string password) + public bool SendResetPasswordHtmlMail(string userEmail, string temporaryPassword, string resetPasswordTokenHash) { - var data = new HTMLMailData() + var data = new HtmlMailData() { From = new From() { @@ -34,8 +34,8 @@ public bool SendResetPasswordHtmlMail(string userEmail, string password) TemplateVariables = new TemplateVariables() { UserEmail = userEmail, - PassResetLink = "random_shit", - UserPassword = password, + ResetPasswordTokenHash = resetPasswordTokenHash, + UserPassword = temporaryPassword, }, }; diff --git a/tests/Application.Tests.Integration/CustomMailService.cs b/tests/Application.Tests.Integration/CustomMailService.cs index e439bf07..32c8d360 100644 --- a/tests/Application.Tests.Integration/CustomMailService.cs +++ b/tests/Application.Tests.Integration/CustomMailService.cs @@ -4,7 +4,7 @@ namespace Application.Tests.Integration; public class CustomMailService : IMailService { - public bool SendResetPasswordHtmlMail(string userEmail, string password) + public bool SendResetPasswordHtmlMail(string userEmail, string temporaryPassword, string resetPasswordTokenHash) { return true; } From 146159209b7dfa188d62adb4807429e658ac8cb3 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 16:02:17 +0700 Subject: [PATCH 03/10] add: IAuthDbContext.cs and registered it --- src/Application/Common/Interfaces/IAuthDbContext.cs | 12 ++++++++++++ src/Infrastructure/ConfigureServices.cs | 1 + .../Persistence/ApplicationDbContext.cs | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 src/Application/Common/Interfaces/IAuthDbContext.cs diff --git a/src/Application/Common/Interfaces/IAuthDbContext.cs b/src/Application/Common/Interfaces/IAuthDbContext.cs new file mode 100644 index 00000000..49801213 --- /dev/null +++ b/src/Application/Common/Interfaces/IAuthDbContext.cs @@ -0,0 +1,12 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Application.Common.Interfaces; + +public interface IAuthDbContext +{ + public DbSet RefreshTokens { get; } + public DbSet ResetPasswordTokens { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Infrastructure/ConfigureServices.cs b/src/Infrastructure/ConfigureServices.cs index d3820329..4c58b208 100644 --- a/src/Infrastructure/ConfigureServices.cs +++ b/src/Infrastructure/ConfigureServices.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddInfrastructureServices(this IServiceCollecti { services.AddApplicationDbContext(configuration); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddMailService(configuration); diff --git a/src/Infrastructure/Persistence/ApplicationDbContext.cs b/src/Infrastructure/Persistence/ApplicationDbContext.cs index 155c4cde..e454ec5a 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContext.cs @@ -8,7 +8,7 @@ namespace Infrastructure.Persistence; -public class ApplicationDbContext : DbContext, IApplicationDbContext +public class ApplicationDbContext : DbContext, IApplicationDbContext, IAuthDbContext { private readonly IMediator _mediator; public ApplicationDbContext( From bc359baa84c8dcd650a3855ff54353be2b2b9401 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 16:07:32 +0700 Subject: [PATCH 04/10] chore: refactored IdentityService to be using abstractions instead of implementations --- .../Identity/IdentityService.cs | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index 1947979e..2f613651 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -1,7 +1,5 @@ -using System.Data; using System.Globalization; using System.IdentityModel.Tokens.Jwt; -using System.Reflection.Metadata.Ecma335; using System.Security.Authentication; using System.Security.Claims; using System.Security.Cryptography; @@ -12,7 +10,6 @@ using Application.Users.Queries; using AutoMapper; using Domain.Entities; -using Infrastructure.Persistence; using Infrastructure.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -26,16 +23,25 @@ public class IdentityService : IIdentityService { private readonly TokenValidationParameters _tokenValidationParameters; private readonly JweSettings _jweSettings; - private readonly ApplicationDbContext _context; + private readonly IApplicationDbContext _applicationDbContext; + private readonly IAuthDbContext _authDbContext; private readonly RSA _encryptionKey; private readonly ECDsa _signingKey; private readonly IMapper _mapper; - public IdentityService(TokenValidationParameters tokenValidationParameters, IOptions jweSettingsOptions, ApplicationDbContext context, RSA encryptionKey, ECDsa signingKey, IMapper mapper) + public IdentityService( + TokenValidationParameters tokenValidationParameters, + IOptions jweSettingsOptions, + IApplicationDbContext applicationDbContext, + IAuthDbContext authDbContext, + RSA encryptionKey, + ECDsa signingKey, + IMapper mapper) { _tokenValidationParameters = tokenValidationParameters; _jweSettings = jweSettingsOptions.Value; - _context = context; + _applicationDbContext = applicationDbContext; + _authDbContext = authDbContext; _encryptionKey = encryptionKey; _signingKey = signingKey; _mapper = mapper; @@ -54,7 +60,7 @@ public async Task Validate(string token, string refreshToken) var email = validatedToken.Claims.Single(y => y.Type.Equals(emailClaim)).Value; - var user = await _context.Users.FirstOrDefaultAsync(x => + var user = await _applicationDbContext.Users.FirstOrDefaultAsync(x => x.Username.Equals(email) || x.Email!.Equals(email)); @@ -75,7 +81,7 @@ public async Task Validate(string token, string refreshToken) } var jti = validatedToken.Claims.Single(x => x.Type.Equals(JwtRegisteredClaimNames.Jti)).Value; - var storedRefreshToken = await _context.RefreshTokens.SingleOrDefaultAsync(x => x.Token.Equals(Guid.Parse(refreshToken))); + var storedRefreshToken = await _authDbContext.RefreshTokens.SingleOrDefaultAsync(x => x.Token.Equals(Guid.Parse(refreshToken))); if (storedRefreshToken is null) { @@ -113,7 +119,7 @@ public async Task RefreshTokenAsync(string token, string r var email = validatedToken.Claims.Single(y => y.Type.Equals(emailClaim)).Value; - var user = await _context.Users.FirstOrDefaultAsync(x => + var user = await _applicationDbContext.Users.FirstOrDefaultAsync(x => x.Username.Equals(email) || x.Email!.Equals(email)); @@ -123,7 +129,7 @@ public async Task RefreshTokenAsync(string token, string r } var jti = validatedToken.Claims.Single(x => x.Type.Equals(JwtRegisteredClaimNames.Jti)).Value; - var storedRefreshToken = await _context.RefreshTokens.SingleOrDefaultAsync(x => x.Token.Equals(Guid.Parse(refreshToken))); + var storedRefreshToken = await _authDbContext.RefreshTokens.SingleOrDefaultAsync(x => x.Token.Equals(Guid.Parse(refreshToken))); if (storedRefreshToken is null) { @@ -185,7 +191,7 @@ public async Task RefreshTokenAsync(string token, string r public async Task<(AuthenticationResult, UserDto)> LoginAsync(string email, string password) { - var user = _context.Users + var user = _applicationDbContext.Users .Include(x => x.Department) .FirstOrDefault(x => x.Email!.Equals(email)); @@ -194,10 +200,10 @@ public async Task RefreshTokenAsync(string token, string r throw new AuthenticationException("Username or password is invalid."); } - var existedRefreshTokens = _context.RefreshTokens.Where(x => x.User.Email!.Equals(user.Email)); + var existedRefreshTokens = _authDbContext.RefreshTokens.Where(x => x.User.Email!.Equals(user.Email)); - _context.RemoveRange(existedRefreshTokens); - await _context.SaveChangesAsync(); + _authDbContext.RefreshTokens.RemoveRange(existedRefreshTokens); + await _authDbContext.SaveChangesAsync(CancellationToken.None); return (await GenerateAuthenticationResultForUserAsync(user), _mapper.Map(user)); } @@ -213,7 +219,7 @@ public async Task LogoutAsync(string token, string refreshToken) var jti = validatedToken.Claims.Single(x => x.Type.Equals(JwtRegisteredClaimNames.Jti)).Value; var storedRefreshToken = - await _context.RefreshTokens.SingleOrDefaultAsync(x => x.Token.Equals(Guid.Parse(refreshToken))); + await _authDbContext.RefreshTokens.SingleOrDefaultAsync(x => x.Token.Equals(Guid.Parse(refreshToken))); if (storedRefreshToken is null) return; @@ -222,8 +228,8 @@ public async Task LogoutAsync(string token, string refreshToken) throw new AuthenticationException("This refresh token does not match this Jwt."); } - _context.RefreshTokens.Remove(storedRefreshToken); - await _context.SaveChangesAsync(); + _authDbContext.RefreshTokens.Remove(storedRefreshToken); + await _authDbContext.SaveChangesAsync(CancellationToken.None); } private async Task GenerateAuthenticationResultForUserAsync(User user) @@ -239,8 +245,8 @@ private async Task GenerateAuthenticationResultForUserAsyn ExpiryDateTime = LocalDateTime.FromDateTime(utcNow.AddDays(_jweSettings.RefreshTokenLifetimeInDays)) }; - await _context.RefreshTokens.AddAsync(refreshToken); - await _context.SaveChangesAsync(); + await _authDbContext.RefreshTokens.AddAsync(refreshToken); + await _authDbContext.SaveChangesAsync(CancellationToken.None); return new() { Token = token, From ac9991442f68768f98b8b037659816ce53c2196d Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 16:26:34 +0700 Subject: [PATCH 05/10] chore: add reset token creation logic to UserCreatedEventHandler.cs --- .../EventHandlers/UserCreatedEventHandler.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs b/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs index 253b3334..789c074f 100644 --- a/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs +++ b/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs @@ -1,20 +1,45 @@ using Application.Common.Interfaces; +using Application.Helpers; +using Domain.Entities; using Domain.Events; using MediatR; +using Microsoft.EntityFrameworkCore; +using NodaTime; namespace Application.Users.EventHandlers; public class UserCreatedEventHandler : INotificationHandler { private readonly IMailService _mailService; + private readonly IApplicationDbContext _applicationDbContext; + private readonly IAuthDbContext _authDbContext; - public UserCreatedEventHandler(IMailService mailService) + public UserCreatedEventHandler(IMailService mailService, IAuthDbContext authDbContext, IApplicationDbContext applicationDbContext) { _mailService = mailService; + _applicationDbContext = applicationDbContext; + _authDbContext = authDbContext; } public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) { - _mailService.SendResetPasswordHtmlMail(notification.Email, notification.Password, ""); + var user = await _applicationDbContext.Users.FirstAsync( + x => x.Email.ToLower() + .Equals(notification.Email.ToLower()), cancellationToken); + + var expirationDate = LocalDateTime.FromDateTime(DateTime.Now.AddDays(1)); + + var resetPasswordToken = new ResetPasswordToken() + { + User = user, + TokenHash = SecurityUtil.Hash(Guid.NewGuid().ToString()), + ExpirationDate = expirationDate, + IsInvalidated = false, + }; + + var tokenEntity = await _authDbContext.ResetPasswordTokens.AddAsync(resetPasswordToken, cancellationToken); + await _authDbContext.SaveChangesAsync(cancellationToken); + + _mailService.SendResetPasswordHtmlMail(notification.Email, notification.Password, tokenEntity.Entity.TokenHash); } } \ No newline at end of file From 76cfdc79d69292a4f9b7a5b21504d0a1727117b2 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 16:29:14 +0700 Subject: [PATCH 06/10] chore: UserId foreign key naming in configuration --- .../Configurations/ResetPasswordTokenConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Infrastructure/Persistence/Configurations/ResetPasswordTokenConfiguration.cs b/src/Infrastructure/Persistence/Configurations/ResetPasswordTokenConfiguration.cs index fde3e1e6..36b168e6 100644 --- a/src/Infrastructure/Persistence/Configurations/ResetPasswordTokenConfiguration.cs +++ b/src/Infrastructure/Persistence/Configurations/ResetPasswordTokenConfiguration.cs @@ -18,7 +18,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasOne(x => x.User) .WithMany() - .HasForeignKey() + .HasForeignKey("UserId") .IsRequired(); } } \ No newline at end of file From 5f10fd10f3c39060d01ab9a07eb5cffa8886e3b0 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 16:50:21 +0700 Subject: [PATCH 07/10] chore: add migrations for reset password token table --- ...07093007_AddResetPasswordToken.Designer.cs | 520 ++++++++++++++++++ .../20230607093007_AddResetPasswordToken.cs | 48 ++ 2 files changed, 568 insertions(+) create mode 100644 src/Infrastructure/Persistence/Migrations/20230607093007_AddResetPasswordToken.Designer.cs create mode 100644 src/Infrastructure/Persistence/Migrations/20230607093007_AddResetPasswordToken.cs diff --git a/src/Infrastructure/Persistence/Migrations/20230607093007_AddResetPasswordToken.Designer.cs b/src/Infrastructure/Persistence/Migrations/20230607093007_AddResetPasswordToken.Designer.cs new file mode 100644 index 00000000..22ae64be --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20230607093007_AddResetPasswordToken.Designer.cs @@ -0,0 +1,520 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20230607093007_AddResetPasswordToken")] + partial class AddResetPasswordToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasAlternateKey("Name"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Borrow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualReturnTime") + .HasColumnType("timestamp without time zone"); + + b.Property("BorrowTime") + .HasColumnType("timestamp without time zone"); + + b.Property("BorrowerId") + .HasColumnType("uuid"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("DueTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BorrowerId"); + + b.HasIndex("DocumentId"); + + b.ToTable("Borrows"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FolderId") + .HasColumnType("uuid"); + + b.Property("ImporterId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("FolderId"); + + b.HasIndex("ImporterId"); + + b.ToTable("Documents"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("LockerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NumberOfDocuments") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LockerId"); + + b.ToTable("Folders"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Locker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NumberOfFolders") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("Lockers"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Room", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NumberOfLockers") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasAlternateKey("Name"); + + b.HasIndex("DepartmentId") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Staff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("UserId"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId") + .IsUnique(); + + b.ToTable("Staffs"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Token") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExpiryDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("IsInvalidated") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("JwtId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.ResetPasswordToken", b => + { + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsInvalidated") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("ResetPasswordTokens"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("FirstName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastModified") + .HasColumnType("timestamp without time zone"); + + b.Property("LastModifiedBy") + .HasColumnType("uuid"); + + b.Property("LastName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Borrow", b => + { + b.HasOne("Domain.Entities.User", "Borrower") + .WithMany() + .HasForeignKey("BorrowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Physical.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Borrower"); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Document", b => + { + b.HasOne("Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId"); + + b.HasOne("Domain.Entities.Physical.Folder", "Folder") + .WithMany("Documents") + .HasForeignKey("FolderId"); + + b.HasOne("Domain.Entities.User", "Importer") + .WithMany() + .HasForeignKey("ImporterId"); + + b.Navigation("Department"); + + b.Navigation("Folder"); + + b.Navigation("Importer"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Folder", b => + { + b.HasOne("Domain.Entities.Physical.Locker", "Locker") + .WithMany("Folders") + .HasForeignKey("LockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Locker"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Locker", b => + { + b.HasOne("Domain.Entities.Physical.Room", "Room") + .WithMany("Lockers") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Room", b => + { + b.HasOne("Domain.Entities.Department", "Department") + .WithOne("Room") + .HasForeignKey("Domain.Entities.Physical.Room", "DepartmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Department"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Staff", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("Domain.Entities.Physical.Staff", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Physical.Room", "Room") + .WithOne("Staff") + .HasForeignKey("Domain.Entities.Physical.Staff", "RoomId"); + + b.Navigation("Room"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.ResetPasswordToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId"); + + b.Navigation("Department"); + }); + + modelBuilder.Entity("Domain.Entities.Department", b => + { + b.Navigation("Room"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Folder", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Locker", b => + { + b.Navigation("Folders"); + }); + + modelBuilder.Entity("Domain.Entities.Physical.Room", b => + { + b.Navigation("Lockers"); + + b.Navigation("Staff"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/20230607093007_AddResetPasswordToken.cs b/src/Infrastructure/Persistence/Migrations/20230607093007_AddResetPasswordToken.cs new file mode 100644 index 00000000..3a34e1da --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20230607093007_AddResetPasswordToken.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Infrastructure.Persistence.Migrations +{ + /// + public partial class AddResetPasswordToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ResetPasswordTokens", + columns: table => new + { + TokenHash = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ExpirationDate = table.Column(type: "timestamp without time zone", nullable: false), + IsInvalidated = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ResetPasswordTokens", x => x.TokenHash); + table.ForeignKey( + name: "FK_ResetPasswordTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ResetPasswordTokens_UserId", + table: "ResetPasswordTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ResetPasswordTokens"); + } + } +} From bf0d9c14bb276a3f7f5e9cd56f6068c8bde78c84 Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 16:51:59 +0700 Subject: [PATCH 08/10] chore: modified service a little bit - Changing the way service registrations work so that 2 interfaces share the same instance - Pass the whole entity to event so that auth db context can get its id - I forgot the db context model snapshot --- src/Application/Users/Commands/AddUser.cs | 2 +- .../EventHandlers/UserCreatedEventHandler.cs | 12 ++----- src/Domain/Events/UserCreatedEvent.cs | 6 ++-- src/Infrastructure/ConfigureServices.cs | 4 +-- .../ApplicationDbContextModelSnapshot.cs | 34 +++++++++++++++++++ 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/Application/Users/Commands/AddUser.cs b/src/Application/Users/Commands/AddUser.cs index 52ca1f80..c077511c 100644 --- a/src/Application/Users/Commands/AddUser.cs +++ b/src/Application/Users/Commands/AddUser.cs @@ -106,7 +106,7 @@ public async Task Handle(Command request, CancellationToken cancellatio IsActivated = false, Created = LocalDateTime.FromDateTime(DateTime.UtcNow) }; - entity.AddDomainEvent(new UserCreatedEvent(entity.Email, password)); + entity.AddDomainEvent(new UserCreatedEvent(entity, password)); var result = await _context.Users.AddAsync(entity, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return _mapper.Map(result.Entity); diff --git a/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs b/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs index 789c074f..044330af 100644 --- a/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs +++ b/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs @@ -11,27 +11,21 @@ namespace Application.Users.EventHandlers; public class UserCreatedEventHandler : INotificationHandler { private readonly IMailService _mailService; - private readonly IApplicationDbContext _applicationDbContext; private readonly IAuthDbContext _authDbContext; - public UserCreatedEventHandler(IMailService mailService, IAuthDbContext authDbContext, IApplicationDbContext applicationDbContext) + public UserCreatedEventHandler(IMailService mailService, IAuthDbContext authDbContext) { _mailService = mailService; - _applicationDbContext = applicationDbContext; _authDbContext = authDbContext; } public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) { - var user = await _applicationDbContext.Users.FirstAsync( - x => x.Email.ToLower() - .Equals(notification.Email.ToLower()), cancellationToken); - var expirationDate = LocalDateTime.FromDateTime(DateTime.Now.AddDays(1)); var resetPasswordToken = new ResetPasswordToken() { - User = user, + User = notification.User, TokenHash = SecurityUtil.Hash(Guid.NewGuid().ToString()), ExpirationDate = expirationDate, IsInvalidated = false, @@ -40,6 +34,6 @@ public async Task Handle(UserCreatedEvent notification, CancellationToken cancel var tokenEntity = await _authDbContext.ResetPasswordTokens.AddAsync(resetPasswordToken, cancellationToken); await _authDbContext.SaveChangesAsync(cancellationToken); - _mailService.SendResetPasswordHtmlMail(notification.Email, notification.Password, tokenEntity.Entity.TokenHash); + _mailService.SendResetPasswordHtmlMail(notification.User.Email, notification.Password, tokenEntity.Entity.TokenHash); } } \ No newline at end of file diff --git a/src/Domain/Events/UserCreatedEvent.cs b/src/Domain/Events/UserCreatedEvent.cs index ee1e507e..eb43de5a 100644 --- a/src/Domain/Events/UserCreatedEvent.cs +++ b/src/Domain/Events/UserCreatedEvent.cs @@ -5,12 +5,12 @@ namespace Domain.Events; public class UserCreatedEvent : BaseEvent { - public UserCreatedEvent(string email, string password) + public UserCreatedEvent(User user, string password) { - Email = email; + User = user; Password = password; } - public string Email { get; } + public User User { get; } public string Password { get; } } \ No newline at end of file diff --git a/src/Infrastructure/ConfigureServices.cs b/src/Infrastructure/ConfigureServices.cs index 4c58b208..3c6c8af2 100644 --- a/src/Infrastructure/ConfigureServices.cs +++ b/src/Infrastructure/ConfigureServices.cs @@ -17,8 +17,8 @@ public static class ConfigureServices public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration) { services.AddApplicationDbContext(configuration); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(sp => sp.GetService()!); + services.AddScoped(sp => sp.GetService()!); services.AddScoped(); services.AddMailService(configuration); diff --git a/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 26a343b3..fbfbf4f7 100644 --- a/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -280,6 +280,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RefreshTokens"); }); + modelBuilder.Entity("Domain.Entities.ResetPasswordToken", b => + { + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsInvalidated") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("ResetPasswordTokens"); + }); + modelBuilder.Entity("Domain.Entities.User", b => { b.Property("Id") @@ -447,6 +470,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Domain.Entities.ResetPasswordToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Domain.Entities.User", b => { b.HasOne("Domain.Entities.Department", "Department") From ac3b1db57d621bcec5275b2089135df97fa4bcff Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 16:54:42 +0700 Subject: [PATCH 09/10] chore: tweaks how tokens are sent - send the token instead of the hashed version of it --- .../Users/EventHandlers/UserCreatedEventHandler.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs b/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs index 044330af..41b48e34 100644 --- a/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs +++ b/src/Application/Users/EventHandlers/UserCreatedEventHandler.cs @@ -22,18 +22,19 @@ public UserCreatedEventHandler(IMailService mailService, IAuthDbContext authDbCo public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) { var expirationDate = LocalDateTime.FromDateTime(DateTime.Now.AddDays(1)); - + + var token = Guid.NewGuid().ToString(); var resetPasswordToken = new ResetPasswordToken() { User = notification.User, - TokenHash = SecurityUtil.Hash(Guid.NewGuid().ToString()), + TokenHash = SecurityUtil.Hash(token), ExpirationDate = expirationDate, IsInvalidated = false, }; - var tokenEntity = await _authDbContext.ResetPasswordTokens.AddAsync(resetPasswordToken, cancellationToken); + await _authDbContext.ResetPasswordTokens.AddAsync(resetPasswordToken, cancellationToken); await _authDbContext.SaveChangesAsync(cancellationToken); - _mailService.SendResetPasswordHtmlMail(notification.User.Email, notification.Password, tokenEntity.Entity.TokenHash); + _mailService.SendResetPasswordHtmlMail(notification.User.Email, notification.Password, token); } } \ No newline at end of file From 10c32800e60615b6c99c8864d4cde36db1bb005b Mon Sep 17 00:00:00 2001 From: Nguyen Quang Chien Date: Wed, 7 Jun 2023 20:42:29 +0700 Subject: [PATCH 10/10] add: endpoint and service to reset password --- src/Api/Controllers/AuthController.cs | 17 +++++++++++ .../Requests/Auth/ResetPasswordRequest.cs | 8 +++++ .../Common/Interfaces/IIdentityService.cs | 1 + .../Identity/IdentityService.cs | 30 +++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 src/Api/Controllers/Payload/Requests/Auth/ResetPasswordRequest.cs diff --git a/src/Api/Controllers/AuthController.cs b/src/Api/Controllers/AuthController.cs index a770a036..0190f016 100644 --- a/src/Api/Controllers/AuthController.cs +++ b/src/Api/Controllers/AuthController.cs @@ -116,6 +116,23 @@ public async Task Logout() return Ok(); } + [HttpPost("reset-password")] + public async Task ResetPassword([FromBody] ResetPasswordRequest request) + { + if (string.IsNullOrEmpty(request.NewPassword)) + { + return BadRequest("Password cannot be empty."); + } + + if (!request.NewPassword.Equals(request.ConfirmPassword)) + { + return BadRequest("Confirm password must match with new password."); + } + + await _identityService.ResetPassword(request.Token, request.NewPassword); + return Ok(); + } + private void SetJweToken(SecurityToken jweToken, RefreshTokenDto newRefreshToken) { var cookieOptions = new CookieOptions diff --git a/src/Api/Controllers/Payload/Requests/Auth/ResetPasswordRequest.cs b/src/Api/Controllers/Payload/Requests/Auth/ResetPasswordRequest.cs new file mode 100644 index 00000000..2e18993f --- /dev/null +++ b/src/Api/Controllers/Payload/Requests/Auth/ResetPasswordRequest.cs @@ -0,0 +1,8 @@ +namespace Api.Controllers.Payload.Requests.Auth; + +public class ResetPasswordRequest +{ + public string Token { get; set; } + public string NewPassword { get; set; } + public string ConfirmPassword { get; set; } +} \ No newline at end of file diff --git a/src/Application/Common/Interfaces/IIdentityService.cs b/src/Application/Common/Interfaces/IIdentityService.cs index 048300af..c4d91102 100644 --- a/src/Application/Common/Interfaces/IIdentityService.cs +++ b/src/Application/Common/Interfaces/IIdentityService.cs @@ -9,4 +9,5 @@ public interface IIdentityService Task RefreshTokenAsync(string token, string refreshToken); Task<(AuthenticationResult AuthResult, UserDto UserCredentials)> LoginAsync(string email, string password); Task LogoutAsync(string token, string refreshToken); + Task ResetPassword(string token, string newPassword); } \ No newline at end of file diff --git a/src/Infrastructure/Identity/IdentityService.cs b/src/Infrastructure/Identity/IdentityService.cs index 2f613651..8e913ca5 100644 --- a/src/Infrastructure/Identity/IdentityService.cs +++ b/src/Infrastructure/Identity/IdentityService.cs @@ -3,6 +3,7 @@ using System.Security.Authentication; using System.Security.Claims; using System.Security.Cryptography; +using Application.Common.Exceptions; using Application.Common.Interfaces; using Application.Common.Models; using Application.Common.Models.Dtos; @@ -232,6 +233,35 @@ public async Task LogoutAsync(string token, string refreshToken) await _authDbContext.SaveChangesAsync(CancellationToken.None); } + public async Task ResetPassword(string token, string newPassword) + { + var tokenHash = SecurityUtil.Hash(token); + var resetPasswordToken = await _authDbContext.ResetPasswordTokens + .Include(x => x.User) + .FirstOrDefaultAsync(x => x.TokenHash.Equals(tokenHash)); + if (resetPasswordToken is null) + { + throw new KeyNotFoundException("Token is invalid."); + } + + if (resetPasswordToken.IsInvalidated) + { + throw new ConflictException("Token is invalid."); + } + + var user = resetPasswordToken.User; + + if (user.IsActivated is false) + { + user.IsActivated = true; + } + + user.PasswordHash = SecurityUtil.Hash(newPassword); + resetPasswordToken.IsInvalidated = true; + await _applicationDbContext.SaveChangesAsync(CancellationToken.None); + await _authDbContext.SaveChangesAsync(CancellationToken.None); + } + private async Task GenerateAuthenticationResultForUserAsync(User user) { var token = CreateJweToken(user);