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

Implemented password reset. #26

Merged
merged 2 commits into from
May 7, 2024
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
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