Skip to content

Commit

Permalink
Added MfaOptions to PasswordCredential. #52
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Nov 3, 2024
1 parent 94a79bb commit c1dea5c
Show file tree
Hide file tree
Showing 16 changed files with 457 additions and 73 deletions.
Binary file modified docs/images/Multitenancy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Sources.pptx
Binary file not shown.
3 changes: 3 additions & 0 deletions iac/AzureSQLServer-Seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,9 @@ CREATE TABLE [dbo].[PasswordCredential]
[LastPersistedAtUtc] [datetime] NULL,
[IsDeleted] [bit] NULL,
[AccountLocked] [bit] NULL,
[IsMfaEnabled] [bit] NULL,
[MfaCanBeDisabled] [bit] NULL,
[MfaTypes] [int] NULL,
[PasswordResetToken] [nvarchar](450) NULL,
[RegistrationVerificationToken] [nvarchar](max) NULL,
[RegistrationVerified] [bit] NULL,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Domain.Common;
using Domain.Common.ValueObjects;
using Domain.Shared.Identities;
using JetBrains.Annotations;

namespace Domain.Events.Shared.Identities.PasswordCredentials;
Expand All @@ -15,5 +16,11 @@ public Created()
{
}

public required bool MfaCanBeDisabled { get; set; }

public required bool IsMfaEnabled { get; set; }

public required MfaAuthenticators MfaTypes { get; set; }

public required string UserId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Domain.Common;
using Domain.Common.ValueObjects;
using Domain.Shared.Identities;
using JetBrains.Annotations;

namespace Domain.Events.Shared.Identities.PasswordCredentials;

public sealed class MfaOptionsChanged : DomainEvent
{
public MfaOptionsChanged(Identifier id) : base(id)
{
}

[UsedImplicitly]
public MfaOptionsChanged()
{
}

public required bool MfaCanBeDisabled { get; set; }

public required bool IsMfaEnabled { get; set; }

public required MfaAuthenticators MfaTypes { get; set; }

public required string UserId { get; set; }
}
10 changes: 10 additions & 0 deletions src/Domain.Shared/Identities/MfaAuthenticators.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Domain.Shared.Identities;

[Flags]
public enum MfaAuthenticators
{
None = 0, // No Authenticator is required
OobSms = 2, // Code is sent "Out of Band" in an SMS message
OobEmail = 4, // Code is sent "Out of Band" in an email message
TotpAuthenticator = 8 // "Time-based One Time Password" is generated by a supported authenticator App
}
6 changes: 2 additions & 4 deletions src/IdentityApplication/PasswordCredentialsApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,8 @@ await _userNotificationsService.NotifyPasswordResetUnknownUserCourtesyAsync(call
}

