Skip to content

Commit

Permalink
Verify MFA factors for OOBSMS, OOBEmail, TOTP Authenticator and Recov…
Browse files Browse the repository at this point in the history
…eryCodes
  • Loading branch information
jezzsantos committed Nov 23, 2024
1 parent 868da9f commit bc35975
Show file tree
Hide file tree
Showing 23 changed files with 561 additions and 48 deletions.
9 changes: 9 additions & 0 deletions src/Application.Interfaces/Audits.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Application.Interfaces/Audits.resx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
<data name="PasswordCredentialsApplication_MfaAuthenticate_Failed" xml:space="preserve">
<value>Authentication.Password.Mfa.Failed.InvalidMfa</value>
</data>
<data name="PasswordCredentialsApplication_MfaReset" xml:space="preserve">
<value>Authentication.Password.Mfa.Reset</value>
</data>
<data name="EndUsersApplication_User_Registered_TermsAccepted" xml:space="preserve">
<value>EndUser.Registered.TermsAccepted</value>
</data>
Expand Down
2 changes: 2 additions & 0 deletions src/Application.Resources.Shared/PasswordCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public class PasswordCredential : IIdentifiableResource
public required EndUser User { get; set; }

public required string Id { get; set; }

public bool IsMfaEnabled { get; set; }
}

public class PasswordCredentialConfirmation
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Domain.Common;
using Domain.Common.ValueObjects;
using JetBrains.Annotations;

namespace Domain.Events.Shared.Identities.PasswordCredentials;

#pragma warning disable SAASDDD043
public sealed class MfaStateReset : DomainEvent
#pragma warning restore SAASDDD043
{
public MfaStateReset(Identifier id) : base(id)
{
}

[UsedImplicitly]
public MfaStateReset()
{
}

public required bool CanBeDisabled { get; set; }

public required bool IsEnabled { get; set; }

public required string UserId { get; set; }
}
2 changes: 1 addition & 1 deletion src/EndUsersDomain/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/EndUsersDomain/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
<value>A membership must always have at least the feature '{0}'</value>
</data>
<data name="EndUserRoot_NotOperator" xml:space="preserve">
<value>The assigner is not a member of the operations team</value>
<value>This user is not a member of the operations team</value>
</data>
<data name="GuestInvitation_NotInvited" xml:space="preserve">
<value>This invitation has not been sent yet</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Common.Extensions;
using Domain.Common.Identity;
using Domain.Common.ValueObjects;
using Domain.Interfaces.Authorization;
using Domain.Interfaces.Entities;
using Domain.Services.Shared;
using Domain.Shared;
Expand Down Expand Up @@ -139,7 +140,7 @@ public PasswordCredentialsApplicationMfaSpec()
}

