Skip to content

Commit

Permalink
Completed PasswordCredentials API. #4
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Apr 21, 2024
1 parent dd17fe5 commit fbd9dc6
Show file tree
Hide file tree
Showing 43 changed files with 1,341 additions and 292 deletions.
8 changes: 4 additions & 4 deletions docs/design-principles/0000-all-use-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/Application.Interfaces/UsageConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/Application.Services.Shared/INotificationsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ Task<Result<Error>> NotifyPasswordRegistrationConfirmationAsync(ICallerContext c
/// <summary>
/// Notifies a user, via email, to warn them that an attempt to re-register an account by another party has occurred
/// </summary>
Task<Result<Error>> NotifyReRegistrationCourtesyAsync(ICallerContext caller, string userId, string emailAddress,
Task<Result<Error>> NotifyPasswordRegistrationRepeatCourtesyAsync(ICallerContext caller, string userId,
string emailAddress,
string name, string? timezone, string? countryCode, CancellationToken cancellationToken);

/// <summary>
/// Notifies a user, via email, that their password reset has been initiated
/// </summary>
Task<Result<Error>> NotifyPasswordResetInitiatedAsync(ICallerContext caller, string name, string emailAddress,
string token, CancellationToken cancellationToken);

/// <summary>
/// Notifies an unknown user, via email, that their email has been used to initiate a password reset
/// </summary>
Task<Result<Error>> NotifyPasswordResetUnknownUserCourtesyAsync(ICallerContext caller, string emailAddress,
CancellationToken cancellationToken);
}
2 changes: 2 additions & 0 deletions src/Application.Services.Shared/IWebsiteUiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ public interface IWebsiteUiService
{
string ConstructPasswordRegistrationConfirmationPageUrl(string token);

string ConstructPasswordResetConfirmationPageUrl(string token);

string CreateRegistrationPageUrl(string token);
}
12 changes: 6 additions & 6 deletions src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegis
cc.CallId == "acallid"
&& cc.IsServiceAccount
), "anid", It.IsAny<CancellationToken>()));
_notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny<ICallerContext>(),
_notificationsService.Verify(ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny<ICallerContext>(),
It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Never);
Expand Down Expand Up @@ -259,7 +259,7 @@ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(),
cc.CallId == "acallid"
&& cc.IsServiceAccount
), "anid", It.IsAny<CancellationToken>()));
_notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny<ICallerContext>(),
_notificationsService.Verify(ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny<ICallerContext>(),
It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Never);
Expand Down Expand Up @@ -337,7 +337,7 @@ public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenReg
cc.CallId == "acallid"
&& cc.IsServiceAccount
), "anid", It.IsAny<CancellationToken>()));
_notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny<ICallerContext>(),
_notificationsService.Verify(ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny<ICallerContext>(),
It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Never);
Expand Down Expand Up @@ -391,7 +391,7 @@ public async Task
It.IsAny<CancellationToken>()),
Times.Never);
_notificationsService.Verify(
ns => ns.NotifyReRegistrationCourtesyAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()), Times.Never);
}
Expand Down Expand Up @@ -429,7 +429,7 @@ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyE
rep.LoadAsync(It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(endUser);
_notificationsService.Setup(ns =>
ns.NotifyReRegistrationCourtesyAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
ns.NotifyPasswordRegistrationRepeatCourtesyAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok);
Expand Down Expand Up @@ -459,7 +459,7 @@ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyE
ups.GetProfilePrivateAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()),
Times.Never);
_notificationsService.Verify(ns => ns.NotifyReRegistrationCourtesyAsync(_caller.Object, "anid",
_notificationsService.Verify(ns => ns.NotifyPasswordRegistrationRepeatCourtesyAsync(_caller.Object, "anid",
"[email protected]", "afirstname", "atimezone", "acountrycode", CancellationToken.None));
}

Expand Down
2 changes: 1 addition & 1 deletion src/EndUsersApplication/EndUsersApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ public async Task<Result<RegisteredEndUser, Error>> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAuthTokensService> _authTokensService;
private readonly Mock<ICallerContext> _caller;
Expand Down Expand Up @@ -398,7 +399,7 @@ public async Task WhenRegisterPersonAsyncAndNotExists_ThenCreatesAndSendsConfirm
&& uc.Registration.Value.EmailAddress == "[email protected]"
&& uc.Password.PasswordHash == "apasswordhash"
&& uc.Login.Exists()
&& !uc.Verification.IsVerified
&& !uc.VerificationKeep.IsVerified
), It.IsAny<CancellationToken>()));
_notificationsService.Verify(ns =>
ns.NotifyPasswordRegistrationConfirmationAsync(_caller.Object, "[email protected]", "adisplayname",
Expand All @@ -411,7 +412,9 @@ public async Task WhenRegisterPersonAsyncAndNotExists_ThenCreatesAndSendsConfirm
[Fact]
public async Task WhenConfirmPersonRegistrationAsyncAndTokenUnknown_ThenReturnsError()
{
_repository.Setup(s => s.FindCredentialsByTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
_repository.Setup(s =>
s.FindCredentialsByRegistrationVerificationTokenAsync(It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns(Task.FromResult<Result<Optional<PasswordCredentialRoot>, Error>>(Optional<PasswordCredentialRoot>
.None));

Expand All @@ -427,7 +430,9 @@ public async Task WhenConfirmPersonRegistrationAsyncAndTokenUnknown_ThenReturnsE
public async Task WhenConfirmPersonRegistrationAsync_ThenReturnsSuccess()
{
var credential = CreateUnVerifiedCredential();
_repository.Setup(s => s.FindCredentialsByTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
_repository.Setup(s =>
s.FindCredentialsByRegistrationVerificationTokenAsync(It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns(Task.FromResult<Result<Optional<PasswordCredentialRoot>, Error>>(credential.ToOptional()));

var result =
Expand All @@ -440,10 +445,174 @@ public async Task WhenConfirmPersonRegistrationAsync_ThenReturnsSuccess()
), It.IsAny<CancellationToken>()));
}

[Fact]
public async Task WhenInitiatePasswordRequestAndUnknownEmailAddress_ThenSendsCourtesyNotification()
{
_repository.Setup(s => s.FindCredentialsByUsernameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Optional<PasswordCredentialRoot>.None);

var result =
await _application.InitiatePasswordResetAsync(_caller.Object, "[email protected]", CancellationToken.None);

result.Should().BeSuccess();
_repository.Verify(s => s.SaveAsync(It.IsAny<PasswordCredentialRoot>(), It.IsAny<CancellationToken>()),
Times.Never);
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetInitiatedAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetUnknownUserCourtesyAsync(_caller.Object, "[email protected]", CancellationToken.None));
}

[Fact]
public async Task WhenInitiatePasswordRequest_ThenSendsNotification()
{
_tokensService.Setup(ts => ts.CreatePasswordResetToken())
.Returns(TestingToken);
_repository.Setup(s => s.FindCredentialsByUsernameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateVerifiedCredential().ToOptional());

var result =
await _application.InitiatePasswordResetAsync(_caller.Object, "[email protected]", CancellationToken.None);

result.Should().BeSuccess();
_repository.Verify(s => s.SaveAsync(It.Is<PasswordCredentialRoot>(cred =>
cred.IsPasswordSet
), It.IsAny<CancellationToken>()));
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetInitiatedAsync(_caller.Object, "aname", "[email protected]", TestingToken,
It.IsAny<CancellationToken>()));
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetUnknownUserCourtesyAsync(It.IsAny<ICallerContext>(), "[email protected]",
CancellationToken.None), Times.Never);
}

[Fact]
public async Task WhenResendPasswordRequestAndUnknownToken_ThenReturnsError()
{
_repository.Setup(s =>
s.FindCredentialsByPasswordResetTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Optional<PasswordCredentialRoot>.None);

var result =
await _application.ResendPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None);

result.Should().BeError(ErrorCode.EntityNotFound);
_repository.Verify(s => s.SaveAsync(It.IsAny<PasswordCredentialRoot>(), It.IsAny<CancellationToken>()),
Times.Never);
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetInitiatedAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task WhenResendPasswordRequest_ThenResendsNotification()
{
_tokensService.Setup(ts => ts.CreatePasswordResetToken())
.Returns(TestingToken);
_repository.Setup(s =>
s.FindCredentialsByPasswordResetTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateVerifiedCredential().ToOptional());

var result =
await _application.ResendPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None);

result.Should().BeSuccess();
_repository.Verify(s => s.SaveAsync(It.Is<PasswordCredentialRoot>(cred =>
cred.IsPasswordResetInitiated
), It.IsAny<CancellationToken>()));
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetInitiatedAsync(_caller.Object, "aname", "[email protected]",
TestingToken, It.IsAny<CancellationToken>()));
}

[Fact]
public async Task WhenVerifyPasswordResetAsyncAndUnknownToken_ThenReturnsError()
{
_repository.Setup(s =>
s.FindCredentialsByPasswordResetTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Optional<PasswordCredentialRoot>.None);

var result =
await _application.VerifyPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None);

result.Should().BeError(ErrorCode.EntityNotFound);
_repository.Verify(s => s.SaveAsync(It.IsAny<PasswordCredentialRoot>(), It.IsAny<CancellationToken>()),
Times.Never);
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetInitiatedAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(credential.ToOptional());

var result =
await _application.VerifyPasswordResetAsync(_caller.Object, TestingToken, CancellationToken.None);

result.Should().BeSuccess();
_repository.Verify(s => s.SaveAsync(It.IsAny<PasswordCredentialRoot>(), It.IsAny<CancellationToken>()),
Times.Never);
}