public async Task<Result<PasswordCredential, Error>> RegisterPersonAsync(ICallerContext caller,
string? invitationToken, string firstName,
string lastName, string emailAddress, string password, string? timezone, string? countryCode,
bool termsAndConditionsAccepted,
CancellationToken cancellationToken)
string? invitationToken, string firstName, string lastName, string emailAddress, string password,
string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken)
{
var registered = await _endUsersService.RegisterPersonPrivateAsync(caller, invitationToken, emailAddress,
firstName, lastName, timezone, countryCode, termsAndConditionsAccepted, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Application.Persistence.Common;
using Common;
using Domain.Shared.Identities;
using QueryAny;

namespace IdentityApplication.Persistence.ReadModels;
Expand All @@ -9,6 +10,12 @@ public class PasswordCredential : ReadModelEntity
{
public Optional<bool> AccountLocked { get; set; }

public Optional<bool> IsMfaEnabled { get; set; }

public Optional<bool> MfaCanBeDisabled { get; set; }

public Optional<MfaAuthenticators> MfaTypes { get; set; }

public Optional<string> PasswordResetToken { get; set; }

public Optional<string> RegistrationVerificationToken { get; set; }
Expand Down
84 changes: 84 additions & 0 deletions src/IdentityDomain.UnitTests/MfaOptionsSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using Common;
using Domain.Shared.Identities;
using FluentAssertions;
using UnitTesting.Common;
using Xunit;

namespace IdentityDomain.UnitTests;

[Trait("Category", "Unit")]
public class MfaOptionsSpec
{
[Fact]
public void WhenCreateAndEnabledButNoAuthenticators_ThenReturnsError()
{
var result = MfaOptions.Create(true, true, MfaAuthenticators.None);

result.Should().BeError(ErrorCode.Validation, Resources.MfaOptions_NoAuthenticators);
}

[Fact]
public void WhenCreate_ThenCreates()
{
var result = MfaOptions.Create(true, true, MfaAuthenticators.TotpAuthenticator);

result.Should().BeSuccess();
result.Value.IsEnabled.Should().BeTrue();
result.Value.CanBeDisabled.Should().BeTrue();
result.Value.Types.Should().Be(MfaAuthenticators.TotpAuthenticator);
}

[Fact]
public void WhenChangeAndEnabledAndCannotBeDisabled_ThenReturnsError()
{
var options = MfaOptions.Create(true, false, MfaAuthenticators.TotpAuthenticator).Value;

var result = options.Change(MfaOptions.Create(false, false, MfaAuthenticators.TotpAuthenticator).Value);

result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_Change_CannotBeDisabled);
}

[Fact]
public void WhenChangeAndDisabledAndCannotBeDisabled_ThenReturnsError()
{
var options = MfaOptions.Create(false, false, MfaAuthenticators.None).Value;

var result = options.Change(MfaOptions.Create(true, false, MfaAuthenticators.TotpAuthenticator).Value);

result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_Change_CannotBeEnabled);
}

[Fact]
public void WhenChangeAndCanBeDisabledAndTryToChangeCanBeDisabled_ThenReturnsError()
{
var options = MfaOptions.Create(true, true, MfaAuthenticators.TotpAuthenticator).Value;

var result = options.Change(MfaOptions.Create(true, false, MfaAuthenticators.TotpAuthenticator).Value);

result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_CannotChangeCanBeDisabled);
}

[Fact]
public void WhenChangeAndCannotBeDisabledAndTryToChangeCanBeDisabled_ThenReturnsError()
{
var options = MfaOptions.Create(true, false, MfaAuthenticators.TotpAuthenticator).Value;

var result = options.Change(MfaOptions.Create(true, true, MfaAuthenticators.TotpAuthenticator).Value);

result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_CannotChangeCanBeDisabled);
}

[Fact]
public void WhenChange_ThenReturnsChanged()
{
var options = MfaOptions.Create(false, true, MfaAuthenticators.None).Value;

var result =
options.Change(MfaOptions.Create(true, true, MfaAuthenticators.TotpAuthenticator).Value);

result.Should().BeSuccess();
result.Value.IsEnabled.Should().BeTrue();
result.Value.CanBeDisabled.Should().BeTrue();
result.Value.Types.Should().Be(MfaAuthenticators.TotpAuthenticator);
}
}
83 changes: 63 additions & 20 deletions src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Domain.Interfaces.Entities;
using Domain.Services.Shared;
using Domain.Shared;
using Domain.Shared.Identities;
using FluentAssertions;
using IdentityDomain.DomainServices;
using Moq;
Expand All @@ -20,14 +21,17 @@ public class PasswordCredentialRootSpec
private const string Token = "5n6nA42SQrsO1UIgc7lIVebR6_3CmZwcthUEx3nF2sM";
private readonly PasswordCredentialRoot _credential;
private readonly Mock<IEmailAddressService> _emailAddressService;
private readonly Mock<IIdentifierFactory> _idFactory;
private readonly Mock<IPasswordHasherService> _passwordHasherService;
private readonly Mock<IRecorder> _recorder;
private readonly Mock<IConfigurationSettings> _settings;
private readonly Mock<ITokensService> _tokensService;

