Skip to content

Commit

Permalink
Added MFA support for PasswordCredentials. Closes #52
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Nov 26, 2024
1 parent 570b894 commit f1be55d
Show file tree
Hide file tree
Showing 41 changed files with 780 additions and 323 deletions.
14 changes: 11 additions & 3 deletions docs/design-principles/0000-all-use-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ Legend:

* <sup>OPS</sup> denotes a support API that is only accessible to operations team of the platform

### Cars
### Cars (Sample)

1. Register a new car <sup>$$</sup>
> This is sample subdomain, and is expected to be deleted when this product goes to production
1. Register a new car <sup>$$$</sup>
2. Delete a car <sup>$$$</sup>
3. Schedule the car for "maintenance" <sup>$$$</sup>
4. Take a car "offline" <sup>$$$</sup>
Expand All @@ -27,7 +29,9 @@ Legend:
7. Find all the cars on the platform <sup>$$$</sup>
8. Find all the available cars for a specific time frame <sup>$$$</sup>

### Bookings
### Bookings (Sample)

> This is sample subdomain, and is expected to be deleted when this product goes to production
1. Make a booking for a specific car and time frame <sup>$$$</sup>
2. Cancel an existing booking <sup>$$$</sup>
Expand Down Expand Up @@ -109,6 +113,10 @@ These are the end users on the platform.

### Identities

Identity is the way that a user can authenticate with the platform.

1. Fetch the identity characteristics about the authenticated user

#### API Keys

API Key are the way a user (person or machine) can authenticate with the platform using an API key.
Expand Down
140 changes: 91 additions & 49 deletions docs/design-principles/0090-authentication-authorization.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/.idea/.idea.SaaStack/.idea/codeStyles/Project.xml

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

15 changes: 12 additions & 3 deletions src/Application.Resources.Shared/Identity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

namespace Application.Resources.Shared;

public class Identity : IIdentifiableResource
{
public required bool IsMfaEnabled { get; set; }

Check warning on line 7 in src/Application.Resources.Shared/Identity.cs

View workflow job for this annotation

GitHub Actions / build

Property 'IsMfaEnabled' must return one of these primitive types: 'bool or string or ulong or int or long or double or decimal or System.DateTime or byte or System.IO.Stream', or a List<T>/Dictionary<string, T> of one of those types, or be another type in the 'Application.Resources.Shared' namespace

public required bool HasCredentials { get; set; }

Check warning on line 9 in src/Application.Resources.Shared/Identity.cs

View workflow job for this annotation

GitHub Actions / build

Property 'HasCredentials' must return one of these primitive types: 'bool or string or ulong or int or long or double or decimal or System.DateTime or byte or System.IO.Stream', or a List<T>/Dictionary<string, T> of one of those types, or be another type in the 'Application.Resources.Shared' namespace

public required string Id { get; set; }

Check warning on line 11 in src/Application.Resources.Shared/Identity.cs

View workflow job for this annotation

GitHub Actions / build

Property 'Id' must return one of these primitive types: 'bool or string or ulong or int or long or double or decimal or System.DateTime or byte or System.IO.Stream', or a List<T>/Dictionary<string, T> of one of those types, or be another type in the 'Application.Resources.Shared' namespace
}

public class AuthenticateTokens
{
public required AuthenticationToken AccessToken { get; set; }
Expand Down Expand Up @@ -62,7 +71,7 @@ public AuthToken(TokenType type, string value, DateTime? expiresOn)

public enum TokenType
{
OtherToken = 0,
AccessToken = 1,
RefreshToken = 2
OtherToken = 0, // e.g. idToken
AccessToken = 1, // access_token
RefreshToken = 2 // refresh_token
}
6 changes: 3 additions & 3 deletions src/Application.Resources.Shared/PasswordCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public class PasswordCredentialMfaAuthenticator : IIdentifiableResource
public required string Id { get; set; }
}

public class PasswordCredentialMfaAssociation
public class PasswordCredentialMfaAuthenticatorAssociation
{
public string? BarCodeUri { get; set; }

Expand All @@ -49,14 +49,14 @@ public class PasswordCredentialMfaAssociation
public required PasswordCredentialMfaAuthenticatorType Type { get; set; }
}

public class PasswordCredentialMfaChallenge
public class PasswordCredentialMfaAuthenticatorChallenge
{
public string? OobCode { get; set; }

public PasswordCredentialMfaAuthenticatorType Type { get; set; }
}