[Fact]
public async Task WhenCompletePasswordResetAsyncAndUnknownToken_ThenReturnsError()
{
_repository.Setup(s =>
s.FindCredentialsByPasswordResetTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Optional<PasswordCredentialRoot>.None);

var result =
await _application.CompletePasswordResetAsync(_caller.Object, "atoken", "apassword",
CancellationToken.None);

result.Should().BeError(ErrorCode.EntityNotFound);
_repository.Verify(s => s.SaveAsync(It.IsAny<PasswordCredentialRoot>(), It.IsAny<CancellationToken>()),
Times.Never);
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetInitiatedAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task WhenCompletePasswordResetAsync_ThenCompletes()
{
_tokensService.Setup(ts => ts.CreatePasswordResetToken())
.Returns(TestingToken);
_passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny<string>(), It.IsAny<string>()))
.Returns(false);

var credential = CreateVerifiedCredential();
credential.InitiatePasswordReset();
_repository.Setup(s =>
s.FindCredentialsByPasswordResetTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(credential.ToOptional());

var result =
await _application.CompletePasswordResetAsync(_caller.Object, TestingToken, "2Password!",
CancellationToken.None);

result.Should().BeSuccess();
_repository.Verify(s => s.SaveAsync(It.Is<PasswordCredentialRoot>(creds =>
!creds.IsPasswordResetInitiated
), It.IsAny<CancellationToken>()));
_notificationsService.Verify(ns =>
ns.NotifyPasswordResetInitiatedAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}

private PasswordCredentialRoot CreateUnVerifiedCredential()
{
var credential = CreateCredential();
credential.SetCredential("apassword");
credential.SetPasswordCredential("apassword");
credential.SetRegistrationDetails(EmailAddress.Create("[email protected]").Value,
PersonDisplayName.Create("aname").Value);
credential.InitiateRegistrationVerification();
Expand Down
12 changes: 12 additions & 0 deletions src/IdentityApplication/IPasswordCredentialsApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public interface IPasswordCredentialsApplication
Task<Result<AuthenticateTokens, Error>> AuthenticateAsync(ICallerContext context, string username, string password,
CancellationToken cancellationToken);

Task<Result<Error>> CompletePasswordResetAsync(ICallerContext caller, string token, string password,
CancellationToken cancellationToken);

Task<Result<Error>> ConfirmPersonRegistrationAsync(ICallerContext context, string token,
CancellationToken cancellationToken);

Expand All @@ -17,8 +20,17 @@ Task<Result<PasswordCredentialConfirmation, Error>> GetPersonRegistrationConfirm
string userId, CancellationToken cancellationToken);
#endif

Task<Result<Error>> InitiatePasswordResetAsync(ICallerContext caller, string emailAddress,
CancellationToken cancellationToken);

Task<Result<PasswordCredential, Error>> RegisterPersonAsync(ICallerContext context, string? invitationToken,
string firstName,
string lastName, string emailAddress, string password, string? timezone, string? countryCode,
bool termsAndConditionsAccepted, CancellationToken cancellationToken);

Task<Result<Error>> ResendPasswordResetAsync(ICallerContext caller, string token,
CancellationToken cancellationToken);

Task<Result<Error>> VerifyPasswordResetAsync(ICallerContext caller, string token, CancellationToken
cancellationToken);
}
Loading

0 comments on commit fbd9dc6

Please sign in to comment.