diff --git a/docs/design-principles/0000-all-use-cases.md b/docs/design-principles/0000-all-use-cases.md index 226c9882..63b1e862 100644 --- a/docs/design-principles/0000-all-use-cases.md +++ b/docs/design-principles/0000-all-use-cases.md @@ -89,10 +89,10 @@ These are the main use cases of this product that are exposed via "public" APIs 1. Authenticate the current user (with a password) 2. Register a new person (with a password and with optional invitation) 3. Confirm registration of a person (from email) -4. (coming soon) Initiate a password reset -5. (coming soon) Resend password reset notification -6. (coming soon) Confirm password reset -7. (coming soon) Reset password +4. Initiate a password reset +5. Resend password reset notification +6. Verify a password reset token +7. Reset password #### Single-Sign On diff --git a/src/Application.Interfaces/UsageConstants.cs b/src/Application.Interfaces/UsageConstants.cs index fe0cfaeb..e370ad96 100644 --- a/src/Application.Interfaces/UsageConstants.cs +++ b/src/Application.Interfaces/UsageConstants.cs @@ -52,6 +52,7 @@ public static class UsageScenarios public const string UserExtendedLogin = "User Extended Login"; public const string UserLogin = "User Login"; public const string UserLogout = "User Logout"; + public const string UserPasswordReset = "User Password Reset"; } public static class Web diff --git a/src/Application.Services.Shared/INotificationsService.cs b/src/Application.Services.Shared/INotificationsService.cs index 684acd85..7ac0b105 100644 --- a/src/Application.Services.Shared/INotificationsService.cs +++ b/src/Application.Services.Shared/INotificationsService.cs @@ -23,6 +23,19 @@ Task> NotifyPasswordRegistrationConfirmationAsync(ICallerContext c /// /// Notifies a user, via email, to warn them that an attempt to re-register an account by another party has occurred /// - Task> NotifyReRegistrationCourtesyAsync(ICallerContext caller, string userId, string emailAddress, + Task> NotifyPasswordRegistrationRepeatCourtesyAsync(ICallerContext caller, string userId, + string emailAddress, string name, string? timezone, string? countryCode, CancellationToken cancellationToken); + + /// + /// Notifies a user, via email, that their password reset has been initiated + /// + Task> NotifyPasswordResetInitiatedAsync(ICallerContext caller, string name, string emailAddress, + string token, CancellationToken cancellationToken); + + /// + /// Notifies an unknown user, via email, that their email has been used to initiate a password reset + /// + Task> NotifyPasswordResetUnknownUserCourtesyAsync(ICallerContext caller, string emailAddress, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Services.Shared/IWebsiteUiService.cs b/src/Application.Services.Shared/IWebsiteUiService.cs index d3dd5cb9..dfbe6a9f 100644 --- a/src/Application.Services.Shared/IWebsiteUiService.cs +++ b/src/Application.Services.Shared/IWebsiteUiService.cs @@ -7,5 +7,7 @@ public interface IWebsiteUiService { string ConstructPasswordRegistrationConfirmationPageUrl(string token); + string ConstructPasswordResetConfirmationPageUrl(string token); + string CreateRegistrationPageUrl(string token); } \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index 96084939..3be6979b 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -170,7 +170,7 @@ public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegis cc.CallId == "acallid" && cc.IsServiceAccount ), "anid", It.IsAny())); - _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), + _notificationsService.Verify(ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -259,7 +259,7 @@ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(), cc.CallId == "acallid" && cc.IsServiceAccount ), "anid", It.IsAny())); - _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), + _notificationsService.Verify(ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -337,7 +337,7 @@ public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenReg cc.CallId == "acallid" && cc.IsServiceAccount ), "anid", It.IsAny())); - _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), + _notificationsService.Verify(ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -391,7 +391,7 @@ public async Task It.IsAny()), Times.Never); _notificationsService.Verify( - ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), It.IsAny(), + ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -429,7 +429,7 @@ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyE rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(endUser); _notificationsService.Setup(ns => - ns.NotifyReRegistrationCourtesyAsync(It.IsAny(), It.IsAny(), + ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Ok); @@ -459,7 +459,7 @@ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyE ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - _notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(_caller.Object, "anid", + _notificationsService.Verify(ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(_caller.Object, "anid", "anotheruser@company.com", "afirstname", "atimezone", "acountrycode", CancellationToken.None)); } diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index 1ccada0e..9a5ee4b5 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -261,7 +261,7 @@ public async Task> RegisterPersonAsync(ICallerC return Error.EntityNotFound(Resources.EndUsersApplication_NotPersonProfile); } - var notified = await _notificationsService.NotifyReRegistrationCourtesyAsync(context, + var notified = await _notificationsService.NotifyPasswordRegistrationRepeatCourtesyAsync(context, unregisteredUser.Id, unregisteredUserProfile.EmailAddress, unregisteredUserProfile.DisplayName, unregisteredUserProfile.Timezone, unregisteredUserProfile.Address.CountryCode, cancellationToken); if (!notified.IsSuccessful) diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs index 032c5c8f..7a887458 100644 --- a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs @@ -25,6 +25,7 @@ namespace IdentityApplication.UnitTests; [Trait("Category", "Unit")] public class PasswordCredentialsApplicationSpec { + private const string TestingToken = "Ll4qhv77XhiXSqsTUc6icu56ZLrqu5p1gH9kT5IlHio"; private readonly PasswordCredentialsApplication _application; private readonly Mock _authTokensService; private readonly Mock _caller; @@ -398,7 +399,7 @@ public async Task WhenRegisterPersonAsyncAndNotExists_ThenCreatesAndSendsConfirm && uc.Registration.Value.EmailAddress == "auser@company.com" && uc.Password.PasswordHash == "apasswordhash" && uc.Login.Exists() - && !uc.Verification.IsVerified + && !uc.VerificationKeep.IsVerified ), It.IsAny())); _notificationsService.Verify(ns => ns.NotifyPasswordRegistrationConfirmationAsync(_caller.Object, "auser@company.com", "adisplayname", @@ -411,7 +412,9 @@ public async Task WhenRegisterPersonAsyncAndNotExists_ThenCreatesAndSendsConfirm [Fact] public async Task WhenConfirmPersonRegistrationAsyncAndTokenUnknown_ThenReturnsError() { - _repository.Setup(s => s.FindCredentialsByTokenAsync(It.IsAny(), It.IsAny())) + _repository.Setup(s => + s.FindCredentialsByRegistrationVerificationTokenAsync(It.IsAny(), + It.IsAny())) .Returns(Task.FromResult, Error>>(Optional .None)); @@ -427,7 +430,9 @@ public async Task WhenConfirmPersonRegistrationAsyncAndTokenUnknown_ThenReturnsE public async Task WhenConfirmPersonRegistrationAsync_ThenReturnsSuccess() { var credential = CreateUnVerifiedCredential(); - _repository.Setup(s => s.FindCredentialsByTokenAsync(It.IsAny(), It.IsAny())) + _repository.Setup(s => + s.FindCredentialsByRegistrationVerificationTokenAsync(It.IsAny(), + It.IsAny())) .Returns(Task.FromResult, Error>>(credential.ToOptional())); var result = @@ -440,10 +445,174 @@ public async Task WhenConfirmPersonRegistrationAsync_ThenReturnsSuccess() ), It.IsAny())); } + [Fact] + public async Task WhenInitiatePasswordRequestAndUnknownEmailAddress_ThenSendsCourtesyNotification() + { + _repository.Setup(s => s.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.InitiatePasswordResetAsync(_caller.Object, "user@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetUnknownUserCourtesyAsync(_caller.Object, "user@company.com", CancellationToken.None)); + } + + [Fact] + public async Task WhenInitiatePasswordRequest_ThenSendsNotification() + { + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(TestingToken); + _repository.Setup(s => s.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateVerifiedCredential().ToOptional()); + + var result = + await _application.InitiatePasswordResetAsync(_caller.Object, "user@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.Is(cred => + cred.IsPasswordSet + ), It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(_caller.Object, "aname", "user@company.com", TestingToken, + It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetUnknownUserCourtesyAsync(It.IsAny(), "user@company.com", + CancellationToken.None), Times.Never); + } + + [Fact] + public async Task WhenResendPasswordRequestAndUnknownToken_ThenReturnsError() + { + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.ResendPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenResendPasswordRequest_ThenResendsNotification() + { + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(TestingToken); + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateVerifiedCredential().ToOptional()); + + var result = + await _application.ResendPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.Is(cred => + cred.IsPasswordResetInitiated + ), It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(_caller.Object, "aname", "auser@company.com", + TestingToken, It.IsAny())); + } + + [Fact] + public async Task WhenVerifyPasswordResetAsyncAndUnknownToken_ThenReturnsError() + { + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.VerifyPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenVerifyPasswordResetAsync_ThenVerifies() + { + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(TestingToken); + var credential = CreateVerifiedCredential(); + credential.InitiatePasswordReset(); + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(credential.ToOptional()); + + var result = + await _application.VerifyPasswordResetAsync(_caller.Object, TestingToken, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenCompletePasswordResetAsyncAndUnknownToken_ThenReturnsError() + { + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.CompletePasswordResetAsync(_caller.Object, "atoken", "apassword", + CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenCompletePasswordResetAsync_ThenCompletes() + { + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(TestingToken); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); + + var credential = CreateVerifiedCredential(); + credential.InitiatePasswordReset(); + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(credential.ToOptional()); + + var result = + await _application.CompletePasswordResetAsync(_caller.Object, TestingToken, "2Password!", + CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.Is(creds => + !creds.IsPasswordResetInitiated + ), It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + private PasswordCredentialRoot CreateUnVerifiedCredential() { var credential = CreateCredential(); - credential.SetCredential("apassword"); + credential.SetPasswordCredential("apassword"); credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, PersonDisplayName.Create("aname").Value); credential.InitiateRegistrationVerification(); diff --git a/src/IdentityApplication/IPasswordCredentialsApplication.cs b/src/IdentityApplication/IPasswordCredentialsApplication.cs index eec814dc..fdabec86 100644 --- a/src/IdentityApplication/IPasswordCredentialsApplication.cs +++ b/src/IdentityApplication/IPasswordCredentialsApplication.cs @@ -9,6 +9,9 @@ public interface IPasswordCredentialsApplication Task> AuthenticateAsync(ICallerContext context, string username, string password, CancellationToken cancellationToken); + Task> CompletePasswordResetAsync(ICallerContext caller, string token, string password, + CancellationToken cancellationToken); + Task> ConfirmPersonRegistrationAsync(ICallerContext context, string token, CancellationToken cancellationToken); @@ -17,8 +20,17 @@ Task> GetPersonRegistrationConfirm string userId, CancellationToken cancellationToken); #endif + Task> InitiatePasswordResetAsync(ICallerContext caller, string emailAddress, + CancellationToken cancellationToken); + Task> RegisterPersonAsync(ICallerContext context, string? invitationToken, string firstName, string lastName, string emailAddress, string password, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken); + + Task> ResendPasswordResetAsync(ICallerContext caller, string token, + CancellationToken cancellationToken); + + Task> VerifyPasswordResetAsync(ICallerContext caller, string token, CancellationToken + cancellationToken); } \ No newline at end of file diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs index 014f96e9..413230e6 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -188,6 +188,55 @@ async Task> VerifyPasswordAsync() } } + public async Task> InitiatePasswordResetAsync(ICallerContext caller, string emailAddress, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindCredentialsByUsernameAsync(emailAddress, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + var warned = + await _notificationsService.NotifyPasswordResetUnknownUserCourtesyAsync(caller, emailAddress, + cancellationToken); + if (!warned.IsSuccessful) + { + return warned.Error; + } + + return Result.Ok; + } + + var credentials = retrieved.Value.Value; + var initiated = credentials.InitiatePasswordReset(); + if (!initiated.IsSuccessful) + { + return initiated.Error; + } + + var registration = credentials.Registration.Value; + var notified = await _notificationsService.NotifyPasswordResetInitiatedAsync(caller, registration.Name, + emailAddress, credentials.Password.ResetToken, cancellationToken); + if (!notified.IsSuccessful) + { + return notified.Error; + } + + var saved = await _repository.SaveAsync(credentials, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + credentials = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Password reset initiated for {Id}", credentials.UserId); + + return Result.Ok; + } + public async Task> RegisterPersonAsync(ICallerContext context, string? invitationToken, string firstName, string lastName, string emailAddress, string password, string? timezone, string? countryCode, @@ -205,7 +254,112 @@ public async Task> RegisterPersonAsync(ICaller return await RegisterPersonInternalAsync(context, password, registered.Value, cancellationToken); } + public async Task> ResendPasswordResetAsync(ICallerContext caller, string token, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var credentials = retrieved.Value.Value; + var initiated = credentials.InitiatePasswordReset(); + if (!initiated.IsSuccessful) + { + return initiated.Error; + } + + var registration = credentials.Registration.Value; + var notified = await _notificationsService.NotifyPasswordResetInitiatedAsync(caller, registration.Name, + registration.EmailAddress, credentials.Password.ResetToken, cancellationToken); + if (!notified.IsSuccessful) + { + return notified.Error; + } + + var saved = await _repository.SaveAsync(credentials, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + credentials = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Password reset re-initiated for {Id}", credentials.UserId); + + return Result.Ok; + } + + public async Task> VerifyPasswordResetAsync(ICallerContext caller, string token, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var credentials = retrieved.Value.Value; + var verified = credentials.VerifyPasswordReset(token); + if (!verified.IsSuccessful) + { + return verified.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Password reset verified for {Id}", credentials.UserId); + + return Result.Ok; + } + #if TESTINGONLY + public async Task> CompletePasswordResetAsync(ICallerContext caller, string token, string password, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var credentials = retrieved.Value.Value; + var reset = credentials.CompletePasswordReset(token, password); + if (!reset.IsSuccessful) + { + return reset.Error; + } + + var saved = await _repository.SaveAsync(credentials, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + credentials = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Password was reset for {Id}", credentials.UserId); + _recorder.TrackUsage(caller.ToCall(), UsageConstants.Events.UsageScenarios.UserPasswordReset, + new Dictionary + { + { nameof(credentials.Id), credentials.UserId } + }); + + return Result.Ok; + } + public async Task> GetPersonRegistrationConfirmationAsync( ICallerContext context, string userId, CancellationToken cancellationToken) @@ -224,8 +378,8 @@ public async Task> GetPersonRegist var credential = retrieved.Value.Value; return new PasswordCredentialConfirmation { - Token = credential.Verification.Token, - Url = _websiteUiService.ConstructPasswordRegistrationConfirmationPageUrl(credential.Verification.Token) + Token = credential.VerificationKeep.Token, + Url = _websiteUiService.ConstructPasswordRegistrationConfirmationPageUrl(credential.VerificationKeep.Token) }; } #endif @@ -233,7 +387,7 @@ public async Task> GetPersonRegist public async Task> ConfirmPersonRegistrationAsync(ICallerContext context, string token, CancellationToken cancellationToken) { - var retrieved = await _repository.FindCredentialsByTokenAsync(token, cancellationToken); + var retrieved = await _repository.FindCredentialsByRegistrationVerificationTokenAsync(token, cancellationToken); if (!retrieved.IsSuccessful) { return retrieved.Error; @@ -303,7 +457,7 @@ private async Task> RegisterPersonInternalAsyn } var credentials = created.Value; - var credentialed = credentials.SetCredential(password); + var credentialed = credentials.SetPasswordCredential(password); if (!credentialed.IsSuccessful) { return credentialed.Error; @@ -323,7 +477,7 @@ private async Task> RegisterPersonInternalAsyn var notified = await _notificationsService.NotifyPasswordRegistrationConfirmationAsync(context, credentials.Registration.Value.EmailAddress, - credentials.Registration.Value.Name, credentials.Verification.Token, cancellationToken); + credentials.Registration.Value.Name, credentials.VerificationKeep.Token, cancellationToken); if (!notified.IsSuccessful) { return notified.Error; diff --git a/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs b/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs index 5ab13f44..27f86c80 100644 --- a/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs +++ b/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs @@ -7,7 +7,11 @@ namespace IdentityApplication.Persistence; public interface IPasswordCredentialsRepository : IApplicationRepository { - Task, Error>> FindCredentialsByTokenAsync(string token, + Task, Error>> FindCredentialsByPasswordResetTokenAsync(string token, + CancellationToken cancellationToken); + + Task, Error>> FindCredentialsByRegistrationVerificationTokenAsync( + string token, CancellationToken cancellationToken); Task, Error>> FindCredentialsByUserIdAsync(Identifier userId, diff --git a/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs b/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs index 1725b49f..0a22014e 100644 --- a/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs +++ b/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs @@ -80,7 +80,7 @@ public void WhenInitiateRegistrationVerificationAndNotVerified_ThenInitiates() { _credential.InitiateRegistrationVerification(); - _credential.Verification.IsStillVerifying.Should().BeTrue(); + _credential.VerificationKeep.IsStillVerifying.Should().BeTrue(); _credential.Events.Last().Should() .BeOfType(); } @@ -91,7 +91,7 @@ public void WhenSetCredentialAndInvalidPassword_ThenReturnsError() _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) .Returns(false); - var result = _credential.SetCredential("notavalidpassword"); + var result = _credential.SetPasswordCredential("notavalidpassword"); result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword); } @@ -99,7 +99,7 @@ public void WhenSetCredentialAndInvalidPassword_ThenReturnsError() [Fact] public void WhenSetCredentials_ThenSetsCredentials() { - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); _credential.Password.PasswordHash.Should().Be("apasswordhash"); _credential.Events.Last().Should().BeOfType(); @@ -138,7 +138,7 @@ public void WhenVerifyPasswordAndWrongPasswordAndAudit_ThenAuditsFailedLogin() .Returns(true); _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) .Returns(false); - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); var result = _credential.VerifyPassword("1WrongPassword!"); result.Should().BeSuccess(); @@ -155,7 +155,7 @@ public void WhenVerifyPasswordAndAndAudit_ThenResetsLoginMonitor() { _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) .Returns(true); - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); var result = _credential.VerifyPassword("apassword"); result.Should().BeSuccess(); @@ -173,7 +173,7 @@ public void WhenVerifyPasswordAndFailsAndLocksAccount_ThenLocksLogin() { _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) .Returns(false); - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); #if TESTINGONLY _credential.TestingOnly_LockAccount("awrongpassword"); #endif @@ -191,7 +191,7 @@ public void WhenVerifyPasswordAndSucceedsAfterCooldown_ThenUnlocksCredentials() { _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) .Returns(false); - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); #if TESTINGONLY _credential.TestingOnly_LockAccount("awrongpassword"); _credential.TestingOnly_ResetLoginCooldownPeriod(); @@ -241,7 +241,7 @@ public void WhenVerifyRegistration_ThenVerified() _credential.VerifyRegistration(); - _credential.Verification.IsVerified.Should().BeTrue(); + _credential.VerificationKeep.IsVerified.Should().BeTrue(); _credential.Events.Last().Should().BeOfType(); } @@ -277,7 +277,7 @@ public void WhenInitiatePasswordReset_ThenInitiated() { _tokensService.Setup(ts => ts.CreatePasswordResetToken()) .Returns(Token); - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, PersonDisplayName.Create("aname").Value); _credential.InitiateRegistrationVerification(); @@ -285,19 +285,19 @@ public void WhenInitiatePasswordReset_ThenInitiated() _credential.InitiatePasswordReset(); - _credential.Password.IsInitiating.Should().BeTrue(); + _credential.Password.IsResetInitiated.Should().BeTrue(); _credential.Events[1].Should().BeOfType(); _credential.Events[2].Should().BeOfType(); _credential.Events.Last().Should().BeOfType(); } [Fact] - public void WhenResetPasswordWithInvalidPassword_ThenReturnsError() + public void WhenCompletePasswordResetWithInvalidPassword_ThenReturnsError() { _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) .Returns(false); - var result = _credential.ResetPassword(Token, "apassword"); + var result = _credential.CompletePasswordReset(Token, "apassword"); result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword); @@ -305,12 +305,12 @@ public void WhenResetPasswordWithInvalidPassword_ThenReturnsError() } [Fact] - public void WhenResetPasswordAndNoExistingPassword_ThenReturnsError() + public void WhenCompletePasswordResetAndNoExistingPassword_ThenReturnsError() { _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) .Returns(true); - var result = _credential.ResetPassword(Token, "apassword"); + var result = _credential.CompletePasswordReset(Token, "apassword"); result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsRoot_NoPassword); @@ -318,15 +318,15 @@ public void WhenResetPasswordAndNoExistingPassword_ThenReturnsError() } [Fact] - public void WhenResetPasswordAndSamePassword_ThenReturnsError() + public void WhenCompletePasswordResetAndSameAsOldPassword_ThenReturnsError() { _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) .Returns(true); _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) - .Returns(false); - _credential.SetCredential("apassword"); + .Returns(true); + _credential.SetPasswordCredential("apassword"); - var result = _credential.ResetPassword(Token, "apassword"); + var result = _credential.CompletePasswordReset(Token, "apassword"); result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_DuplicatePassword); @@ -334,30 +334,30 @@ public void WhenResetPasswordAndSamePassword_ThenReturnsError() } [Fact] - public void WhenResetPasswordAndExpired_ThenReturnsError() + public void WhenCompletePasswordResetAndExpired_ThenReturnsError() { _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) .Returns(true); _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) - .Returns(true); - _credential.SetCredential("apassword"); + .Returns(false); + _credential.SetPasswordCredential("apassword"); _credential.InitiateRegistrationVerification(); _credential.VerifyRegistration(); #if TESTINGONLY _credential.TestingOnly_ExpirePasswordResetVerification(); #endif - var result = _credential.ResetPassword("atoken", "apassword"); + var result = _credential.CompletePasswordReset("atoken", "apassword"); result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsRoot_PasswordResetTokenExpired); } [Fact] - public void WhenResetPasswordAndCredentialsLocked_ThenResetsPasswordAndUnlocks() + public void WhenCompletePasswordResetAndCredentialsLocked_ThenResetsPasswordAndUnlocks() { _tokensService.Setup(ts => ts.CreatePasswordResetToken()) .Returns(Token); - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) .Returns(false); #if TESTINGONLY @@ -369,9 +369,9 @@ public void WhenResetPasswordAndCredentialsLocked_ThenResetsPasswordAndUnlocks() _credential.VerifyRegistration(); _credential.InitiatePasswordReset(); _passwordHasherService.Setup(es => es.VerifyPassword(It.IsAny(), It.IsAny())) - .Returns(true); + .Returns(false); - _credential.ResetPassword(_credential.Password.Token, "anewpassword"); + _credential.CompletePasswordReset(_credential.Password.ResetToken, "anewpassword"); _passwordHasherService.Verify(ph => ph.ValidatePassword("apassword", true)); _passwordHasherService.Verify(ph => ph.ValidatePassword("anewpassword", false)); @@ -382,7 +382,7 @@ public void WhenResetPasswordAndCredentialsLocked_ThenResetsPasswordAndUnlocks() } [Fact] - public void WhenResetPassword_ThenResetsPassword() + public void WhenCompletePasswordReset_ThenResetsPassword() { _tokensService.Setup(ts => ts.CreatePasswordResetToken()) .Returns(Token); @@ -390,15 +390,15 @@ public void WhenResetPassword_ThenResetsPassword() .Returns(true); _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) .Returns(true); - _credential.SetCredential("apassword"); - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); + _credential.SetPasswordCredential("apassword"); _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, PersonDisplayName.Create("aname").Value); _credential.InitiateRegistrationVerification(); _credential.VerifyRegistration(); _credential.InitiatePasswordReset(); - _credential.ResetPassword(_credential.Password.Token, "anewpassword"); + _credential.CompletePasswordReset(_credential.Password.ResetToken, "anewpassword"); _passwordHasherService.Verify(ph => ph.ValidatePassword("apassword", true)); _passwordHasherService.Verify(ph => ph.ValidatePassword("anewpassword", false)); } @@ -421,7 +421,7 @@ public void WhenEnsureInvariantsAndInitiatingPasswordResetButUnRegistered_ThenRe { _tokensService.Setup(ts => ts.CreatePasswordResetToken()) .Returns(Token); - _credential.SetCredential("apassword"); + _credential.SetPasswordCredential("apassword"); _credential.InitiateRegistrationVerification(); _credential.VerifyRegistration(); _credential.InitiatePasswordReset(); diff --git a/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs b/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs index ae334fdb..6c28e39a 100644 --- a/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs +++ b/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs @@ -30,7 +30,7 @@ public void WhenConstructed_ThenPropertiesAssigned() var password = PasswordKeep.Create().Value; password.PasswordHash.Should().BeNone(); - password.Token.Should().BeNone(); + password.ResetToken.Should().BeNone(); password.TokenExpiresUtc.Should().BeNone(); } @@ -56,7 +56,7 @@ public void WhenConstructedWithHash_ThenPropertiesAssigned() var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; password.PasswordHash.Should().Be("apasswordhash"); - password.Token.Should().BeNone(); + password.ResetToken.Should().BeNone(); password.TokenExpiresUtc.Should().BeNone(); } @@ -78,7 +78,7 @@ public void WhenInitiatePasswordReset_ThenCreatesResetToken() password = password.InitiatePasswordReset(Token).Value; password.PasswordHash.Should().Be("apasswordhash"); - password.Token.Should().Be(Token); + password.ResetToken.Should().Be(Token); password.TokenExpiresUtc.Should().BeNear(DateTime.UtcNow.Add(PasswordKeep.DefaultResetExpiry)); } @@ -93,7 +93,7 @@ public void WhenInitiatePasswordResetTwice_ThenCreatesNewResetToken() password = password.InitiatePasswordReset(token2).Value; password.PasswordHash.Should().Be("apasswordhash"); - password.Token.Should().Be(token2); + password.ResetToken.Should().Be(token2); password.TokenExpiresUtc.Should().BeNear(DateTime.UtcNow.Add(PasswordKeep.DefaultResetExpiry)); } @@ -161,40 +161,40 @@ public void WhenVerifyAndMatchesHash_ThenReturnsTrue() } [Fact] - public void WhenConfirmResetWithEmptyToken_ThenReturnsError() + public void WhenVerifyResetWithEmptyToken_ThenReturnsError() { var password = PasswordKeep.Create().Value; - var result = password.ConfirmReset(string.Empty); + var result = password.VerifyReset(string.Empty); result.Should().BeError(ErrorCode.Validation); } [Fact] - public void WhenConfirmResetWithInvalidToken_ThenReturnsError() + public void WhenVerifyResetWithInvalidToken_ThenReturnsError() { var password = PasswordKeep.Create().Value; - var result = password.ConfirmReset("aninvalidtoken"); + var result = password.VerifyReset("aninvalidtoken"); result.Should().BeError(ErrorCode.Validation, Resources.PasswordKeep_InvalidToken); } [Fact] - public void WhenConfirmResetAndTokensNotMatch_ThenReturnsError() + public void WhenVerifyResetAndTokensNotMatch_ThenReturnsError() { const string token1 = "5n6nA42SQrsO1UIgc7lIVebR6_3CmZwcthUEx3nF2sM"; const string token2 = "7n6nA42SQrsO1UIgc7lIVebR6_3CmZwcthUEx3nF2sM"; var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; password = password.InitiatePasswordReset(token1).Value; - var result = password.ConfirmReset(token2); + var result = password.VerifyReset(token2); result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_TokensNotMatch); } [Fact] - public void WhenConfirmResetAndTokenExpired_ThenReturnsError() + public void WhenVerifyResetAndTokenExpired_ThenReturnsError() { var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; password = password.InitiatePasswordReset(Token).Value; @@ -202,58 +202,58 @@ public void WhenConfirmResetAndTokenExpired_ThenReturnsError() password = password.TestingOnly_ExpireToken(); #endif - var result = password.ConfirmReset(Token); + var result = password.VerifyReset(Token); result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordKeep_TokenExpired); } [Fact] - public void WhenResetPasswordAndEmptyPasswordHash_ThenReturnsError() + public void WhenCompletePasswordResetAndEmptyPasswordHash_ThenReturnsError() { var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; - var result = password.ResetPassword(_passwordHasherService.Object, Token, string.Empty); + var result = password.CompletePasswordReset(_passwordHasherService.Object, Token, string.Empty); result.Should().BeError(ErrorCode.Validation); } [Fact] - public void WhenResetPasswordAndTokenInvalid_ThenReturnsError() + public void WhenCompletePasswordResetAndTokenInvalid_ThenReturnsError() { var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; - var result = password.ResetPassword(_passwordHasherService.Object, "aninvalidtoken", "apassword"); + var result = password.CompletePasswordReset(_passwordHasherService.Object, "aninvalidtoken", "apassword"); result.Should().BeError(ErrorCode.Validation, Resources.PasswordKeep_InvalidToken); } [Fact] - public void WhenResetPasswordAndPasswordHashInvalid_ThenReturnsError() + public void WhenCompletePasswordResetAndPasswordHashInvalid_ThenReturnsError() { var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; _passwordHasherService.Setup(ph => ph.ValidatePasswordHash(It.IsAny())) .Returns(false); - var result = password.ResetPassword(_passwordHasherService.Object, Token, "aninvalidpasswordhash"); + var result = password.CompletePasswordReset(_passwordHasherService.Object, Token, "aninvalidpasswordhash"); result.Should().BeError(ErrorCode.Validation, Resources.PasswordKeep_InvalidPasswordHash); } [Fact] - public void WhenResetPasswordAndTokenNotMatch_ThenReturnsError() + public void WhenCompletePasswordResetAndTokenNotMatch_ThenReturnsError() { const string token1 = "5n6nA42SQrsO1UIgc7lIVebR6_3CmZwcthUEx3nF2sM"; const string token2 = "7n6nA42SQrsO1UIgc7lIVebR6_3CmZwcthUEx3nF2sM"; var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; password = password.InitiatePasswordReset(token1).Value; - var result = password.ResetPassword(_passwordHasherService.Object, token2, "apasswordhash"); + var result = password.CompletePasswordReset(_passwordHasherService.Object, token2, "apasswordhash"); result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_TokensNotMatch); } [Fact] - public void WhenResetPasswordAndTokenExpired_ThenReturnsError() + public void WhenCompletePasswordResetAndTokenExpired_ThenReturnsError() { var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value; password = password.InitiatePasswordReset(Token).Value; @@ -261,31 +261,32 @@ public void WhenResetPasswordAndTokenExpired_ThenReturnsError() password = password.TestingOnly_ExpireToken(); #endif - var result = password.ResetPassword(_passwordHasherService.Object, Token, "apasswordhash"); + var result = password.CompletePasswordReset(_passwordHasherService.Object, Token, "apasswordhash"); result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordKeep_TokenExpired); } [Fact] - public void WhenResetPasswordAndNoPasswordSet_ThenReturnsError() + public void WhenCompletePasswordResetAndNoPasswordSet_ThenReturnsError() { var password = PasswordKeep.Create().Value; - var result = password.ResetPassword(_passwordHasherService.Object, Token, "apasswordhash"); + var result = password.CompletePasswordReset(_passwordHasherService.Object, Token, "apasswordhash"); result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordKeep_NoPasswordHash); } [Fact] - public void WhenResetPassword_ThenReturnsNewPassword() + public void WhenCompletePasswordReset_ThenReturnsNewPassword() { var password = PasswordKeep.Create(_passwordHasherService.Object, "apasswordhash").Value .InitiatePasswordReset(Token).Value; - password = password.ResetPassword(_passwordHasherService.Object, password.Token, "apasswordhash").Value; + password = password.CompletePasswordReset(_passwordHasherService.Object, password.ResetToken, "apasswordhash") + .Value; password.PasswordHash.Should().Be("apasswordhash"); - password.Token.Should().BeNone(); + password.ResetToken.Should().BeNone(); password.TokenExpiresUtc.Should().BeNone(); } } \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/VerificationSpec.cs b/src/IdentityDomain.UnitTests/VerificationKeepSpec.cs similarity index 76% rename from src/IdentityDomain.UnitTests/VerificationSpec.cs rename to src/IdentityDomain.UnitTests/VerificationKeepSpec.cs index d70b66b6..bdce1313 100644 --- a/src/IdentityDomain.UnitTests/VerificationSpec.cs +++ b/src/IdentityDomain.UnitTests/VerificationKeepSpec.cs @@ -6,12 +6,12 @@ namespace IdentityDomain.UnitTests; [Trait("Category", "Unit")] -public class VerificationSpec +public class VerificationKeepSpec { [Fact] public void WhenConstructed_ThenIsNotSet() { - var invitation = Verification.Create().Value; + var invitation = VerificationKeep.Create().Value; invitation.IsStillVerifying.Should().BeFalse(); } @@ -19,7 +19,7 @@ public void WhenConstructed_ThenIsNotSet() [Fact] public void WhenIsStillValidAndNoToken_ThenReturnsFalse() { - var invitation = Verification.Create().Value; + var invitation = VerificationKeep.Create().Value; invitation.IsStillVerifying.Should().BeFalse(); invitation.Token.Should().BeNone(); @@ -29,11 +29,11 @@ public void WhenIsStillValidAndNoToken_ThenReturnsFalse() [Fact] public void WhenIsStillValidAfterSet_ThenReturnsTrue() { - var invitation = Verification.Create().Value; + var invitation = VerificationKeep.Create().Value; invitation = invitation.Renew("atoken"); invitation.IsStillVerifying.Should().BeTrue(); ((object)invitation.Token).Should().Be("atoken"); - invitation.ExpiresUtc.Value.Should().BeNear(DateTime.UtcNow.Add(Verification.DefaultTokenExpiry)); + invitation.ExpiresUtc.Value.Should().BeNear(DateTime.UtcNow.Add(VerificationKeep.DefaultTokenExpiry)); } } \ No newline at end of file diff --git a/src/IdentityDomain/PasswordCredentialRoot.cs b/src/IdentityDomain/PasswordCredentialRoot.cs index e11536d0..a6e87466 100644 --- a/src/IdentityDomain/PasswordCredentialRoot.cs +++ b/src/IdentityDomain/PasswordCredentialRoot.cs @@ -56,17 +56,19 @@ private PasswordCredentialRoot(IRecorder recorder, IIdentifierFactory idFactory, public bool IsLocked => Login.IsLocked; - public bool IsPasswordInitiated => Password.IsInitiated; + public bool IsPasswordResetInitiated => Password.IsResetInitiated; - public bool IsPasswordResetStillValid => Password.IsInitiatingStillValid; + public bool IsPasswordResetStillValid => Password.IsResetStillValid; - public bool IsRegistrationVerified => Verification.IsVerified; + public bool IsPasswordSet => Password.HasPassword; - public bool IsVerificationStillVerifying => Verification.IsStillVerifying; + public bool IsRegistrationVerified => VerificationKeep.IsVerified; - public bool IsVerificationVerifying => Verification.IsVerifying; + public bool IsVerificationStillVerifying => VerificationKeep.IsStillVerifying; - public bool IsVerified => Verification.IsVerified; + public bool IsVerificationVerifying => VerificationKeep.IsVerifying; + + public bool IsVerified => VerificationKeep.IsVerified; public LoginMonitor Login { get; private set; } @@ -76,7 +78,7 @@ private PasswordCredentialRoot(IRecorder recorder, IIdentifierFactory idFactory, public Identifier UserId { get; private set; } = Identifier.Empty(); - public Verification Verification { get; private set; } = Verification.Create().Value; + public VerificationKeep VerificationKeep { get; private set; } = VerificationKeep.Create().Value; public static AggregateRootFactory Rehydrate() { @@ -106,7 +108,7 @@ public override Result EnsureInvariants() } if (!Registration.HasValue - && Password.IsInitiating) + && Password.IsResetInitiated) { return Error.RuleViolation(Resources.PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration); } @@ -178,14 +180,14 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case RegistrationVerificationCreated created: { - Verification = Verification.Renew(created.Token); + VerificationKeep = VerificationKeep.Renew(created.Token); Recorder.TraceDebug(null, "Password credential {Id} verification has been renewed", Id); return Result.Ok; } case RegistrationVerificationVerified _: { - Verification = Verification.Verify(); + VerificationKeep = VerificationKeep.Verify(); Recorder.TraceDebug(null, "Password credential {Id} has been verified", Id); return Result.Ok; } @@ -205,7 +207,7 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case PasswordResetCompleted changed: { - var reset = Password.ResetPassword(_passwordHasherService, changed.Token, changed.PasswordHash); + var reset = Password.CompletePasswordReset(_passwordHasherService, changed.Token, changed.PasswordHash); if (!reset.IsSuccessful) { return reset.Error; @@ -222,48 +224,7 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco } } - public Result ConfirmPasswordReset(string token) - { - if (token.IsNotValuedParameter(nameof(token), out var error)) - { - return error; - } - - var confirmed = Password.ConfirmReset(token); - if (!confirmed.IsSuccessful) - { - return confirmed.Error; - } - - Password = confirmed.Value; - - return Result.Ok; - } - - public Result InitiatePasswordReset() - { - if (!IsRegistrationVerified) - { - return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationUnverified); - } - - var token = _tokensService.CreatePasswordResetToken(); - return RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.PasswordResetInitiated(Id, token)); - } - - public Result InitiateRegistrationVerification() - { - if (IsVerified) - { - return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationVerified); - } - - var token = _tokensService.CreateRegistrationVerificationToken(); - return RaiseChangeEvent( - IdentityDomain.Events.PasswordCredentials.RegistrationVerificationCreated(Id, token)); - } - - public Result ResetPassword(string token, string password) + public Result CompletePasswordReset(string token, string password) { if (token.IsNotValuedParameter(nameof(token), out var error1)) { @@ -276,12 +237,12 @@ public Result ResetPassword(string token, string password) return error2; } - if (!IsPasswordInitiated) + if (!IsPasswordSet) { return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_NoPassword); } - if (password.IsInvalidParameter(pwd => _passwordHasherService.VerifyPassword(pwd, Password.PasswordHash), + if (password.IsInvalidParameter(pwd => !_passwordHasherService.VerifyPassword(pwd, Password.PasswordHash), nameof(password), Resources.PasswordCredentialsRoot_DuplicatePassword, out var error3)) { return error3; @@ -298,18 +259,45 @@ public Result ResetPassword(string token, string password) } var passwordHash = _passwordHasherService.HashPassword(password); - RaiseChangeEvent( + var completed = RaiseChangeEvent( IdentityDomain.Events.PasswordCredentials.PasswordResetCompleted(Id, token, passwordHash)); + if (!completed.IsSuccessful) + { + return completed.Error; + } if (Login.HasJustUnlocked) { - RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.AccountUnlocked(Id)); + return RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.AccountUnlocked(Id)); } return Result.Ok; } - public Result SetCredential(string password) + public Result InitiatePasswordReset() + { + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationUnverified); + } + + var token = _tokensService.CreatePasswordResetToken(); + return RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.PasswordResetInitiated(Id, token)); + } + + public Result InitiateRegistrationVerification() + { + if (IsVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationVerified); + } + + var token = _tokensService.CreateRegistrationVerificationToken(); + return RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.RegistrationVerificationCreated(Id, token)); + } + + public Result SetPasswordCredential(string password) { if (password.IsInvalidParameter(pwd => _passwordHasherService.ValidatePassword(pwd, true), nameof(password), Resources.PasswordCredentialsRoot_InvalidPassword, out var error1)) @@ -338,7 +326,7 @@ public void TestingOnly_ExpirePasswordResetVerification() #if TESTINGONLY public void TestingOnly_ExpireRegistrationVerification() { - Verification = Verification.TestingOnly_ExpireToken(); + VerificationKeep = VerificationKeep.TestingOnly_ExpireToken(); } #endif @@ -353,7 +341,7 @@ public void TestingOnly_LockAccount(string wrongPassword) #if TESTINGONLY public void TestingOnly_RenewVerification(string token) { - Verification = Verification.Renew(token); + VerificationKeep = VerificationKeep.Renew(token); } #endif @@ -415,6 +403,19 @@ public Result VerifyPassword(string password, bool auditAttempt = t return isVerified; } + public Result VerifyPasswordReset(string token) + { + var verified = Password.VerifyReset(token); + if (!verified.IsSuccessful) + { + return verified.Error; + } + + Password = verified.Value; + + return Result.Ok; + } + public Result VerifyRegistration() { if (!IsVerificationStillVerifying) diff --git a/src/IdentityDomain/PasswordKeep.cs b/src/IdentityDomain/PasswordKeep.cs index e1e930fb..4eedbd02 100644 --- a/src/IdentityDomain/PasswordKeep.cs +++ b/src/IdentityDomain/PasswordKeep.cs @@ -28,25 +28,23 @@ public static Result Create(IPasswordHasherService password return new PasswordKeep(passwordHash, Optional.None, Optional.None); } - private PasswordKeep(Optional passwordHash, Optional token, + private PasswordKeep(Optional passwordHash, Optional resetToken, Optional tokenExpiresUtc) { PasswordHash = passwordHash; - Token = token; + ResetToken = resetToken; TokenExpiresUtc = tokenExpiresUtc; } - public bool IsInitiated => PasswordHash.HasValue; + public bool HasPassword => PasswordHash.HasValue; - public bool IsInitiating => Token.HasValue && TokenExpiresUtc.HasValue; + public bool IsResetInitiated => ResetToken.HasValue && TokenExpiresUtc.HasValue; - public bool IsInitiatingStillValid => IsInitiating && TokenExpiresUtc > DateTime.UtcNow; - - public bool IsReset => !Token.HasValue && !TokenExpiresUtc.HasValue; + public bool IsResetStillValid => IsResetInitiated && TokenExpiresUtc > DateTime.UtcNow; public Optional PasswordHash { get; } - public Optional Token { get; } + public Optional ResetToken { get; } public Optional TokenExpiresUtc { get; } @@ -62,10 +60,11 @@ public static ValueObjectFactory Rehydrate() protected override IEnumerable GetAtomicValues() { - return new object[] { PasswordHash, Token, TokenExpiresUtc }; + return new object[] { PasswordHash, ResetToken, TokenExpiresUtc }; } - public Result ConfirmReset(string token) + public Result CompletePasswordReset(IPasswordHasherService passwordHasherService, string token, + string passwordHash) { if (token.IsNotValuedParameter(nameof(token), out var error1)) { @@ -78,43 +77,36 @@ public Result ConfirmReset(string token) return error2; } - if (token.NotEqualsOrdinal(Token)) + if (passwordHash.IsNotValuedParameter(nameof(passwordHash), out var error3)) { - return Error.RuleViolation(Resources.PasswordKeep_TokensNotMatch); + return error3; } - if (!IsInitiatingStillValid) + if (passwordHash.IsInvalidParameter(passwordHasherService.ValidatePasswordHash, + nameof(passwordHash), Resources.PasswordKeep_InvalidPasswordHash, out var error4)) { - return Error.PreconditionViolation(Resources.PasswordKeep_TokenExpired); + return error4; } - return this; - } - - public Result InitiatePasswordReset(string token) - { - if (token.IsNotValuedParameter(nameof(token), out var error1)) + if (!HasPassword) { - return error1; + return Error.RuleViolation(Resources.PasswordKeep_NoPasswordHash); } - if (token.IsInvalidParameter(Validations.Credentials.Password.ResetToken, nameof(token), - Resources.PasswordKeep_InvalidToken, out var error2)) + if (token.NotEqualsOrdinal(ResetToken)) { - return error2; + return Error.RuleViolation(Resources.PasswordKeep_TokensNotMatch); } - if (!IsInitiated) + if (!IsResetStillValid) { - return Error.RuleViolation(Resources.PasswordKeep_NoPasswordHash); + return Error.PreconditionViolation(Resources.PasswordKeep_TokenExpired); } - var expiry = DateTime.UtcNow.Add(DefaultResetExpiry); - return new PasswordKeep(PasswordHash, token, expiry); + return new PasswordKeep(passwordHash, Optional.None, Optional.None); } - public Result ResetPassword(IPasswordHasherService passwordHasherService, string token, - string passwordHash) + public Result InitiatePasswordReset(string token) { if (token.IsNotValuedParameter(nameof(token), out var error1)) { @@ -127,33 +119,13 @@ public Result ResetPassword(IPasswordHasherService password return error2; } - if (passwordHash.IsNotValuedParameter(nameof(passwordHash), out var error3)) - { - return error3; - } - - if (passwordHash.IsInvalidParameter(passwordHasherService.ValidatePasswordHash, - nameof(passwordHash), Resources.PasswordKeep_InvalidPasswordHash, out var error4)) - { - return error4; - } - - if (!IsInitiated) + if (!HasPassword) { return Error.RuleViolation(Resources.PasswordKeep_NoPasswordHash); } - if (token.NotEqualsOrdinal(Token)) - { - return Error.RuleViolation(Resources.PasswordKeep_TokensNotMatch); - } - - if (!IsInitiatingStillValid) - { - return Error.PreconditionViolation(Resources.PasswordKeep_TokenExpired); - } - - return new PasswordKeep(passwordHash, Optional.None, Optional.None); + var expiry = DateTime.UtcNow.Add(DefaultResetExpiry); + return new PasswordKeep(PasswordHash, token, expiry); } public Result SetPassword(IPasswordHasherService passwordHasherService, string passwordHash) @@ -176,7 +148,7 @@ public Result SetPassword(IPasswordHasherService passwordHa public PasswordKeep TestingOnly_ExpireToken() { - return new PasswordKeep(PasswordHash, Token, DateTime.UtcNow.SubtractSeconds(1)); + return new PasswordKeep(PasswordHash, ResetToken, DateTime.UtcNow.SubtractSeconds(1)); } #endif @@ -194,11 +166,37 @@ public Result Verify(IPasswordHasherService passwordHasherService, return error2; } - if (!IsInitiated) + if (!HasPassword) { return Error.RuleViolation(Resources.PasswordKeep_NoPasswordHash); } return passwordHasherService.VerifyPassword(password, PasswordHash); } + + public Result VerifyReset(string token) + { + if (token.IsNotValuedParameter(nameof(token), out var error1)) + { + return error1; + } + + if (token.IsInvalidParameter(Validations.Credentials.Password.ResetToken, nameof(token), + Resources.PasswordKeep_InvalidToken, out var error2)) + { + return error2; + } + + if (token.NotEqualsOrdinal(ResetToken)) + { + return Error.RuleViolation(Resources.PasswordKeep_TokensNotMatch); + } + + if (!IsResetStillValid) + { + return Error.PreconditionViolation(Resources.PasswordKeep_TokenExpired); + } + + return this; + } } \ No newline at end of file diff --git a/src/IdentityDomain/Resources.Designer.cs b/src/IdentityDomain/Resources.Designer.cs index 9bbc5664..f8631575 100644 --- a/src/IdentityDomain/Resources.Designer.cs +++ b/src/IdentityDomain/Resources.Designer.cs @@ -328,5 +328,14 @@ internal static string PasswordKeep_TokensNotMatch { return ResourceManager.GetString("PasswordKeep_TokensNotMatch", resourceCulture); } } + + /// + /// Looks up a localized string similar to The verification token is either missing or invalid. + /// + internal static string VerificationKeep_InvalidToken { + get { + return ResourceManager.GetString("VerificationKeep_InvalidToken", resourceCulture); + } + } } } diff --git a/src/IdentityDomain/Resources.resx b/src/IdentityDomain/Resources.resx index 123b7514..de82ace1 100644 --- a/src/IdentityDomain/Resources.resx +++ b/src/IdentityDomain/Resources.resx @@ -114,4 +114,8 @@ The is no API key to verify yet + + The verification token is either missing or invalid + + \ No newline at end of file diff --git a/src/IdentityDomain/Validations.cs b/src/IdentityDomain/Validations.cs index 0c047f31..fba28440 100644 --- a/src/IdentityDomain/Validations.cs +++ b/src/IdentityDomain/Validations.cs @@ -16,7 +16,6 @@ public static class Machine public static class Credentials { - public static readonly Validation VerificationToken = CommonValidations.RandomToken(); public static readonly Validation InvitationToken = CommonValidations.RandomToken(); public static class Person @@ -42,6 +41,7 @@ public static class Login public static class Password { public static readonly Validation ResetToken = CommonValidations.RandomToken(); + public static readonly Validation VerificationToken = CommonValidations.RandomToken(); } } diff --git a/src/IdentityDomain/Verification.cs b/src/IdentityDomain/Verification.cs deleted file mode 100644 index 1eeb833f..00000000 --- a/src/IdentityDomain/Verification.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Common; -using Common.Extensions; -using Domain.Common.ValueObjects; -using Domain.Interfaces; - -namespace IdentityDomain; - -public sealed class Verification : ValueObjectBase -{ - public static readonly TimeSpan DefaultTokenExpiry = TimeSpan.FromDays(1); - - public static Result Create() - { - return new Verification(Optional.None, Optional.None, Optional.None); - } - - public static Result Create(Optional token, Optional expiresUtc, - Optional verifiedUtc) - { - return new Verification(token, expiresUtc, verifiedUtc); - } - - private Verification(Optional token, Optional expiresUtc, Optional verifiedUtc) - { - Token = token; - ExpiresUtc = expiresUtc; - VerifiedUtc = verifiedUtc; - } - - public Optional ExpiresUtc { get; } - - public bool IsStillVerifying => IsVerifying && ExpiresUtc > DateTime.UtcNow; - - public bool IsVerifiable => !Token.HasValue && !ExpiresUtc.HasValue && !VerifiedUtc.HasValue; - - public bool IsVerified => !Token.HasValue && !ExpiresUtc.HasValue && VerifiedUtc.HasValue; - - public bool IsVerifying => Token.HasValue && ExpiresUtc.HasValue; - - public Optional Token { get; } - - public Optional VerifiedUtc { get; } - - public static ValueObjectFactory Rehydrate() - { - return (property, _) => - { - var parts = RehydrateToList(property, false); - return new Verification(parts[0].ToOptional(), parts[1].FromIso8601().ToOptional(), - parts[2].FromIso8601().ToOptional()); - }; - } - - protected override IEnumerable GetAtomicValues() - { - return new object[] { Token, ExpiresUtc, VerifiedUtc }; - } - -#pragma warning disable CA1822 - public Verification Renew(string token) -#pragma warning restore CA1822 - { - ArgumentException.ThrowIfNullOrEmpty(token); - - return new Verification(token, DateTime.UtcNow.Add(DefaultTokenExpiry), Optional.None); - } - -#if TESTINGONLY - public Verification TestingOnly_ExpireToken() - { - return new Verification(Token, DateTime.UtcNow, Optional.None); - } -#endif - -#pragma warning disable CA1822 - public Verification Verify() -#pragma warning restore CA1822 - { - return new Verification(Optional.None, Optional.None, DateTime.UtcNow.SubtractSeconds(1)); - } -} \ No newline at end of file diff --git a/src/IdentityDomain/VerificationKeep.cs b/src/IdentityDomain/VerificationKeep.cs new file mode 100644 index 00000000..44b3c5ae --- /dev/null +++ b/src/IdentityDomain/VerificationKeep.cs @@ -0,0 +1,91 @@ +using Common; +using Common.Extensions; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace IdentityDomain; + +public sealed class VerificationKeep : ValueObjectBase +{ + public static readonly TimeSpan DefaultTokenExpiry = TimeSpan.FromDays(1); + + public static Result Create() + { + return new VerificationKeep(Optional.None, Optional.None, Optional.None); + } + + public static Result Create(Optional token, Optional expiresUtc, + Optional verifiedUtc) + { + if (token.HasValue) + { + if (token.Value.IsInvalidParameter(Validations.Credentials.Password.VerificationToken, nameof(token), + Resources.VerificationKeep_InvalidToken, out var error1)) + { + return error1; + } + } + + return new VerificationKeep(token, expiresUtc, verifiedUtc); + } + + private VerificationKeep(Optional token, Optional expiresUtc, Optional verifiedUtc) + { + Token = token; + ExpiresUtc = expiresUtc; + VerifiedUtc = verifiedUtc; + } + + public Optional ExpiresUtc { get; } + + public bool IsStillVerifying => IsVerifying && ExpiresUtc > DateTime.UtcNow; + + public bool IsVerifiable => !Token.HasValue && !ExpiresUtc.HasValue && !VerifiedUtc.HasValue; + + public bool IsVerified => !Token.HasValue && !ExpiresUtc.HasValue && VerifiedUtc.HasValue; + + public bool IsVerifying => Token.HasValue && ExpiresUtc.HasValue; + + public Optional Token { get; } + + public Optional VerifiedUtc { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new VerificationKeep(parts[0].ToOptional(), parts[1].FromIso8601().ToOptional(), + parts[2].FromIso8601().ToOptional()); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { Token, ExpiresUtc, VerifiedUtc }; + } + +#pragma warning disable CA1822 + public VerificationKeep Renew(string token) +#pragma warning restore CA1822 + { + ArgumentException.ThrowIfNullOrEmpty(token); + + return new VerificationKeep(token, DateTime.UtcNow.Add(DefaultTokenExpiry), Optional.None); + } + +#if TESTINGONLY + public VerificationKeep TestingOnly_ExpireToken() + { + return new VerificationKeep(Token, DateTime.UtcNow, Optional.None); + } +#endif + +#pragma warning disable CA1822 + public VerificationKeep Verify() +#pragma warning restore CA1822 + { + return new VerificationKeep(Optional.None, Optional.None, DateTime.UtcNow.SubtractSeconds(1)); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs index 7164b955..13c2977c 100644 --- a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs +++ b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs @@ -5,7 +5,9 @@ using Common; using Domain.Interfaces.Authorization; using FluentAssertions; +using IdentityDomain; using Infrastructure.Interfaces; +using Infrastructure.Shared.DomainServices; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Operations.Shared.Identities; using Infrastructure.Web.Api.Operations.Shared.TestingOnly; @@ -21,6 +23,7 @@ namespace IdentityInfrastructure.IntegrationTests; [Collection("API")] public class PasswordCredentialsApiSpec : WebApiSpec { + private static int _userCount; private readonly StubNotificationsService _notificationsService; public PasswordCredentialsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) @@ -163,6 +166,173 @@ await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest #endif } + [Fact] + public async Task WhenInitiatePasswordResetForUnregisteredEmailAddress_ThenSendsCourtesyEmail() + { + var result = await Api.PostAsync(new InitiatePasswordResetRequest + { + EmailAddress = "anunknownuser@company.com" + }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _notificationsService.LastPasswordResetCourtesyEmailRecipient.Should().Be("anunknownuser@company.com"); + } + + [Fact] + public async Task WhenInitiatePasswordResetAndRegistrationNotVerified_ThenReturnsError() + { + var emailAddress = CreateRandomEmailAddress(); + await Api.PostAsync(new RegisterPersonPasswordRequest + { + EmailAddress = emailAddress, + FirstName = "afirstname", + LastName = "alastname", + Password = "1Password!", + TermsAndConditionsAccepted = true + }); + + var result = await Api.PostAsync(new InitiatePasswordResetRequest + { + EmailAddress = emailAddress + }); + + result.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + } + + [Fact] + public async Task WhenInitiatePasswordReset_ThenSendsEmailNotification() + { + var login = await LoginUserAsync(); + + var emailAddress = login.User.Profile!.EmailAddress!; + var result = await Api.PostAsync(new InitiatePasswordResetRequest + { + EmailAddress = emailAddress + }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _notificationsService.LastPasswordResetEmailRecipient.Should().Be(emailAddress); + _notificationsService.LastPasswordResetToken.Should().NotBeNull(); + } + + [Fact] + public async Task WhenResendPasswordReset_ThenResendsEmailNotification() + { + var login = await LoginUserAsync(); + + await Api.PostAsync(new InitiatePasswordResetRequest + { + EmailAddress = login.User.Profile!.EmailAddress! + }); + + var token = _notificationsService.LastPasswordResetToken; + _notificationsService.Reset(); + + var result = await Api.PostAsync(new ResendPasswordResetRequest + { + Token = token! + }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _notificationsService.LastPasswordResetEmailRecipient.Should().Be(login.User.Profile.EmailAddress); + _notificationsService.LastPasswordResetToken.Should().NotBe(token); + } + + [Fact] + public async Task WhenVerifyPasswordReset_ThenConfirms() + { + var login = await LoginUserAsync(); + + await Api.PostAsync(new InitiatePasswordResetRequest + { + EmailAddress = login.User.Profile!.EmailAddress! + }); + + var token = _notificationsService.LastPasswordResetToken; + var result = await Api.GetAsync(new VerifyPasswordResetRequest + { + Token = token! + }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenCompletePasswordResetWithUnknownToken_ThenReturnsError() + { + var token = new TokensService().CreatePasswordResetToken(); + var result = await Api.PostAsync(new CompletePasswordResetRequest + { + Token = token, + Password = "a1Password!" + }); + + result.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task WhenCompletePasswordResetWithLockedAccount_ThenUnlocksAccountAndResets() + { + var login = await LoginUserAsync(); + LockAccountWithFailedLogins(login); + + var emailAddress = login.User.Profile!.EmailAddress!; + await Api.PostAsync(new InitiatePasswordResetRequest + { + EmailAddress = emailAddress + }); + + var token = _notificationsService.LastPasswordResetToken!; + await Api.PostAsync(new CompletePasswordResetRequest + { + Token = token, + Password = "2Password!" + }); + + var authenticated = await ReAuthenticateUserAsync(login.User, "2Password!"); + + authenticated.AccessToken.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task WhenCompletePasswordReset_ThenResets() + { + var login = await LoginUserAsync(); + + var emailAddress = login.User.Profile!.EmailAddress!; + await Api.PostAsync(new InitiatePasswordResetRequest + { + EmailAddress = emailAddress + }); + + var token = _notificationsService.LastPasswordResetToken!; + await Api.PostAsync(new CompletePasswordResetRequest + { + Token = token, + Password = "2Password!" + }); + + var authenticated = await ReAuthenticateUserAsync(login.User, "2Password!"); + + authenticated.AccessToken.Should().NotBeNullOrEmpty(); + } + + private void LockAccountWithFailedLogins(LoginDetails login, + int wrongAttempts = Validations.Credentials.Login.DefaultMaxFailedPasswordAttempts) + { + var emailAddress = login.User.Profile!.EmailAddress!; + Repeat.Times(() => Try.Safely(() => Api.PostAsync(new AuthenticatePasswordRequest + { + Username = emailAddress, + Password = "awrongpassword" + })), wrongAttempts); + } + + private static string CreateRandomEmailAddress() + { + return $"auser{++_userCount}@company.com"; + } + private static void OverrideDependencies(IServiceCollection services) { } diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/CompletePasswordResetRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/CompletePasswordResetRequestValidatorSpec.cs new file mode 100644 index 00000000..546373fc --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/CompletePasswordResetRequestValidatorSpec.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.PasswordCredentials; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.PasswordCredentials; + +[Trait("Category", "Unit")] +public class CompletePasswordResetRequestValidatorSpec +{ + private readonly CompletePasswordResetRequest _dto; + private readonly CompletePasswordResetRequestValidator _validator; + + public CompletePasswordResetRequestValidatorSpec() + { + _validator = new CompletePasswordResetRequestValidator(); + _dto = new CompletePasswordResetRequest + { + Password = "1Password!", + Token = new TokensService().CreatePasswordResetToken() + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenPasswordIsEmpty_ThenThrows() + { + _dto.Password = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CompletePasswordResetRequestValidator_InvalidPassword); + } + + [Fact] + public void WhenPasswordIsInvalid_ThenThrows() + { + _dto.Password = "not"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CompletePasswordResetRequestValidator_InvalidPassword); + } + + [Fact] + public void WhenTokenIsEmpty_ThenThrows() + { + _dto.Token = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } + + [Fact] + public void WhenTokenIsInvalid_ThenThrows() + { + _dto.Token = "notavalidtoken"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/InitiatePasswordResetRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/InitiatePasswordResetRequestValidatorSpec.cs new file mode 100644 index 00000000..f5bcde78 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/InitiatePasswordResetRequestValidatorSpec.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.PasswordCredentials; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.PasswordCredentials; + +[Trait("Category", "Unit")] +public class InitiatePasswordResetRequestValidatorSpec +{ + private readonly InitiatePasswordResetRequest _dto; + private readonly InitiatePasswordResetRequestValidator _validator; + + public InitiatePasswordResetRequestValidatorSpec() + { + _validator = new InitiatePasswordResetRequestValidator(); + _dto = new InitiatePasswordResetRequest + { + EmailAddress = "user@company.com" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenEmailAddressIsEmpty_ThenThrows() + { + _dto.EmailAddress = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.InitiatePasswordResetRequestValidator_InvalidEmailAddress); + } + + [Fact] + public void WhenEmailAddressIsInvalid_ThenThrows() + { + _dto.EmailAddress = "notanemail"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.InitiatePasswordResetRequestValidator_InvalidEmailAddress); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/ResendPasswordResetRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/ResendPasswordResetRequestValidatorSpec.cs new file mode 100644 index 00000000..8c65b04f --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/ResendPasswordResetRequestValidatorSpec.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.PasswordCredentials; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.PasswordCredentials; + +[Trait("Category", "Unit")] +public class ResendPasswordResetRequestValidatorSpec +{ + private readonly ResendPasswordResetRequest _dto; + private readonly ResendPasswordResetRequestValidator _validator; + + public ResendPasswordResetRequestValidatorSpec() + { + _validator = new ResendPasswordResetRequestValidator(); + _dto = new ResendPasswordResetRequest + { + Token = new TokensService().CreatePasswordResetToken() + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenTokenIsEmpty_ThenThrows() + { + _dto.Token = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } + + [Fact] + public void WhenTokenIsInvalid_ThenThrows() + { + _dto.Token = "notavalidtoken"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/VerifyPasswordResetRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/VerifyPasswordResetRequestValidatorSpec.cs new file mode 100644 index 00000000..7ed254f0 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/VerifyPasswordResetRequestValidatorSpec.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.PasswordCredentials; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.PasswordCredentials; + +[Trait("Category", "Unit")] +public class VerifyPasswordResetRequestValidatorSpec +{ + private readonly VerifyPasswordResetRequest _dto; + private readonly VerifyPasswordResetRequestValidator _validator; + + public VerifyPasswordResetRequestValidatorSpec() + { + _validator = new VerifyPasswordResetRequestValidator(); + _dto = new VerifyPasswordResetRequest + { + Token = new TokensService().CreatePasswordResetToken() + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenTokenIsEmpty_ThenThrows() + { + _dto.Token = string.Empty; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } + + [Fact] + public void WhenTokenIsInvalid_ThenThrows() + { + _dto.Token = "notavalidtoken"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs index 14ecb54d..5304761d 100644 --- a/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs @@ -83,7 +83,7 @@ private PasswordCredentialRoot CreateCredential(string userId) var credential = PasswordCredentialRoot.Create(_recorder.Object, "acredentialid".ToIdentifierFactory(), _settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object, userId.ToId()).Value; - credential.SetCredential("apassword"); + credential.SetPasswordCredential("apassword"); credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, PersonDisplayName.Create("aname").Value); return credential; diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/CompletePasswordResetRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/CompletePasswordResetRequestValidator.cs new file mode 100644 index 00000000..ebfa17d1 --- /dev/null +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/CompletePasswordResetRequestValidator.cs @@ -0,0 +1,20 @@ +using Domain.Interfaces.Validations; +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.PasswordCredentials; + +public class CompletePasswordResetRequestValidator : AbstractValidator +{ + public CompletePasswordResetRequestValidator() + { + RuleFor(req => req.Password) + .Matches(CommonValidations.Passwords.Password.Strict) + .WithMessage(Resources.CompletePasswordResetRequestValidator_InvalidPassword); + RuleFor(req => req.Token) + .Matches(Validations.Credentials.Password.ResetToken) + .WithMessage(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/ConfirmPersonRegistrationRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/ConfirmPersonRegistrationRequestValidator.cs index 7d049b05..b5e04293 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/ConfirmPersonRegistrationRequestValidator.cs +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/ConfirmPersonRegistrationRequestValidator.cs @@ -11,7 +11,7 @@ public ConfirmPersonRegistrationRequestValidator() { RuleFor(req => req.Token) .NotEmpty() - .Matches(Validations.Credentials.VerificationToken) + .Matches(Validations.Credentials.Password.VerificationToken) .WithMessage(Resources.ConfirmPersonRegistrationRequestValidator_InvalidToken); } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/InitiatePasswordResetRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/InitiatePasswordResetRequestValidator.cs new file mode 100644 index 00000000..5302a3ee --- /dev/null +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/InitiatePasswordResetRequestValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.PasswordCredentials; + +public class InitiatePasswordResetRequestValidator : AbstractValidator +{ + public InitiatePasswordResetRequestValidator() + { + RuleFor(req => req.EmailAddress) + .IsEmailAddress() + .WithMessage(Resources.InitiatePasswordResetRequestValidator_InvalidEmailAddress); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs index aa1b5102..ff2f69ec 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs @@ -36,6 +36,15 @@ await _passwordCredentialsApplication.AuthenticateAsync(_contextFactory.Create() })); } + public async Task CompletePasswordReset(CompletePasswordResetRequest request, + CancellationToken cancellationToken) + { + var completion = await _passwordCredentialsApplication.CompletePasswordResetAsync(_contextFactory.Create(), + request.Token, request.Password, cancellationToken); + + return () => completion.HandleApplicationResult(); + } + public async Task ConfirmRegistration(ConfirmRegistrationPersonPasswordRequest request, CancellationToken cancellationToken) { @@ -75,4 +84,33 @@ public async Task credential.HandleApplicationResult(creds => new PostResult(new RegisterPersonPasswordResponse { Credential = creds })); } + + public async Task RequestPasswordReset(InitiatePasswordResetRequest request, + CancellationToken cancellationToken) + { + var reset = await _passwordCredentialsApplication.InitiatePasswordResetAsync(_contextFactory.Create(), + request.EmailAddress, cancellationToken); + + return () => reset.HandleApplicationResult(); + } + + public async Task ResendPasswordReset(ResendPasswordResetRequest request, + CancellationToken cancellationToken) + { + var resent = + await _passwordCredentialsApplication.ResendPasswordResetAsync(_contextFactory.Create(), request.Token, + cancellationToken); + + return () => resent.HandleApplicationResult(); + } + + public async Task VerifyPasswordReset(VerifyPasswordResetRequest request, + CancellationToken cancellationToken) + { + var verified = + await _passwordCredentialsApplication.VerifyPasswordResetAsync(_contextFactory.Create(), request.Token, + cancellationToken); + + return () => verified.HandleApplicationResult(); + } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/ResendPasswordResetRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/ResendPasswordResetRequestValidator.cs new file mode 100644 index 00000000..5996dd90 --- /dev/null +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/ResendPasswordResetRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.PasswordCredentials; + +public class ResendPasswordResetRequestValidator : AbstractValidator +{ + public ResendPasswordResetRequestValidator() + { + RuleFor(req => req.Token) + .Matches(Validations.Credentials.Password.ResetToken) + .WithMessage(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/VerifyPasswordResetRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/VerifyPasswordResetRequestValidator.cs new file mode 100644 index 00000000..b79699c5 --- /dev/null +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/VerifyPasswordResetRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.PasswordCredentials; + +public class VerifyPasswordResetRequestValidator : AbstractValidator +{ + public VerifyPasswordResetRequestValidator() + { + RuleFor(req => req.Token) + .Matches(Validations.Credentials.Password.ResetToken) + .WithMessage(Resources.CompletePasswordResetRequestValidator_InvalidToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs b/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs index 9b8cc36b..1d4f19c2 100644 --- a/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs +++ b/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs @@ -31,8 +31,17 @@ public async Task> DestroyAllAsync(CancellationToken cancellationT _credentials.DestroyAllAsync(cancellationToken)); } - public async Task, Error>> FindCredentialsByTokenAsync(string token, - CancellationToken cancellationToken) + public async Task, Error>> FindCredentialsByPasswordResetTokenAsync( + string token, CancellationToken cancellationToken) + { + var query = Query.From() + .Where(pc => pc.PasswordResetToken, ConditionOperator.EqualTo, token); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + public async Task, Error>> + FindCredentialsByRegistrationVerificationTokenAsync(string token, + CancellationToken cancellationToken) { var query = Query.From() .Where(pc => pc.RegistrationVerificationToken, ConditionOperator.EqualTo, token); diff --git a/src/IdentityInfrastructure/Resources.Designer.cs b/src/IdentityInfrastructure/Resources.Designer.cs index 40cce192..ac197b90 100644 --- a/src/IdentityInfrastructure/Resources.Designer.cs +++ b/src/IdentityInfrastructure/Resources.Designer.cs @@ -113,6 +113,24 @@ internal static string AuthenticateSingleSignOnRequestValidator_InvalidUsername } } + /// + /// Looks up a localized string similar to The 'Password' is either missing or invalid. + /// + internal static string CompletePasswordResetRequestValidator_InvalidPassword { + get { + return ResourceManager.GetString("CompletePasswordResetRequestValidator_InvalidPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'Token' is either missing or invalid. + /// + internal static string CompletePasswordResetRequestValidator_InvalidToken { + get { + return ResourceManager.GetString("CompletePasswordResetRequestValidator_InvalidToken", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'Token' is either missing or invalid. /// @@ -131,6 +149,15 @@ internal static string CreateAPIKeyRequestValidator_InvalidExpiresOn { } } + /// + /// Looks up a localized string similar to The 'EmailAddress' is either missing or invalid. + /// + internal static string InitiatePasswordResetRequestValidator_InvalidEmailAddress { + get { + return ResourceManager.GetString("InitiatePasswordResetRequestValidator_InvalidEmailAddress", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'RefreshToken' is invalid or missing. /// diff --git a/src/IdentityInfrastructure/Resources.resx b/src/IdentityInfrastructure/Resources.resx index ac02ac1f..7039273d 100644 --- a/src/IdentityInfrastructure/Resources.resx +++ b/src/IdentityInfrastructure/Resources.resx @@ -87,4 +87,13 @@ The 'InvitationToken' is either missing or invalid + + The 'EmailAddress' is either missing or invalid + + + The 'Password' is either missing or invalid + + + The 'Token' is either missing or invalid + \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs b/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs index e31014d8..38d4a28a 100644 --- a/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs @@ -87,9 +87,64 @@ public async Task> NotifyPasswordRegistrationConfirmationAsync(ICa }, cancellationToken); } - public async Task> NotifyReRegistrationCourtesyAsync(ICallerContext caller, string userId, - string emailAddress, string name, - string? timezone, string? countryCode, CancellationToken cancellationToken) + public async Task> NotifyPasswordResetInitiatedAsync(ICallerContext caller, string name, + string emailAddress, string token, + CancellationToken cancellationToken) + { + var webSiteUrl = _hostSettings.GetWebsiteHostBaseUrl(); + var webSiteRoute = _websiteUiService.ConstructPasswordResetConfirmationPageUrl(token); + var link = webSiteUrl.WithoutTrailingSlash() + webSiteRoute; + var htmlBody = + $$""" +