public class PasswordCredentialMfaConfirmation
public class PasswordCredentialMfaAuthenticatorConfirmation
{
public AuthenticateTokens? Tokens { get; set; }

Expand Down
24 changes: 22 additions & 2 deletions src/Application.Services.Shared/ISSOService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,32 @@

namespace Application.Services.Shared;

/// <summary>
/// Defines a service to access and manage the SSO AuthTokens for a user
/// </summary>
public interface ISSOService
{
/// <summary>
/// Retrieves the list of tokens for the current user
/// </summary>
Task<Result<IReadOnlyList<ProviderAuthenticationTokens>, Error>> GetTokensAsync(ICallerContext caller,
string userId,
CancellationToken cancellationToken);

Task<Result<ProviderAuthenticationTokens, Error>> RefreshTokenAsync(ICallerContext caller, string userId,
/// <summary>
/// Retrieves the list of tokens for the specified user
/// </summary>
Task<Result<IReadOnlyList<ProviderAuthenticationTokens>, Error>> GetTokensOnBehalfOfUserAsync(ICallerContext caller,
string userId, CancellationToken cancellationToken);

/// <summary>
/// Refreshes the specified <see cref="refreshToken" /> for the current user
/// </summary>
Task<Result<ProviderAuthenticationTokens, Error>> RefreshTokenAsync(ICallerContext caller,
string providerName, string refreshToken, CancellationToken cancellationToken);

/// <summary>
/// Refreshes the specified <see cref="refreshToken" /> for the specified user
/// </summary>
Task<Result<ProviderAuthenticationTokens, Error>> RefreshTokenOnBehalfOfUserAsync(ICallerContext caller,
string userId, string providerName, string refreshToken, CancellationToken cancellationToken);
}
9 changes: 9 additions & 0 deletions src/Common/Error.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ public bool Is(ErrorCode code, string? message = null)
return code == Code && (message == null || message == Message);
}

/// <summary>
/// Whether this error is notof the specified <see cref="code" />
/// and optional <see cref="message" />
/// </summary>
public bool IsNot(ErrorCode code, string? message = null)
{
return !Is(code, message);
}

/// <summary>
/// Creates a <see cref="ErrorCode.NoError" /> error
/// </summary>
Expand Down
65 changes: 65 additions & 0 deletions src/IdentityApplication.UnitTests/IdentityApplicationSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Application.Interfaces;
using Application.Resources.Shared;
using Common;
using FluentAssertions;
using IdentityApplication.ApplicationServices;
using Moq;
using UnitTesting.Common;
using Xunit;

namespace IdentityApplication.UnitTests;

[Trait("Category", "Unit")]
public class IdentityApplicationSpec
{
private readonly IdentityApplication _application;
private readonly Mock<ICallerContext> _caller;
private readonly Mock<IPasswordCredentialsService> _passwordCredentialsService;

public IdentityApplicationSpec()
{
_caller = new Mock<ICallerContext>();
_caller.Setup(c => c.CallerId)
.Returns("acallerid");
_passwordCredentialsService = new Mock<IPasswordCredentialsService>();
_application = new IdentityApplication(_passwordCredentialsService.Object);
}

[Fact]
public async Task WhenGetIdentityAsyncAndCredentialNotExist_ThenReturnsIdentity()
{
_passwordCredentialsService.Setup(pcs =>
pcs.GetCredentialsPrivateAsync(It.IsAny<ICallerContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Error.EntityNotFound());

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

result.Should().BeSuccess();
result.Value.Id.Should().Be("acallerid");
result.Value.IsMfaEnabled.Should().BeFalse();
result.Value.HasCredentials.Should().BeFalse();
}

[Fact]
public async Task WhenGetIdentityAsyncAndCredentialExists_ThenReturnsIdentity()
{
_passwordCredentialsService.Setup(pcs =>
pcs.GetCredentialsPrivateAsync(It.IsAny<ICallerContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PasswordCredential
{
Id = "auserid",
User = new EndUser
{
Id = "auserid"
},
IsMfaEnabled = true
});

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

result.Should().BeSuccess();
result.Value.Id.Should().Be("acallerid");
result.Value.IsMfaEnabled.Should().BeTrue();
result.Value.HasCredentials.Should().BeTrue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class PasswordCredentialsApplicationSpec
private readonly Mock<IAuthTokensService> _authTokensService;
private readonly Mock<ICallerContext> _caller;
private readonly Mock<IEmailAddressService> _emailAddressService;
private readonly Mock<IEncryptionService> _encryptionService;
private readonly Mock<IEndUsersService> _endUsersService;
private readonly Mock<IIdentifierFactory> _idFactory;
private readonly Mock<IMfaService> _mfaService;
Expand All @@ -39,7 +40,6 @@ 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 Down Expand Up @@ -580,6 +580,46 @@ public async Task WhenConfirmPersonRegistrationAsync_ThenReturnsSuccess()
), It.IsAny<CancellationToken>()));
}

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

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

result.Should().BeError(ErrorCode.EntityNotFound);
}

[Fact]
public async Task WhenGetPasswordCredentialAsync_ThenReturnsCredentials()
{
_caller.Setup(cc => cc.CallerId)
.Returns("auserid");
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.GetPasswordCredentialAsync(_caller.Object, CancellationToken.None);

result.Value.Id.Should().Be("anid");

result.Value.IsMfaEnabled.Should().BeFalse();
_endUsersService.Verify(eus =>
eus.GetUserPrivateAsync(_caller.Object, "auserid",
It.IsAny<CancellationToken>()));
}

private PasswordCredentialRoot CreateUnVerifiedCredential()
{
var credential = CreateCredential();
Expand Down
6 changes: 4 additions & 2 deletions src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,8 @@ public async Task WhenGetTokensAsyncAndNoTokens_ThenReturnsNone()
It.IsAny<CancellationToken>()))
.ReturnsAsync(ssoUser.ToOptional());

var result = await _service.GetTokensAsync(_caller.Object, "auserid".ToId(), CancellationToken.None);
var result =
await _service.GetTokensOnBehalfOfUserAsync(_caller.Object, "auserid".ToId(), CancellationToken.None);

result.Should().BeSuccess();
result.Value.Count.Should().Be(0);
Expand All @@ -367,7 +368,8 @@ public async Task WhenGetTokensAsync_ThenReturnsError()
It.IsAny<CancellationToken>()))
.ReturnsAsync(ssoUser.ToOptional());

var result = await _service.GetTokensAsync(_caller.Object, "auserid".ToId(), CancellationToken.None);
var result =
await _service.GetTokensOnBehalfOfUserAsync(_caller.Object, "auserid".ToId(), CancellationToken.None);

result.Should().BeSuccess();
result.Value.Count.Should().Be(1);
Expand Down
Loading

0 comments on commit f1be55d

Please sign in to comment.