Skip to content
This repository has been archived by the owner on Jul 9, 2024. It is now read-only.

Commit

Permalink
Implemented password reset. (#26)
Browse files Browse the repository at this point in the history
* Implemented password recovery.

* Completed password reset.
  • Loading branch information
Utar94 authored May 7, 2024
1 parent 3b6a755 commit 0c14ecc
Show file tree
Hide file tree
Showing 19 changed files with 438 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using Logitar.Master.Contracts.Accounts;
using Logitar.Portal.Contracts;
using MediatR;

namespace Logitar.Master.Application.Accounts.Commands;

public record ResetPasswordCommand(ResetPasswordPayload Payload, IEnumerable<CustomAttribute> CustomAttributes) : IRequest<ResetPasswordResult>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using FluentValidation;
using Logitar.Master.Application.Accounts.Events;
using Logitar.Master.Application.Accounts.Validators;
using Logitar.Master.Application.Constants;
using Logitar.Master.Contracts.Accounts;
using Logitar.Portal.Contracts;
using Logitar.Portal.Contracts.Messages;
using Logitar.Portal.Contracts.Realms;
using Logitar.Portal.Contracts.Sessions;
using Logitar.Portal.Contracts.Tokens;
using Logitar.Portal.Contracts.Users;
using MediatR;

namespace Logitar.Master.Application.Accounts.Commands;

internal class ResetPasswordCommandHandler : IRequestHandler<ResetPasswordCommand, ResetPasswordResult>
{
private readonly IMessageService _messageService;
private readonly IPublisher _publisher;
private readonly IRealmService _realmService;
private readonly ISessionService _sessionService;
private readonly ITokenService _tokenService;
private readonly IUserService _userService;

public ResetPasswordCommandHandler(IMessageService messageService, IPublisher publisher,
IRealmService realmService, ISessionService sessionService, ITokenService tokenService, IUserService userService)
{
_messageService = messageService;
_publisher = publisher;
_realmService = realmService;
_sessionService = sessionService;
_tokenService = tokenService;
_userService = userService;
}

public async Task<ResetPasswordResult> Handle(ResetPasswordCommand command, CancellationToken cancellationToken)
{
Realm realm = await _realmService.FindAsync(cancellationToken);

ResetPasswordPayload payload = command.Payload;
new ResetPasswordValidator(realm.PasswordSettings).ValidateAndThrow(payload);

if (!string.IsNullOrWhiteSpace(payload.EmailAddress))
{
return await HandleEmailAddressAsync(payload.EmailAddress, payload.Locale, cancellationToken);
}
else if (payload.Token != null && payload.NewPassword != null)
{
return await HandleNewPasswordAsync(payload.Token, payload.NewPassword, command.CustomAttributes, cancellationToken);
}

throw new ArgumentException($"The '{nameof(command.Payload)}' is not valid.", nameof(command));
}

private async Task<ResetPasswordResult> HandleEmailAddressAsync(string emailAddress, string locale, CancellationToken cancellationToken)
{
SentMessages sentMessages;

User? user = await _userService.FindAsync(emailAddress, cancellationToken);
if (user != null)
{
CreatedToken token = await _tokenService.CreateAsync(user.GetSubject(), TokenTypes.PasswordRecovery, cancellationToken);
Dictionary<string, string> variables = new()
{
["Token"] = token.Token
};
sentMessages = await _messageService.SendAsync(Templates.PasswordRecovery, user, ContactType.Email, locale, variables, cancellationToken);
}
else
{
sentMessages = new([Guid.NewGuid()]);
}

SentMessage sentMessage = sentMessages.ToSentMessage(new Email(emailAddress));
return ResetPasswordResult.RecoveryLinkSent(sentMessage);
}

private async Task<ResetPasswordResult> HandleNewPasswordAsync(string token, string newPassword, IEnumerable<CustomAttribute> customAttributes, CancellationToken cancellationToken)
{
ValidatedToken validatedToken = await _tokenService.ValidateAsync(token, TokenTypes.PasswordRecovery, cancellationToken);
if (validatedToken.Subject == null)
{
throw new InvalidOperationException($"The claim '{validatedToken.Subject}' is required.");
}
Guid userId = Guid.Parse(validatedToken.Subject);
_ = await _userService.FindAsync(userId, cancellationToken) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found.");
User user = await _userService.ResetPasswordAsync(userId, newPassword, cancellationToken);

return await EnsureProfileIsCompleted(user, customAttributes, cancellationToken);
}

private async Task<ResetPasswordResult> EnsureProfileIsCompleted(User user, IEnumerable<CustomAttribute> customAttributes, CancellationToken cancellationToken)
{
if (!user.IsProfileCompleted())
{
CreatedToken token = await _tokenService.CreateAsync(user.GetSubject(), TokenTypes.Profile, cancellationToken);
return ResetPasswordResult.RequireProfileCompletion(token);
}

Session session = await _sessionService.CreateAsync(user, customAttributes, cancellationToken);
await _publisher.Publish(new UserSignedInEvent(session), cancellationToken);
return ResetPasswordResult.Succeed(session);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public interface IUserService
Task<User> CreateAsync(Email email, CancellationToken cancellationToken = default);
Task<User?> FindAsync(string uniqueName, CancellationToken cancellationToken = default);
Task<User?> FindAsync(Guid userId, CancellationToken cancellationToken = default);
Task<User> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken = default);
Task<User> SaveProfileAsync(Guid userId, SaveProfilePayload payload, CancellationToken cancellationToken = default);
Task<User?> SignOutAsync(Guid userId, CancellationToken cancellationToken = default);
Task<User> UpdateEmailAsync(Guid userId, Email email, CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using FluentValidation;
using Logitar.Identity.Contracts.Settings;
using Logitar.Identity.Domain.Passwords.Validators;
using Logitar.Identity.Domain.Shared;
using Logitar.Master.Contracts.Accounts;

namespace Logitar.Master.Application.Accounts.Validators;

internal class ResetPasswordValidator : AbstractValidator<ResetPasswordPayload>
{
public ResetPasswordValidator(IPasswordSettings passwordSettings)
{
RuleFor(x => x.Locale).SetValidator(new LocaleValidator());

When(x => !string.IsNullOrWhiteSpace(x.EmailAddress), () =>
{
RuleFor(x => x.EmailAddress).NotEmpty();
RuleFor(x => x.Token).Empty();
RuleFor(x => x.NewPassword).Empty();
}).Otherwise(() =>
{
RuleFor(x => x.EmailAddress).Empty();
RuleFor(x => x.Token).NotEmpty();
RuleFor(x => x.NewPassword).NotNull();
When(x => x.NewPassword != null, () => RuleFor(x => x.NewPassword!).SetValidator(new PasswordValidator(passwordSettings)));
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace Logitar.Master.Application.Constants;
internal static class Templates
{
public const string AccountAuthentication = "AccountAuthentication";
public const string PasswordRecovery = "PasswordRecovery";

private const string ContactVerification = "ContactVerification{ContactType}";
private const string MultiFactorAuthentication = "MultiFactorAuthentication{ContactType}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
internal static class TokenTypes
{
public const string Authentication = "auth+jwt";
public const string PasswordRecovery = "reset_password+jwt";
public const string Profile = "profile+jwt";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace Logitar.Master.Contracts.Accounts;

public record ResetPasswordPayload
{
public string Locale { get; set; }

public string? EmailAddress { get; set; }

public string? Token { get; set; }
public string? NewPassword { get; set; }

public ResetPasswordPayload() : this(string.Empty)
{
}

public ResetPasswordPayload(string locale)
{
Locale = locale;
}

public ResetPasswordPayload(string locale, string emailAddress) : this(locale)
{
EmailAddress = emailAddress;
}

public ResetPasswordPayload(string locale, string token, string newPassword) : this(locale)
{
Token = token;
NewPassword = newPassword;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Logitar.Portal.Contracts.Sessions;
using Logitar.Portal.Contracts.Tokens;

namespace Logitar.Master.Contracts.Accounts;

public record ResetPasswordResult
{
public SentMessage? RecoveryLinkSentTo { get; set; }
public string? ProfileCompletionToken { get; set; }
public Session? Session { get; set; }

public ResetPasswordResult()
{
}

public static ResetPasswordResult RecoveryLinkSent(SentMessage sentMessage) => new()
{
RecoveryLinkSentTo = sentMessage
};

public static ResetPasswordResult RequireProfileCompletion(CreatedToken createdToken) => new()
{
ProfileCompletionToken = createdToken.Token
};

public static ResetPasswordResult Succeed(Session session) => new()
{
Session = session
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ public async Task<User> CreateAsync(Email email, CancellationToken cancellationT
return await _userClient.ReadAsync(userId, uniqueName: null, identifier: null, context);
}

public async Task<User> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken)
{
ResetUserPasswordPayload payload = new(newPassword);
RequestContext context = new(userId.ToString(), cancellationToken);
return await _userClient.ResetPasswordAsync(userId, payload, context) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found.");
}

public async Task<User> SaveProfileAsync(Guid userId, SaveProfilePayload profile, CancellationToken cancellationToken)
{
UpdateUserPayload payload = profile.ToUpdateUserPayload();
Expand Down
12 changes: 12 additions & 0 deletions backend/src/Logitar.Master/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ public async Task<ActionResult<GetTokenResponse>> TokenAsync([FromBody] GetToken
return Ok(response);
}

[HttpPost("password/reset")]
public async Task<ActionResult<ResetPasswordResponse>> ResetPasswordAsync([FromBody] ResetPasswordPayload payload, CancellationToken cancellationToken)
{
ResetPasswordResult result = await _requestPipeline.ExecuteAsync(new ResetPasswordCommand(payload, HttpContext.GetSessionCustomAttributes()), cancellationToken);
if (result.Session != null)
{
HttpContext.SignIn(result.Session);
}

return Ok(new ResetPasswordResponse(result));
}

[HttpPut("/phone/change")]
[Authorize]
public async Task<ActionResult<ChangePhoneResult>> ChangePhoneAsync([FromBody] ChangePhonePayload payload, CancellationToken cancellationToken)
Expand Down
25 changes: 25 additions & 0 deletions backend/src/Logitar.Master/Models/Account/ResetPasswordResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Logitar.Master.Contracts.Accounts;

namespace Logitar.Master.Models.Account;

public record ResetPasswordResponse
{
public SentMessage? RecoveryLinkSentTo { get; set; }
public string? ProfileCompletionToken { get; set; }
public CurrentUser? CurrentUser { get; set; }

public ResetPasswordResponse()
{
}

public ResetPasswordResponse(ResetPasswordResult result)
{
RecoveryLinkSentTo = result.RecoveryLinkSentTo;
ProfileCompletionToken = result.ProfileCompletionToken;

if (result.Session != null)
{
CurrentUser = new CurrentUser(result.Session.User);
}
}
}
4 changes: 4 additions & 0 deletions backend/src/Logitar.Master/Models/Account/SignInResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public record SignInResponse
{
public SentMessage? AuthenticationLinkSentTo { get; set; }
public bool IsPasswordRequired { get; set; }
public OneTimePasswordValidation? OneTimePasswordValidation { get; set; }
public string? ProfileCompletionToken { get; set; }
public CurrentUser? CurrentUser { get; set; }

public SignInResponse()
Expand All @@ -16,6 +18,8 @@ public SignInResponse(SignInCommandResult result)
{
AuthenticationLinkSentTo = result.AuthenticationLinkSentTo;
IsPasswordRequired = result.IsPasswordRequired;
OneTimePasswordValidation = result.OneTimePasswordValidation;
ProfileCompletionToken = result.ProfileCompletionToken;

if (result.Session != null)
{
Expand Down
Loading

0 comments on commit 0c14ecc

Please sign in to comment.