public PasswordCredentialRootSpec()
{
var recorder = new Mock<IRecorder>();
var idFactory = new Mock<IIdentifierFactory>();
idFactory.Setup(idf => idf.Create(It.IsAny<IIdentifiableEntity>()))
_recorder = new Mock<IRecorder>();
_idFactory = new Mock<IIdentifierFactory>();
_idFactory.Setup(idf => idf.Create(It.IsAny<IIdentifiableEntity>()))
.Returns("anid".ToId());

_passwordHasherService = new Mock<IPasswordHasherService>();
Expand All @@ -40,17 +44,17 @@ public PasswordCredentialRootSpec()
_tokensService = new Mock<ITokensService>();
_tokensService.Setup(ts => ts.CreateRegistrationVerificationToken())
.Returns("averificationtoken");
var settings = new Mock<IConfigurationSettings>();
settings.Setup(s => s.Platform.GetString(It.IsAny<string>(), It.IsAny<string>()))
_settings = new Mock<IConfigurationSettings>();
_settings.Setup(s => s.Platform.GetString(It.IsAny<string>(), It.IsAny<string>()))
.Returns((string?)null!);
settings.Setup(s => s.Platform.GetNumber(It.IsAny<string>(), It.IsAny<double>()))
_settings.Setup(s => s.Platform.GetNumber(It.IsAny<string>(), It.IsAny<double>()))
.Returns(5);
_emailAddressService = new Mock<IEmailAddressService>();
_emailAddressService.Setup(eas => eas.EnsureUniqueAsync(It.IsAny<EmailAddress>(), It.IsAny<Identifier>()))
.ReturnsAsync(true);

_credential = PasswordCredentialRoot.Create(recorder.Object, idFactory.Object,
settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object,
_credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object,
_settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object,
"auserid".ToId()).Value;
}

Expand All @@ -61,6 +65,9 @@ public void WhenConstructed_ThenInitialized()
_credential.Registration.Should().BeNone();
_credential.Login.IsReset.Should().BeTrue();
_credential.Password.PasswordHash.Should().BeNone();
_credential.MfaOptions.IsEnabled.Should().BeFalse();
_credential.MfaOptions.CanBeDisabled.Should().BeTrue();
_credential.MfaOptions.Types.Should().Be(MfaAuthenticators.None);
}

[Fact]
Expand All @@ -72,7 +79,7 @@ public void WhenInitiateRegistrationVerificationAndAlreadyVerified_ThenReturnsEr
var result = _credential.InitiateRegistrationVerification();

result.Should().BeError(ErrorCode.PreconditionViolation,
Resources.PasswordCredentialsRoot_RegistrationVerified);
Resources.PasswordCredentialRoot_RegistrationVerified);
}

[Fact]
Expand All @@ -93,7 +100,7 @@ public void WhenSetCredentialAndInvalidPassword_ThenReturnsError()

var result = _credential.SetPasswordCredential("notavalidpassword");

result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword);
result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialRoot_InvalidPassword);
}

[Fact]
Expand Down Expand Up @@ -124,7 +131,7 @@ public void WhenVerifyPasswordWithInvalidPassword_ThenReturnsError()

var result = _credential.VerifyPassword("1WrongPassword!");

result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword);
result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialRoot_InvalidPassword);
_credential.Login.FailedPasswordAttempts.Should().Be(0);
_credential.Login.IsLocked.Should().BeFalse();
_credential.Login.ToggledLocked.Should().BeFalse();
Expand Down Expand Up @@ -217,7 +224,7 @@ public void WhenVerifyRegistrationAndRegistered_ThenReturnsError()
var result = _credential.VerifyRegistration();

result.Should().BeError(ErrorCode.PreconditionViolation,
Resources.PasswordCredentialsRoot_RegistrationNotVerifying);
Resources.PasswordCredentialRoot_RegistrationNotVerifying);
}

[Fact]
Expand All @@ -231,7 +238,7 @@ public void WhenVerifyRegistrationAndExpired_ThenReturnsError()
var result = _credential.VerifyRegistration();

result.Should().BeError(ErrorCode.PreconditionViolation,
Resources.PasswordCredentialsRoot_RegistrationVerifyingExpired);
Resources.PasswordCredentialRoot_RegistrationVerifyingExpired);
}

[Fact]
Expand Down Expand Up @@ -269,7 +276,7 @@ public void WhenInitiatePasswordResetAndNotVerified_ThenReturnsError()
var result = _credential.InitiatePasswordReset();