[Fact]
public async Task WhenChangeMfaAsyncAndNotExists_ThenReturnsError()
public async Task WhenChangeMfaAsyncAndUserNotExists_ThenReturnsError()
{
_repository.Setup(s =>
s.FindCredentialsByUserIdAsync(It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
Expand Down Expand Up @@ -177,7 +178,7 @@ public async Task WhenChangeMfaAsyncAndNotAPerson_ThenReturnsError()

result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsApplication_NotPerson);
_endUsersService.Verify(eus =>
eus.GetUserPrivateAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
eus.GetUserPrivateAsync(_caller.Object, "acallerid",
It.IsAny<CancellationToken>()));
_repository.Verify(s => s.SaveAsync(It.IsAny<PasswordCredentialRoot>(), It.IsAny<CancellationToken>()),
Times.Never);
Expand Down Expand Up @@ -205,8 +206,9 @@ public async Task WhenChangeMfaAsync_ThenEnablesMfa()
await _application.ChangeMfaAsync(_caller.Object, true, CancellationToken.None);

result.Should().BeSuccess();
result.Value.IsMfaEnabled.Should().BeTrue();
_endUsersService.Verify(eus =>
eus.GetUserPrivateAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
eus.GetUserPrivateAsync(_caller.Object, "auserid",
It.IsAny<CancellationToken>()));
_repository.Verify(s => s.SaveAsync(It.Is<PasswordCredentialRoot>(root =>
root.MfaOptions.IsEnabled
Expand Down Expand Up @@ -1360,6 +1362,84 @@ await _application.VerifyMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
eus.GetMembershipsPrivateAsync(_caller.Object, "auserid",
It.IsAny<CancellationToken>()));
}

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

var result =
await _application.ResetPasswordMfaAsync(_caller.Object, "auserid", CancellationToken.None);

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

[Fact]
public async Task WhenResetPasswordMfaAsyncAndNotAPerson_ThenReturnsError()
{
var credential = CreateVerifiedCredential();
_repository.Setup(s =>
s.FindCredentialsByUserIdAsync(It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(credential.ToOptional());
_endUsersService.Setup(eus =>
eus.GetUserPrivateAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new EndUser
{
Id = "auserid",
Classification = EndUserClassification.Machine
});

var result =
await _application.ResetPasswordMfaAsync(_caller.Object, "auserid", CancellationToken.None);

result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsApplication_NotPerson);
_endUsersService.Verify(eus =>
eus.GetUserPrivateAsync(_caller.Object, "auserid",
It.IsAny<CancellationToken>()));
_repository.Verify(s => s.SaveAsync(It.IsAny<PasswordCredentialRoot>(), It.IsAny<CancellationToken>()),
Times.Never);
}

[Fact]
public async Task WhenResetPasswordMfaAsync_ThenResetsMfaState()
{
_caller.Setup(cc => cc.CallerId)
.Returns("anoperatorid");
_caller.Setup(cc => cc.Roles).Returns(new ICallerContext.CallerRoles([PlatformRoles.Operations], []));
var credential = CreateVerifiedCredential();
_repository.Setup(s =>
s.FindCredentialsByUserIdAsync(It.IsAny<Identifier>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(credential.ToOptional());
_endUsersService.Setup(eus =>
eus.GetUserPrivateAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new EndUser
{
Id = "auserid",
Classification = EndUserClassification.Person
});

var result =
await _application.ResetPasswordMfaAsync(_caller.Object, "auserid", CancellationToken.None);

result.Should().BeSuccess();
result.Value.IsMfaEnabled.Should().BeFalse();
_endUsersService.Verify(eus =>
eus.GetUserPrivateAsync(_caller.Object, "auserid",
It.IsAny<CancellationToken>()));
_repository.Verify(s => s.SaveAsync(It.Is<PasswordCredentialRoot>(root =>
root.MfaOptions.IsEnabled == false
&& root.MfaOptions.CanBeDisabled == true
), It.IsAny<CancellationToken>()));
}

private PasswordCredentialRoot CreateUnVerifiedCredential()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Task<Result<Error>> DisassociateMfaAuthenticatorAsync(ICallerContext caller, str
Task<Result<List<PasswordCredentialMfaAuthenticator>, Error>> ListMfaAuthenticatorsAsync(ICallerContext caller,
string? mfaToken, CancellationToken cancellationToken);

Task<Result<PasswordCredential, Error>> ResetPasswordMfaAsync(ICallerContext caller, string userId,
CancellationToken cancellationToken);

Task<Result<AuthenticateTokens, Error>> VerifyMfaAuthenticatorAsync(ICallerContext caller, string mfaToken,
PasswordCredentialMfaAuthenticatorType authenticatorType, string? oobCode, string confirmationCode,
CancellationToken cancellationToken);
Expand Down
57 changes: 56 additions & 1 deletion src/IdentityApplication/PasswordCredentialsApplication.Mfa.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ public async Task<Result<PasswordCredential, Error>> ChangeMfaAsync(ICallerConte
return Error.EntityNotFound();
}

var retrievedUser = await _endUsersService.GetUserPrivateAsync(caller, caller.ToCallerId(), cancellationToken);
var retrievedUser = await _endUsersService.GetUserPrivateAsync(caller, caller.CallerId, cancellationToken);
if (retrievedUser.IsFailure)
{
return retrievedUser.Error;
Expand Down Expand Up @@ -338,6 +338,61 @@ public async Task<Result<List<PasswordCredentialMfaAuthenticator>, Error>> ListM
return credential.ToMfaAuthenticators();
}

public async Task<Result<PasswordCredential, Error>> ResetPasswordMfaAsync(ICallerContext caller, string userId,
CancellationToken cancellationToken)
{
var retrievedCredential =
await _repository.FindCredentialsByUserIdAsync(userId.ToId(), cancellationToken);
if (retrievedCredential.IsFailure)
{
return retrievedCredential.Error;
}

if (!retrievedCredential.Value.HasValue)
{
return Error.EntityNotFound();
}

var retrievedUser = await _endUsersService.GetUserPrivateAsync(caller, userId, cancellationToken);
if (retrievedUser.IsFailure)
{
return retrievedUser.Error;
}

var user = retrievedUser.Value;
if (user.Classification != EndUserClassification.Person)
{
return Error.PreconditionViolation(Resources.PasswordCredentialsApplication_NotPerson);
}

var credential = retrievedCredential.Value.Value;
var resetterRoles = Roles.Create(caller.Roles.All);
if (resetterRoles.IsFailure)
{
return resetterRoles.Error;
}

var reset = credential.ResetMfa(resetterRoles.Value);
if (reset.IsFailure)
{
return reset.Error;
}

var saved = await _repository.SaveAsync(credential, cancellationToken);
if (saved.IsFailure)
{
return saved.Error;
}

_recorder.TraceInformation(caller.ToCall(), "Password credentials for {UserId} has had MFA reset by {Operator}",
credential.UserId, caller.CallerId);
_recorder.AuditAgainst(caller.ToCall(), credential.UserId,
Audits.PasswordCredentialsApplication_MfaReset,
"User {Id} had their MFA state reset by {Operator}", credential.UserId, caller.CallerId);

return credential.ToCredential(user);
}

public async Task<Result<AuthenticateTokens, Error>> VerifyMfaAuthenticatorAsync(ICallerContext caller,
string mfaToken,
PasswordCredentialMfaAuthenticatorType authenticatorType, string? oobCode, string confirmationCode,
Expand Down
1 change: 1 addition & 0 deletions src/IdentityApplication/PasswordCredentialsApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ public static PasswordCredential ToCredential(this PasswordCredentialRoot creden
return new PasswordCredential
{
Id = credential.Id,
IsMfaEnabled = credential.IsMfaEnabled,
User = user
};
}
Expand Down
Loading

0 comments on commit bc35975

Please sign in to comment.