Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/Api/Controllers/AuthController.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,23 @@ public async Task<IActionResult> Logout()
return Ok();
}

[HttpPost("reset-password")]
public async Task<IActionResult> 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
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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; }
}
12 changes: 12 additions & 0 deletions src/Application/Common/Interfaces/IAuthDbContext.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
using Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace Application.Common.Interfaces;

public interface IAuthDbContext
{
public DbSet<RefreshToken> RefreshTokens { get; }
public DbSet<ResetPasswordToken> ResetPasswordTokens { get; }

Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
1 change: 1 addition & 0 deletions src/Application/Common/Interfaces/IIdentityService.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,4 +9,5 @@ public interface IIdentityService
Task<AuthenticationResult> 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);
}
2 changes: 1 addition & 1 deletion src/Application/Common/Interfaces/IMailService.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

namespace Application.Common.Models;

public class HTMLMailData
public class HtmlMailData
{
[JsonPropertyName("from")]
public From From { get; set; }
Expand DownExpand Up@@ -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; }
}
2 changes: 1 addition & 1 deletion src/Application/Users/Commands/AddUser.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,7 +106,7 @@ public async Task<UserDto> 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<UserDto>(result.Entity);
Expand Down
24 changes: 22 additions & 2 deletions src/Application/Users/EventHandlers/UserCreatedEventHandler.cs
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
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<UserCreatedEvent>
{
private readonly IMailService _mailService;
private readonly IAuthDbContext _authDbContext;

public UserCreatedEventHandler(IMailService mailService)
public UserCreatedEventHandler(IMailService mailService, IAuthDbContext authDbContext)
{
_mailService = mailService;
_authDbContext = authDbContext;
}

public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
{
_mailService.SendResetPasswordHtmlMail(notification.Email, notification.Password);
var expirationDate = LocalDateTime.FromDateTime(DateTime.Now.AddDays(1));

var token = Guid.NewGuid().ToString();
var resetPasswordToken = new ResetPasswordToken()
{
User = notification.User,
TokenHash = SecurityUtil.Hash(token),
ExpirationDate = expirationDate,
IsInvalidated = false,
};

await _authDbContext.ResetPasswordTokens.AddAsync(resetPasswordToken, cancellationToken);
await _authDbContext.SaveChangesAsync(cancellationToken);

_mailService.SendResetPasswordHtmlMail(notification.User.Email, notification.Password, token);
}
}
12 changes: 12 additions & 0 deletions src/Domain/Entities/ResetPasswordToken.cs
Original file line numberDiff line numberDiff line change
@@ -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; }
}
6 changes: 3 additions & 3 deletions src/Domain/Events/UserCreatedEvent.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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; }
}
3 changes: 2 additions & 1 deletion src/Infrastructure/ConfigureServices.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,8 @@ public static class ConfigureServices
public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddApplicationDbContext(configuration);
services.AddScoped<IApplicationDbContext, ApplicationDbContext>();
services.AddScoped<IApplicationDbContext>(sp => sp.GetService<ApplicationDbContext>()!);
services.AddScoped<IAuthDbContext>(sp => sp.GetService<ApplicationDbContext>()!);
services.AddScoped<IIdentityService, IdentityService>();
services.AddMailService(configuration);

Expand Down
74 changes: 55 additions & 19 deletions src/Infrastructure/Identity/IdentityService.cs
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
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;
using Application.Common.Exceptions;
using Application.Common.Interfaces;
using Application.Common.Models;
using Application.Common.Models.Dtos;
using Application.Helpers;
using Application.Users.Queries;
using AutoMapper;
using Domain.Entities;
using Infrastructure.Persistence;
using Infrastructure.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
Expand All@@ -26,16 +24,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<JweSettings> jweSettingsOptions, ApplicationDbContext context, RSA encryptionKey, ECDsa signingKey, IMapper mapper)
public IdentityService(
TokenValidationParameters tokenValidationParameters,
IOptions<JweSettings> 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;
Expand All@@ -54,7 +61,7 @@ public async Task<bool> 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));

Expand All@@ -75,7 +82,7 @@ public async Task<bool> 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)
{
Expand DownExpand Up@@ -113,7 +120,7 @@ public async Task<AuthenticationResult> 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));

Expand All@@ -123,7 +130,7 @@ public async Task<AuthenticationResult> 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)
{
Expand DownExpand Up@@ -185,7 +192,7 @@ public async Task<AuthenticationResult> 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));

Expand All@@ -194,10 +201,10 @@ public async Task<AuthenticationResult> 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<UserDto>(user));
}
Expand All@@ -213,7 +220,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;

Expand All@@ -222,8 +229,37 @@ 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);
}

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<AuthenticationResult> GenerateAuthenticationResultForUserAsync(User user)
Expand All@@ -239,8 +275,8 @@ private async Task<AuthenticationResult> 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,
Expand Down
9 changes: 5 additions & 4 deletions src/Infrastructure/Persistence/ApplicationDbContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@

namespace Infrastructure.Persistence;

public class ApplicationDbContext : DbContext, IApplicationDbContext
public class ApplicationDbContext : DbContext, IApplicationDbContext, IAuthDbContext
{
private readonly IMediator _mediator;
public ApplicationDbContext(
Expand All@@ -33,13 +33,14 @@ public ApplicationDbContext(
public DbSet<Entry> Entries => Set<Entry>();

public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<ResetPasswordToken> ResetPasswordTokens => Set<ResetPasswordToken>();

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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Infrastructure.Persistence.Configurations;

public class ResetPasswordTokenConfiguration : IEntityTypeConfiguration<ResetPasswordToken>
{
public void Configure(EntityTypeBuilder<ResetPasswordToken> 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("UserId")
.IsRequired();
}
}
Loading