result.Should().BeError(ErrorCode.PreconditionViolation,
Resources.PasswordCredentialsRoot_RegistrationUnverified);
Resources.PasswordCredentialRoot_RegistrationUnverified);
}

[Fact]
Expand Down Expand Up @@ -299,7 +306,7 @@ public void WhenCompletePasswordResetWithInvalidPassword_ThenReturnsError()

var result = _credential.CompletePasswordReset(Token, "apassword");

result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword);
result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialRoot_InvalidPassword);

_passwordHasherService.Verify(ph => ph.VerifyPassword(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
Expand All @@ -312,7 +319,7 @@ public void WhenCompletePasswordResetAndNoExistingPassword_ThenReturnsError()

var result = _credential.CompletePasswordReset(Token, "apassword");

result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsRoot_NoPassword);
result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_NoPassword);

_passwordHasherService.Verify(ph => ph.VerifyPassword(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
Expand All @@ -328,7 +335,7 @@ public void WhenCompletePasswordResetAndSameAsOldPassword_ThenReturnsError()

var result = _credential.CompletePasswordReset(Token, "apassword");

result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_DuplicatePassword);
result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialRoot_DuplicatePassword);

_passwordHasherService.Verify(ph => ph.VerifyPassword("apassword", "apasswordhash"));
}
Expand All @@ -349,7 +356,7 @@ public void WhenCompletePasswordResetAndExpired_ThenReturnsError()
var result = _credential.CompletePasswordReset("atoken", "apassword");

result.Should().BeError(ErrorCode.PreconditionViolation,
Resources.PasswordCredentialsRoot_PasswordResetTokenExpired);
Resources.PasswordCredentialRoot_PasswordResetTokenExpired);
}

[Fact]
Expand Down Expand Up @@ -413,7 +420,7 @@ public void WhenEnsureInvariantsAndRegisteredButEmailNotUnique_ThenReturnsErrors

var result = _credential.EnsureInvariants();

result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordCredentialsRoot_EmailNotUnique);
result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordCredentialRoot_EmailNotUnique);
}

[Fact]
Expand All @@ -433,6 +440,42 @@ public void WhenEnsureInvariantsAndInitiatingPasswordResetButUnRegistered_ThenRe
var result = _credential.EnsureInvariants();

result.Should().BeError(ErrorCode.RuleViolation,
Resources.PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration);
Resources.PasswordCredentialRoot_PasswordInitiatedWithoutRegistration);
}

[Fact]
public void WhenChangeMfaOptionsAndSameThenDoesNothing()
{
var mfaOptions = MfaOptions.Create(false, true, MfaAuthenticators.TotpAuthenticator).Value;
var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object,
_settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object,
"auserid".ToId(), mfaOptions).Value;

var result =
credential.ChangeMfaOptions(MfaOptions.Create(false, true, MfaAuthenticators.TotpAuthenticator).Value);

result.Should().BeSuccess();
credential.MfaOptions.IsEnabled.Should().BeFalse();
credential.MfaOptions.CanBeDisabled.Should().BeTrue();
credential.MfaOptions.Types.Should().Be(MfaAuthenticators.TotpAuthenticator);
credential.Events.Last().Should().NotBeOfType<MfaOptionsChanged>();
}

[Fact]
public void WhenChangeMfaOptionsAndDifferent_ThenChanges()
{
var mfaOptions = MfaOptions.Create(false, true, MfaAuthenticators.None).Value;
var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object,
_settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object,
"auserid".ToId(), mfaOptions).Value;

var result =
credential.ChangeMfaOptions(MfaOptions.Create(true, true, MfaAuthenticators.TotpAuthenticator).Value);

result.Should().BeSuccess();
credential.MfaOptions.IsEnabled.Should().BeTrue();
credential.MfaOptions.CanBeDisabled.Should().BeTrue();
credential.MfaOptions.Types.Should().Be(MfaAuthenticators.TotpAuthenticator);
credential.Events.Last().Should().BeOfType<MfaOptionsChanged>();
}
}
Loading

0 comments on commit c1dea5c

Please sign in to comment.