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 e4e7669 commit 868da9f
Show file tree
Hide file tree
Showing 46 changed files with 2,942 additions and 1,049 deletions.
2 changes: 1 addition & 1 deletion iac/AzureSQLServer-Seed-Eventing-Generic.sql
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ CREATE TABLE [dbo].[MfaAuthenticator]
[LastPersistedAtUtc] [datetime] NULL,
[IsDeleted] [bit] NULL,
[BarCodeUri] [nvarchar](max) NULL,
[ConfirmationState] [nvarchar](max) NULL,
[VerifiedState] [nvarchar](max) NULL,
[IsActive] [bit] NULL,
[State] [nvarchar](max) NULL,
[OobChannelValue] [nvarchar](max) NULL,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Domain.Common;
using Domain.Common.ValueObjects;
using Domain.Shared.Identities;
using JetBrains.Annotations;

namespace Domain.Events.Shared.Identities.PasswordCredentials;

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

[UsedImplicitly]
public MfaAuthenticatorAssociated()
{
}

public required string AuthenticatorId { get; set; }

public string? BarCodeUri { get; set; }

public string? OobChannelValue { get; set; }

public string? OobCode { get; set; }

public string? Secret { get; set; }

public required MfaAuthenticatorType Type { get; set; }

public required string UserId { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public MfaAuthenticatorChallenged()

public string? OobCode { get; set; }

public string? SecretHash { get; set; }
public string? Secret { get; set; }

public required MfaAuthenticatorType Type { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Domain.Common;
using Domain.Common.ValueObjects;
using Domain.Shared.Identities;
using JetBrains.Annotations;

namespace Domain.Events.Shared.Identities.PasswordCredentials;

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

[UsedImplicitly]
public MfaAuthenticatorConfirmed()
{
}

public required string AuthenticatorId { get; set; }

public string? ConfirmationCode { get; set; }

public required bool IsActive { get; set; }

public string? OobCode { get; set; }

public required MfaAuthenticatorType Type { get; set; }

public required string UserId { get; set; }

public string? VerifiedState { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@

namespace Domain.Events.Shared.Identities.PasswordCredentials;

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

[UsedImplicitly]
public MfaAuthenticatorAssociationConfirmed()
public MfaAuthenticatorVerified()
{
}

public required string AuthenticatorId { get; set; }

public string? ConfirmationCode { get; set; }

public string? ConfirmationState { get; set; }
public string? VerifiedState { get; set; }

public string? OobCode { get; set; }

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class PasswordCredentialsApplicationPasswordResetSpec
private readonly Mock<IPasswordCredentialsRepository> _repository;
private readonly Mock<IConfigurationSettings> _settings;
private readonly Mock<ITokensService> _tokensService;
private readonly Mock<IEncryptionService> _encryptionService;

public PasswordCredentialsApplicationPasswordResetSpec()
{
Expand All @@ -59,6 +60,7 @@ public PasswordCredentialsApplicationPasswordResetSpec()
.Returns("averificationtoken");
_tokensService.Setup(ts => ts.CreateMfaAuthenticationToken())
.Returns("anmfatoken");
_encryptionService = new Mock<IEncryptionService>();
_passwordHasherService = new Mock<IPasswordHasherService>();
_passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(true);
Expand All @@ -82,7 +84,8 @@ public PasswordCredentialsApplicationPasswordResetSpec()

_application = new PasswordCredentialsApplication(_recorder.Object, _idFactory.Object, endUsersService.Object,
userProfilesService.Object, _notificationsService.Object, _settings.Object, _emailAddressService.Object,
_tokensService.Object, _passwordHasherService.Object, _mfaService.Object, authTokensService.Object,
_tokensService.Object, _encryptionService.Object, _passwordHasherService.Object, _mfaService.Object,
authTokensService.Object,
websiteUiService.Object,
_repository.Object);
}
Expand Down Expand Up @@ -273,7 +276,8 @@ private PasswordCredentialRoot CreateVerifiedCredential()
private PasswordCredentialRoot CreateCredential()
{
return PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, _settings.Object,
_emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object,
_emailAddressService.Object, _tokensService.Object, _encryptionService.Object,
_passwordHasherService.Object,
_mfaService.Object, "auserid".ToId()).Value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public class PasswordCredentialsApplicationSpec
private readonly Mock<IConfigurationSettings> _settings;
private readonly Mock<ITokensService> _tokensService;
private readonly Mock<IUserProfilesService> _userProfilesService;
private readonly Mock<IEncryptionService> _encryptionService;

public PasswordCredentialsApplicationSpec()
{
Expand All @@ -65,6 +66,7 @@ public PasswordCredentialsApplicationSpec()
.Returns("averificationtoken");
_tokensService.Setup(ts => ts.CreateMfaAuthenticationToken())
.Returns("anmfatoken");
_encryptionService = new Mock<IEncryptionService>();
_passwordHasherService = new Mock<IPasswordHasherService>();
_passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(true);
Expand All @@ -88,7 +90,8 @@ public PasswordCredentialsApplicationSpec()

_application = new PasswordCredentialsApplication(_recorder.Object, _idFactory.Object, _endUsersService.Object,
_userProfilesService.Object, _notificationsService.Object, _settings.Object, _emailAddressService.Object,
_tokensService.Object, _passwordHasherService.Object, _mfaService.Object, _authTokensService.Object,
_tokensService.Object, _encryptionService.Object, _passwordHasherService.Object, _mfaService.Object,
_authTokensService.Object,
websiteUiService.Object,
_repository.Object);
}
Expand Down Expand Up @@ -598,7 +601,8 @@ private PasswordCredentialRoot CreateVerifiedCredential()
private PasswordCredentialRoot CreateCredential()
{
return PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, _settings.Object,
_emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object,
_emailAddressService.Object, _tokensService.Object, _encryptionService.Object,
_passwordHasherService.Object,
_mfaService.Object, "auserid".ToId()).Value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@ Task<Result<Error>> DisassociateMfaAuthenticatorAsync(ICallerContext caller, str

Task<Result<List<PasswordCredentialMfaAuthenticator>, Error>> ListMfaAuthenticatorsAsync(ICallerContext caller,
string? mfaToken, CancellationToken cancellationToken);

Task<Result<AuthenticateTokens, Error>> VerifyMfaAuthenticatorAsync(ICallerContext caller, string mfaToken,
PasswordCredentialMfaAuthenticatorType authenticatorType, string? oobCode, string confirmationCode,
CancellationToken cancellationToken);
}
84 changes: 75 additions & 9 deletions src/IdentityApplication/PasswordCredentialsApplication.Mfa.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Common;
using Common.Extensions;
using Domain.Common.ValueObjects;
using Domain.Services.Shared;
using Domain.Shared;
using Domain.Shared.Identities;
using IdentityDomain;
Expand Down Expand Up @@ -52,7 +53,7 @@ public async Task<Result<AssociatedPasswordCredentialMfaAuthenticator, Error>> A
var userProfile = retrievedProfile.Value;
var oobPhoneNumber = DerivePhoneNumber(phoneNumber, userProfile);
var oobEmailAddress = DeriveEmailAddress(userProfile);
var associated = await credential.AssociateMfaAuthenticatorAsync(callerId,
var associated = await credential.AssociateMfaAuthenticatorAsync(caller.IsAuthenticated, callerId,
authenticatorType.ToEnumOrDefault(MfaAuthenticatorType.None), oobPhoneNumber, oobEmailAddress.Value,
challengedAuthenticator => ChallengeAuthenticator(caller, challengedAuthenticator, cancellationToken));
if (associated.IsFailure)
Expand All @@ -68,7 +69,7 @@ public async Task<Result<AssociatedPasswordCredentialMfaAuthenticator, Error>> A

credential = saved.Value;
var authenticator = associated.Value;
var isFirstAuthenticator = credential.MfaAuthenticators.HasOnlyOnePlusRecoveryCodes;
var isFirstAuthenticator = credential.MfaAuthenticators.HasOnlyOneUnconfirmedPlusRecoveryCodes;
_recorder.TraceInformation(caller.ToCall(),
"Password credentials for {UserId} is associating MFA authenticator {AuthenticatorType}",
credential.UserId, authenticatorType);
Expand All @@ -80,7 +81,7 @@ public async Task<Result<AssociatedPasswordCredentialMfaAuthenticator, Error>> A
{ UsageConstants.Properties.MfaAuthenticatorType, authenticatorType }
});

return credential.ToAssociatedAuthenticator(authenticator, isFirstAuthenticator);
return credential.ToAssociatedAuthenticator(authenticator, isFirstAuthenticator, _encryptionService);

static Optional<PhoneNumber> DerivePhoneNumber(string? phoneNumber, UserProfile userProfile)
{
Expand Down Expand Up @@ -337,22 +338,86 @@ public async Task<Result<List<PasswordCredentialMfaAuthenticator>, Error>> ListM
return credential.ToMfaAuthenticators();
}

private async Task<Result<Error>> ChallengeAuthenticator(ICallerContext caller, MfaAuthenticator authenticator,
public async Task<Result<AuthenticateTokens, Error>> VerifyMfaAuthenticatorAsync(ICallerContext caller,
string mfaToken,
PasswordCredentialMfaAuthenticatorType authenticatorType, string? oobCode, string confirmationCode,
CancellationToken cancellationToken)
{
await Task.CompletedTask;
var authenticated = await AuthenticateUserForMfaInternalAsync(caller, mfaToken, cancellationToken);
if (authenticated.IsFailure)
{
return authenticated.Error;
}

var credential = authenticated.Value;
var callerId = authenticated.Value.UserId;
var verified = credential.VerifyMfaAuthenticator(caller.IsAuthenticated, callerId,
authenticatorType.ToEnumOrDefault(MfaAuthenticatorType.None), oobCode, confirmationCode);
if (verified.IsFailure)
{
if (verified.Error.Code == ErrorCode.NotAuthenticated)
{
_recorder.AuditAgainst(caller.ToCall(), credential.UserId,
Audits.PasswordCredentialsApplication_MfaAuthenticate_Failed,
"User {Id} failed to authenticate with invalid 2FA", credential.UserId);
}

return verified.Error;
}

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

credential = saved.Value;
_recorder.TraceInformation(caller.ToCall(),
"Password credentials for {UserId} has successfully authenticated for MFA authenticator {AuthenticatorType}",
credential.UserId, authenticatorType);
_recorder.AuditAgainst(caller.ToCall(), credential.UserId,
Audits.PasswordCredentialsApplication_MfaAuthenticate_Succeeded,
"User {Id} succeeded to authenticate with MFA factor {AuthenticatorType}", credential.UserId,
authenticatorType);
_recorder.TrackUsage(caller.ToCall(),
UsageConstants.Events.UsageScenarios.Generic.UserPasswordMfaAuthenticated,
new Dictionary<string, object>
{
{ nameof(credential.Id), credential.UserId },
{ UsageConstants.Properties.MfaAuthenticatorType, authenticatorType }
});

var retrievedUser =
await _endUsersService.GetMembershipsPrivateAsync(caller, credential.UserId, cancellationToken);
if (retrievedUser.IsFailure)
{
return Error.NotAuthenticated();
}

var user = retrievedUser.Value;
return await IssueAuthenticationTokensAsync(caller, user, cancellationToken);
}

private async Task<Result<Error>> ChallengeAuthenticator(ICallerContext caller, MfaAuthenticator authenticator,
CancellationToken cancellationToken)
{
switch (authenticator.Type)
{
case MfaAuthenticatorType.OobSms:
{
var secret = _encryptionService.Decrypt(authenticator.Secret);
return await _userNotificationsService.NotifyPasswordMfaOobSmsAsync(caller,
authenticator.OobChannelValue, authenticator.OobCode,
authenticator.OobChannelValue, secret,
UserNotificationConstants.EmailTags.PasswordMfaOob, cancellationToken);
}

case MfaAuthenticatorType.OobEmail:
{
var secret = _encryptionService.Decrypt(authenticator.Secret);
return await _userNotificationsService.NotifyPasswordMfaOobEmailAsync(caller,
authenticator.OobChannelValue, authenticator.OobCode,
authenticator.OobChannelValue, secret,
UserNotificationConstants.EmailTags.PasswordMfaOob, cancellationToken);
}

default:
return Result.Ok;
Expand Down Expand Up @@ -421,13 +486,14 @@ private async Task<Result<PasswordCredentialRoot, Error>> AuthenticateUserForMfa
internal static class PasswordCredentialMfaConversionExtensions
{
public static AssociatedPasswordCredentialMfaAuthenticator ToAssociatedAuthenticator(
this PasswordCredentialRoot credential, MfaAuthenticator authenticator, bool showRecoveryCodes)
this PasswordCredentialRoot credential, MfaAuthenticator authenticator, bool showRecoveryCodes,
IEncryptionService encryptionService)
{
return new AssociatedPasswordCredentialMfaAuthenticator
{
Type = authenticator.Type.ToEnum<MfaAuthenticatorType, PasswordCredentialMfaAuthenticatorType>(),
RecoveryCodes = showRecoveryCodes
? credential.MfaAuthenticators.GetRecoveryCodes()
? credential.MfaAuthenticators.ToRecoveryCodes(encryptionService)
: null,
BarCodeUri = authenticator.BarCodeUri,
OobCode = authenticator.OobCode
Expand Down
10 changes: 6 additions & 4 deletions src/IdentityApplication/PasswordCredentialsApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,17 @@ public partial class PasswordCredentialsApplication : IPasswordCredentialsApplic
private readonly IPasswordCredentialsRepository _repository;
private readonly IDelayGenerator _delayGenerator;
private readonly IUserProfilesService _userProfilesService;
private readonly IEncryptionService _encryptionService;

public PasswordCredentialsApplication(IRecorder recorder, IIdentifierFactory identifierFactory,
IEndUsersService endUsersService, IUserProfilesService userProfilesService,
IUserNotificationsService userNotificationsService,
IConfigurationSettings settings,
IEmailAddressService emailAddressService, ITokensService tokensService,
IEmailAddressService emailAddressService, ITokensService tokensService, IEncryptionService encryptionService,
IPasswordHasherService passwordHasherService, IMfaService mfaService, IAuthTokensService authTokensService,
IWebsiteUiService websiteUiService,
IPasswordCredentialsRepository repository) : this(recorder, identifierFactory, endUsersService,
userProfilesService, userNotificationsService, settings, emailAddressService, tokensService,
userProfilesService, userNotificationsService, settings, emailAddressService, tokensService, encryptionService,
passwordHasherService, mfaService, authTokensService, websiteUiService, repository, new DelayGenerator())
{
_recorder = recorder;
Expand All @@ -63,7 +64,7 @@ private PasswordCredentialsApplication(IRecorder recorder, IIdentifierFactory id
IEndUsersService endUsersService, IUserProfilesService userProfilesService,
IUserNotificationsService userNotificationsService,
IConfigurationSettings settings,
IEmailAddressService emailAddressService, ITokensService tokensService,
IEmailAddressService emailAddressService, ITokensService tokensService, IEncryptionService encryptionService,
IPasswordHasherService passwordHasherService, IMfaService mfaService, IAuthTokensService authTokensService,
IWebsiteUiService websiteUiService,
IPasswordCredentialsRepository repository,
Expand All @@ -77,6 +78,7 @@ private PasswordCredentialsApplication(IRecorder recorder, IIdentifierFactory id
_settings = settings;
_emailAddressService = emailAddressService;
_tokensService = tokensService;
_encryptionService = encryptionService;
_passwordHasherService = passwordHasherService;
_mfaService = mfaService;
_authTokensService = authTokensService;
Expand Down Expand Up @@ -335,7 +337,7 @@ private async Task<Result<PasswordCredential, Error>> RegisterPersonInternalAsyn
}

var created = PasswordCredentialRoot.Create(_recorder, _identifierFactory, _settings, _emailAddressService,
_tokensService, _passwordHasherService, _mfaService, user.Id.ToId());
_tokensService, _encryptionService, _passwordHasherService, _mfaService, user.Id.ToId());
if (created.IsFailure)
{
return created.Error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class MfaAuthenticator : ReadModelEntity
{
public Optional<string> BarCodeUri { get; set; }

public Optional<string> ConfirmationState { get; set; }
public Optional<string> VerifiedState { get; set; }

public bool IsActive { get; set; }

Expand Down
Loading

0 comments on commit 868da9f

Please sign in to comment.