Hello {{name}},

+

We have received a request to reset your password at {{_productName}}.

+

If you did not make this request, please contact the {{_productName}} support team immediately.

+

+

If you expected this email, please click this link to reset your password

+

This is an automated email from the support team at {{_productName}}

+ """; + + return await _emailSchedulingService.ScheduleHtmlEmail(caller, new HtmlEmail + { + Subject = $"Reset your {_productName} password", + Body = htmlBody, + FromEmailAddress = _senderEmailAddress, + FromDisplayName = _senderName, + ToEmailAddress = emailAddress, + ToDisplayName = name + }, cancellationToken); + } + + public async Task> NotifyPasswordResetUnknownUserCourtesyAsync(ICallerContext caller, + string emailAddress, + CancellationToken cancellationToken) + { + var htmlBody = + $""" +

Hello,

+

We have received a very suspicious request to reset your password at our web site {_productName}.

+

You have no registered account at the web site of {_productName}, so you are safe.

+

It is possible that some suspicious party is trying to access your account through our web site, but it does not exist.

+

+

There is nothing more for you to do.

+

We have blocked this attempt from succeeding.

+

We just thought you would like to know, that this is going on.

+

This is an automated email from the support team at {_productName}

+ """; + + return await _emailSchedulingService.ScheduleHtmlEmail(caller, new HtmlEmail + { + Subject = $"{_productName} Account Registration Attempt", + Body = htmlBody, + FromEmailAddress = _senderEmailAddress, + FromDisplayName = _senderName, + ToEmailAddress = emailAddress, + ToDisplayName = emailAddress + }, cancellationToken); + } + + public async Task> NotifyPasswordRegistrationRepeatCourtesyAsync(ICallerContext caller, string userId, + string emailAddress, string name, string? timezone, string? countryCode, CancellationToken cancellationToken) { var htmlBody = $""" diff --git a/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs b/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs index d290e654..55439775 100644 --- a/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs @@ -9,6 +9,7 @@ public sealed class WebsiteUiService : IWebsiteUiService { //EXTEND: these URLs must reflect those used by the website that handles UI private const string PasswordRegistrationConfirmationPageRoute = "/confirm-password-registration"; + private const string PasswordResetConfirmationPageRoute = "/confirm-password-reset"; private const string RegistrationPageRoute = "/register"; public string ConstructPasswordRegistrationConfirmationPageUrl(string token) @@ -22,4 +23,10 @@ public string CreateRegistrationPageUrl(string token) var escapedToken = Uri.EscapeDataString(token); return $"{RegistrationPageRoute}?token={escapedToken}"; } + + public string ConstructPasswordResetConfirmationPageUrl(string token) + { + var escapedToken = Uri.EscapeDataString(token); + return $"{PasswordResetConfirmationPageRoute}?token={escapedToken}"; + } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/CompletePasswordResetRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/CompletePasswordResetRequest.cs new file mode 100644 index 00000000..e0f5f5be --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/CompletePasswordResetRequest.cs @@ -0,0 +1,11 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +[Route("/passwords/{Token}/reset/complete", OperationMethod.Post)] +public class CompletePasswordResetRequest : UnTenantedEmptyRequest +{ + public required string Password { get; set; } + + public required string Token { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/InitiatePasswordResetRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/InitiatePasswordResetRequest.cs new file mode 100644 index 00000000..6f2fc3cb --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/InitiatePasswordResetRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +[Route("/passwords/reset", OperationMethod.Post)] +public class InitiatePasswordResetRequest : UnTenantedEmptyRequest +{ + public required string EmailAddress { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ResendPasswordResetRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ResendPasswordResetRequest.cs new file mode 100644 index 00000000..945e2f0b --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ResendPasswordResetRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +[Route("/passwords/{Token}/reset/resend", OperationMethod.Post)] +public class ResendPasswordResetRequest : UnTenantedEmptyRequest +{ + public required string Token { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/VerifyPasswordResetRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/VerifyPasswordResetRequest.cs new file mode 100644 index 00000000..eaf344da --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/VerifyPasswordResetRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +[Route("/passwords/{Token}/reset/verify", OperationMethod.Get)] +public class VerifyPasswordResetRequest : UnTenantedEmptyRequest +{ + public required string Token { get; set; } +} \ No newline at end of file diff --git a/src/IntegrationTesting.WebApi.Common/Stubs/StubNotificationsService.cs b/src/IntegrationTesting.WebApi.Common/Stubs/StubNotificationsService.cs index 8e0706a9..117e1b4a 100644 --- a/src/IntegrationTesting.WebApi.Common/Stubs/StubNotificationsService.cs +++ b/src/IntegrationTesting.WebApi.Common/Stubs/StubNotificationsService.cs @@ -46,7 +46,23 @@ public Task> NotifyPasswordRegistrationConfirmationAsync(ICallerCo return Task.FromResult(Result.Ok); } - public Task> NotifyReRegistrationCourtesyAsync(ICallerContext caller, string userId, + public Task> NotifyPasswordResetInitiatedAsync(ICallerContext caller, string name, + string emailAddress, string token, + CancellationToken cancellationToken) + { + LastPasswordResetEmailRecipient = emailAddress; + LastPasswordResetToken = token; + return Task.FromResult(Result.Ok); + } + + public Task> NotifyPasswordResetUnknownUserCourtesyAsync(ICallerContext caller, string emailAddress, + CancellationToken cancellationToken) + { + LastPasswordResetCourtesyEmailRecipient = emailAddress; + return Task.FromResult(Result.Ok); + } + + public Task> NotifyPasswordRegistrationRepeatCourtesyAsync(ICallerContext caller, string userId, string emailAddress, string name, string? timezone, string? countryCode, CancellationToken cancellationToken) { diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 1c4f6d01..178b5e76 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -198,14 +198,15 @@ protected async Task LoginUserAsync(LoginUser who = LoginUser.Pers return await ReAuthenticateUserAsync(person.Credential!.User); } - protected async Task ReAuthenticateUserAsync(RegisteredEndUser user) + protected async Task ReAuthenticateUserAsync(RegisteredEndUser user, + string password = PasswordForPerson) { var emailAddress = user.Profile!.EmailAddress!; var login = await Api.PostAsync(new AuthenticatePasswordRequest { Username = emailAddress, - Password = PasswordForPerson + Password = password }); var accessToken = login.Content.Value.Tokens!.AccessToken.Value;