diff --git a/docs/decisions/0180-mfa.md b/docs/decisions/0180-mfa.md
new file mode 100644
index 00000000..a94c2724
--- /dev/null
+++ b/docs/decisions/0180-mfa.md
@@ -0,0 +1,43 @@
+# MFA Design
+
+* status: accepted
+* date: 2024-11-24
+* deciders: jezzsantos
+
+# Context and Problem Statement
+
+MFA is a difficult capability to add to any product. See the [OWASP MFA Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html) for considerations.
+
+There are many options to support, and implementation is risky as there is a high chance to introduce unintended vulnerabilities into the product, or disclose secrets at rest, etc
+
+Implementing MFA well is best left to the experts in general. However, due to the popularity of MFA, and given we have implemented credentials authentication already, we do need to decide how to implement MFA in our product.
+
+Further, we need to consider how to make the transition easy if a consumer of SaaStack were to opt in to replace the
+`Identity` subdomain with a third-party provider (such as: Auth0 or Okta or Identity Server, etc).
+
+## Considered Options
+
+The options are:
+
+1. Emulate Auth0's API, with partial support
+
+2. Adapt a common framework or library
+
+> In any case, follow the implementation guidance at OWASP
+
+## Decision Outcome
+
+`Auth0 Lookalike`
+
+- Limit the number of library/framework dependencies in the platform
+- High confidence in having a secure enough implementation
+- Auth0 API are well documented, even though we don't have a fully compliant )Auth2 Identity subdomain (see: [0100-authentication](0100-authentication.md))
+- Our API endpoints mirror Auth0 endpoints close enough.
+
+## (Optional) More Information
+
+Auth0 API is well documented:
+
+* [API Explorer for MFA](https://auth0.com/docs/api/authentication#multi-factor-authentication)
+* [Custom MFA flows](https://auth0.com/docs/secure/multi-factor-authentication/authenticate-using-ropg-flow-with-mfa/enroll-challenge-sms-voice-authenticators) (for credentials)
+
diff --git a/docs/design-principles/0000-all-use-cases.md b/docs/design-principles/0000-all-use-cases.md
index da93835d..11cc9427 100644
--- a/docs/design-principles/0000-all-use-cases.md
+++ b/docs/design-principles/0000-all-use-cases.md
@@ -16,11 +16,9 @@ Legend:
* OPS denotes a support API that is only accessible to operations team of the platform
-### Cars (Sample)
+### Cars
-> This is sample subdomain, and is expected to be deleted when this product goes to production
-
-1. Register a new car $$$
+1. Register a new car $$
2. Delete a car $$$
3. Schedule the car for "maintenance" $$$
4. Take a car "offline" $$$
@@ -29,9 +27,7 @@ Legend:
7. Find all the cars on the platform $$$
8. Find all the available cars for a specific time frame $$$
-### Bookings (Sample)
-
-> This is sample subdomain, and is expected to be deleted when this product goes to production
+### Bookings
1. Make a booking for a specific car and time frame $$$
2. Cancel an existing booking $$$
@@ -138,7 +134,7 @@ Machines are the way that non-human entities can operate on the platform.
Is the way a user can authenticate with the platform using a username and password.
-1. Authenticate the current user (with a password)
+1. Authenticate the current user (with a password), may include a second factor (i.e. MFA)
2. Register a new person (with a password and with optional invitation)
3. Confirm registration of a person (from email)
4. Initiate a password reset
@@ -147,9 +143,21 @@ Is the way a user can authenticate with the platform using a username and passwo
7. Reset password
8. Fetch the registration confirmation token TSTO
+#### MFA
+
+Is the way you can use one or more second factors for authenticating with the platform for password-protected accounts (above)
+
+1. Enable or disable MFA for the current user
+2. Associate a second factor authenticator for use in authentication (e.g., OOB-SMS, OOB-Email, or TOTP for authenticator apps)
+3. Complete the association to an authenticator, and authenticate
+4. Disassociate a second factor authenticator $$$
+5. List the associated authenticators
+6. Challenge for an associated authenticator
+7. Verify the associated authenticator, and authenticate
+
#### Single-Sign On
-Is the way that a user can authenticate with the platform using an external OAuth2 provider (like Google, Facebook, etc.)
+Is the way that a user can authenticate with the platform using an external OAuth2 provider (like with: Microsoft, Google, Facebook, etc.)
1. Authenticate and (auto-register) a person from another OAuth2 provider (with an optional invitation)
diff --git a/docs/design-principles/0090-authentication-authorization.md b/docs/design-principles/0090-authentication-authorization.md
index 6fb915da..b7dd452b 100644
--- a/docs/design-principles/0090-authentication-authorization.md
+++ b/docs/design-principles/0090-authentication-authorization.md
@@ -49,6 +49,544 @@ When passwords are verified in a login attempt, the authentication process intro
Since password credentials include email addresses, these emails addresses, and passwords require resetting, it is important to confirm that the email address is accessible by a human end-user to manage future communications.
+### Multi-Factor Authentication
+
+MFA or 2FA (Two Factor Auth) is only applicable to credential based authentication systems (e.g., those that manage usernames and passwords).
+
+> It does not apply to SSO, HMAC or APIKey authentication mechanisms.
+
+The main idea behind 2FA is to introduce a second "factor" to identify a specific person, since a human having only "Something You Know" like a password (or pin number), is easily compromised (i.e., with social engineering attacks, or poor password management behaviors).
+
+Having a second "factor" introduces some certainty of one of the following: "Something You Have", "Something You Are", "Somewhere You Are", or "Something You Do".
+
+> See this [OWASP cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html) for more information.
+
+There is now good support for 2FA, using the following built-in second factors:
+
+* TOTP Authenticator App - You typically scan a bar code form a website, and use an authenticator app on your mobile device that generates an one-time, time-based secret code (lasting only 30 seconds) and you copy and paste it into the website.
+* OOB SMS messages - Your mobile phone is sent a SMS text message containing a 6-digit code that you enter into the website.
+* OOB Email messages - You are sent an email message containing a 6-digit code that you enter into the website.
+
+> These second factors are backed up by 16 recovery codes, should the user lose access to their mobile device, access to their authenticator app or access to their email inbox.
+
+#### How it works
+
+MFA can be enforced, by default, for all new users of the product, or it can be allowed/disallowed to be turned on/off by individual users.
+
+> See the `MfaOptions` in the `PasswordCredentialRoot` for these default settings.
+
+When MFA is enabled, users will fail to complete authenticate with just their username + password. instead, they will receive a
+`HTTP 403 - Forbidden` error containing the message
+`mfa_required`. This response means that the authentication has partially succeeded, but a second factor is required to complete the authentication. The error response will contain a value for a:
+`MfaToken` that will need to be used to authenticate the user with their chosen second factor, in subsequent API calls.
+
+Whether the user has already set up a second factor or not, they will receive this error, and it at this point that they can either set up a second factor, or be challenged by the second factor they already set up in a previous authentication attempt.
+
+The response will look like this:
+
+```
+POST /passwords/auth
+
+HTTP 403
+{
+ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.4",
+ "title": "mfa_required",
+ "status": 403,
+ "detail": "Authentication requires another factor\"",
+ "MfaToken": "oZJUUJxElejxHESgiGryfP7YlqUmdMSH3jyKr6804R0"
+}
+```
+
+At this point, the client (i.e., web app) will need to request for the available second factors to challenge.
+
+It will issue this request, and receive a list of "authenticators" to challenge:
+
+```
+GET /passwords/mfa/authenticators
+
+HTTP 200
+{
+ "authenticators": [
+ {
+ "isActive": true,
+ "type": "recoveryCodes",
+ "id": "mfaauth_NWa2wP0iXUi3bcbOcfJog"
+ },
+ {
+ "isActive": true,
+ "type": "totpAuthenticator",
+ "id": "mfaauth_bkmOn7kPX0ifxWoMxZrSiA"
+ },
+ {
+ "isActive": false,
+ "type": "oobSms",
+ "id": "mfaauth_3J8f0guUGj9bmNXKzDg"
+ },
+ {
+ "isActive": true,
+ "type": "oobEmail",
+ "id": "mfaauth_wgg5K3E4H0i82MBKfI1kMQ"
+ }
+ ]
+}
+```
+
+If this list of authenticators above is empty, then a second factor needs to be "associated" now.
+
+If this list of authenticators contains any authenticators that are `IsActive == true` (other than
+`type == recoveryCodes`) then the client needs to give the user the choice to challenge one of those authenticators.
+
+It works likes this:
+
+1. The first time the user is authenticated, after enabling MFA, the user will have 10mins to complete the association of a second factor. If this time elapses, the user will be required to authenticate from the start again (where they need to give their username + password again). An
+ `HTTP 401 - Unauthorized` be be received during the association process.
+ 1. The user can only complete the association of one "authenticator" (i.e., one OTP, OOB authenticator) at this time. By completing the association of the first authenticator, they will be authenticated at that moment. If the user wishes to add more "authenticators", they can do so, once they are authenticated. Typically in their "account settings" user interface.
+ 2. To complete the association of an authenticator it is always a two-step process. The client must first request to "associate" the authenticator, and this will give the user instructions to complete to setup the second factor, and then after that the client must "confirm" the association, with the input from the user (sent over whatever channel, e.g., mobile phone, email or authenticator app).
+ 3. When associating the first "authenticator" the client will receive a set of 16 recovery codes in the first step. The client MUST display those recovery codes to the user, and the user SHOULD be strongly advised to make a permanent note of them for later, should they need them to authenticate if they lose access to their second factor.
+ 4. During the first "associate" step, the client can allow the user to modify their association (e.g., correct an incorrect phone number).
+ 5. Once the "confirm" step is complete, the second factor cannot be modified. The user will be authenticated at this point in time, and can go into their "account settings" user interface to either delete the second factor association or add others.
+2. If this is not the users first time through this authentication process, they will already have at least on authenticator associated at this time (authenticators that have
+ `IsActive == true`).
+ 1. The client can offer the user to choose from the active authenticators, but then MUST challenge that authenticator, and the user must answer the challenge (or select one other active authenticator).
+ 2. The user cannot associate a new authenticator at this point in time (this would be a serious vulnerability).
+ 3. Depending on the user's choices, they will be given the relevant instructions to either associate or challenge one of the follow authenticators.
+
+##### Associating a Second Factor
+
+Associating a second factor, during authentication, is only necessary when the user has not already associated any second factors.
+
+![MFA Associate](../images/Authentication-MFA-Associate.png)
+
+At this point the client will need to offer to the user a choice to "associate" one of the available second factors below:
+
+###### TOTP Authenticator
+
+If the user chooses to use an Authenticator App, then they are choosing the
+`TOTP Authenticator` option, which is a time-based one-time password.
+
+This method uses an Authenticator App (e.g. Microsoft, Google, etc), typically installed on a mobile device.
+
+The client will need to make this request:
+
+```
+POST /passwords/mfa/authenticators
+{
+ "MfaToken": "{{mfa_token}}",
+ "Type": "TotpAuthenticator"
+}
+```
+
+and get a response like this:
+
+```
+HTTP 200
+{
+ "authenticator": {
+ "barCodeUri": "otpauth://totp/SaaStack:auser@company.com?secret=ET77VTFQS4NHGZ1G&issuer=SaaStack&algorithm=SHA1&digits=6&period=30",
+ "recoveryCodes": [
+ "3b54fd93",
+ "f041820d",
+ "92a42776", up to 16 recovery codes
+ ],
+ "secret": "ET77VTFQS4NHGZ1G",
+ "type": "totpAuthenticator"
+ }
+}
+```
+
+At this point, the client will need to render the
+`barCodeUri` in the user interface, and instruct the user to open their Authenticator app on their mobile device, and scan it with their Authenticator App.
+
+The client can also offer to display the
+`secret`, if the user needs to input it manually into their Authenticator app, in the case where the bar code scanner is not working.
+
+Once configured in their Authenticator App, the user will see a revolving 6-digit code (every 30secs), they will need to copy and paste that code back into the user interface and the client will issue this request:
+
+```
+PUT /passwords/mfa/authenticators/TotpAuthenticator/confirm
+{
+ "MfaToken": "{{mfa_token}}",
+ "ConfirmationCode": "123456"
+}
+```
+
+If successful, the response will be:
+
+```
+HTTP 200
+{
+ "tokens": {
+ "accessToken": {
+ "expiresOn": "2024-11-24T00:24:14.7736408Z",
+ "type": "accessToken",
+ "value": "eyJhbGciOiJodHRwOi8vd3d3L..."
+ },
+ "refreshToken": {
+ "expiresOn": "2024-12-08T00:09:14.7736415Z",
+ "type": "refreshToken",
+ "value": "XZ31YG9lY85_Of1_hb3rJhCh0jIVG7WudpqsqIqeuvI"
+ },
+ "userId": "user_jTGSLJFnyUGUJ4aD9AU5iQ"
+ }
+}
+```
+
+###### Out of Band SMS
+
+If the user chooses to use their mobile phone device, then they are choosing the
+`SMS OOB` option, the client will need to make this request:
+
+```
+POST /passwords/mfa/authenticators
+{
+ "MfaToken": "{{mfa_token}}",
+ "Type": "OobSms",
+ "PhoneNumber": "+6498876986"
+}
+```
+
+> Note: the `PhoneNumber` in this request is necessary if the user has not provided a phone number in their
+`UserProfile` already, otherwise if blank, their
+`UserProfile.PhoneNumber` will be used. Unfortunately, there is no way at this point in time for the client to know that in advance.
+
+and get a response like this:
+
+```
+HTTP 200
+{
+ "authenticator": {
+ "oobCode": "vPRoGCB6FEmQ-sXbsIFL5Xzi2B8wl2v7vcuTsR5XqyU",
+ "type": "oobSms"
+ }
+}
+```
+
+Shortly, the user will receive an SMS message that will contain a secret code in the body of the text message, which they will need to copy and paste back into the user interface, and the client will issue this request:
+
+```
+PUT /passwords/mfa/authenticators/OobSms/confirm
+{
+ "MfaToken": "{{mfa_token}}",
+ "OobCode": "{{oob_code}}",
+ "ConfirmationCode": "123456"
+}
+```
+
+If successful, the response will be:
+
+```
+HTTP 200
+{
+ "tokens": {
+ "accessToken": {
+ "expiresOn": "2024-11-24T00:24:14.7736408Z",
+ "type": "accessToken",
+ "value": "eyJhbGciOiJodHRwOi8vd3d3L..."
+ },
+ "refreshToken": {
+ "expiresOn": "2024-12-08T00:09:14.7736415Z",
+ "type": "refreshToken",
+ "value": "XZ31YG9lY85_Of1_hb3rJhCh0jIVG7WudpqsqIqeuvI"
+ },
+ "userId": "user_jTGSLJFnyUGUJ4aD9AU5iQ"
+ }
+}
+```
+
+###### Out of Band Email
+
+If the user chooses to use their email, then they are choosing the
+`Email OOB` option, the client will need to make this request:
+
+```
+POST /passwords/mfa/authenticators
+{
+ "MfaToken": "{{mfa_token}}",
+ "Type": "OobEmail",
+}
+```
+
+> Note: there is no input required for the `EmailAddress` in this request. By default, the users email address in their
+`UserProfile` is used.
+
+and get a response like this:
+
+```
+HTTP 200
+{
+ "authenticator": {
+ "oobCode": "vPRoGCB6FEmQ-sXbsIFL5Xzi2B8wl2v7vcuTsR5XqyU",
+ "type": "oobEmail"
+ }
+}
+```
+
+Shortly, the user will receive an email message that will contain a secret code in the body of the message, which they will need to copy and paste back into the user interface, and the client will issue this request:
+
+```
+PUT /passwords/mfa/authenticators/OobEmail/confirm
+{
+ "MfaToken": "{{mfa_token}}",
+ "OobCode": "{{oob_code}}",
+ "ConfirmationCode": "123456"
+}
+```
+
+If successful, the response will be:
+
+```
+HTTP 200
+{
+
+}
+```
+
+##### Challenging a Second Factor
+
+Challenging a second factor, during authentication, is necessary when the user has already associated one or more second factors.
+
+![MFA Challenge](../images/Authentication-MFA-Challenge.png)
+
+That, is when the call to `GET /passwords/mfa/authenticators` yields one or more authenticators where
+`IsActive == true` (other than `type == recoveryCodes`).
+
+In all cases, a request is issued to challenge the authenticator, like this, given the data in the response to:
+
+```
+GET /passwords/mfa/authenticators
+
+HTTP 200
+{
+ "authenticators": [
+ {
+ "isActive": true,
+ "type": "recoveryCodes",
+ "id": "mfaauth_NWa2wP0iXUi3bcbOcfJog"
+ },
+ {
+ "isActive": true,
+ "type": "totpAuthenticator",
+ "id": "mfaauth_bkmOn7kPX0ifxWoMxZrSiA"
+ },
+ {
+ "isActive": false,
+ "type": "oobSms",
+ "id": "mfaauth_3J8f0guUGj9bmNXKzDg"
+ },
+ {
+ "isActive": true,
+ "type": "oobEmail",
+ "id": "mfaauth_wgg5K3E4H0i82MBKfI1kMQ"
+ }
+ ]
+}
+```
+
+The client makes the challenge for each authenticator like this:
+
+```
+PUT /passwords/mfa/authenticators/{{AuthenticatorId}/challenge
+{
+ "MfaToken": "{{mfa_token}}",
+}
+```
+
+and if successful gets a response based on the authenticator type, like this:
+
+```
+HTTP 202
+{
+ "oobCode": "vPRoGCB6FEmQ-sXbsIFL5Xzi2B8wl2v7vcuTsR5XqyU"
+ "type": "oobEmail"
+}
+```
+
+In the case of the OOB channels, an SMS/Email message is sent to the user containing the secret code.
+
+In the case of OTP Authenticator, the user simplyopens their Authenticator App and copies the secret code from the app.
+
+Now the client needs to "verify" the "challenge" with input from the user, that the user typically harvests from the second factor channel (i.e., SMS text message, Email or Authenticator App).
+
+###### TOTP Authenticator
+
+To verify a TOTP Authenticator authenticator, the client needs to collect from the user the 6-digit code from their Authenticator App (e.g. from Microsoft, Google, etc).
+
+Then send this request:
+
+```
+PUT /passwords/mfa/authenticators/totpAuthenticator/verify
+{
+ "MfaToken": "{{mfa_token}}",
+ "ConfirmationCode": "123456"
+}
+```
+
+If successfully authenticated, the client will receive a response like this:
+
+```
+HTTP 200
+{
+ "tokens": {
+ "accessToken": {
+ "expiresOn": "2024-11-24T00:24:14.7736408Z",
+ "type": "accessToken",
+ "value": "eyJhbGciOiJodHRwOi8vd3d3L..."
+ },
+ "refreshToken": {
+ "expiresOn": "2024-12-08T00:09:14.7736415Z",
+ "type": "refreshToken",
+ "value": "XZ31YG9lY85_Of1_hb3rJhCh0jIVG7WudpqsqIqeuvI"
+ },
+ "userId": "user_jTGSLJFnyUGUJ4aD9AU5iQ"
+ }
+}
+```
+
+###### Out of Band SMS
+
+To verify a OOB SMS authenticator, the client needs to collect from the user the 6-digit code that had just been sent to their mobile phone device.
+
+Then send this request:
+
+```
+PUT /passwords/mfa/authenticators/OobSms/verify
+{
+ "MfaToken": "{{mfa_token}}",
+ "OobCode": "{{oob_code}}",
+ "ConfirmationCode": "123456"
+}
+```
+
+If successfully authenticated, the client will receive a response like this:
+
+```
+HTTP 200
+{
+ "tokens": {
+ "accessToken": {
+ "expiresOn": "2024-11-24T00:24:14.7736408Z",
+ "type": "accessToken",
+ "value": "eyJhbGciOiJodHRwOi8vd3d3L..."
+ },
+ "refreshToken": {
+ "expiresOn": "2024-12-08T00:09:14.7736415Z",
+ "type": "refreshToken",
+ "value": "XZ31YG9lY85_Of1_hb3rJhCh0jIVG7WudpqsqIqeuvI"
+ },
+ "userId": "user_jTGSLJFnyUGUJ4aD9AU5iQ"
+ }
+}
+```
+
+###### Out of Band Email
+
+To verify a OOB Email authenticator, the client needs to collect from the user the 6-digit code that had just been sent to their email inbox.
+
+Then send this request:
+
+```
+PUT /passwords/mfa/authenticators/OobEmail/verify
+{
+ "MfaToken": "{{mfa_token}}",
+ "OobCode": "{{oob_code}}",
+ "ConfirmationCode": "123456"
+}
+```
+
+If successfully authenticated, the client will receive a response like this:
+
+```
+HTTP 200
+{
+ "tokens": {
+ "accessToken": {
+ "expiresOn": "2024-11-24T00:24:14.7736408Z",
+ "type": "accessToken",
+ "value": "eyJhbGciOiJodHRwOi8vd3d3L..."
+ },
+ "refreshToken": {
+ "expiresOn": "2024-12-08T00:09:14.7736415Z",
+ "type": "refreshToken",
+ "value": "XZ31YG9lY85_Of1_hb3rJhCh0jIVG7WudpqsqIqeuvI"
+ },
+ "userId": "user_jTGSLJFnyUGUJ4aD9AU5iQ"
+ }
+}
+```
+
+###### Recovery Codes
+
+To verify the Recovery Code authenticator, the client needs to collect from the user one of the 8-digit recovery codes that had been displayed when associating their first authenticator.
+
+Then send this request:
+
+```
+PUT /passwords/mfa/authenticators/RecoveryCodes/verify
+{
+ "MfaToken": "{{mfa_token}}",
+ "ConfirmationCode": "12345678"
+}
+```
+
+If successfully authenticated, the client will receive a response like this:
+
+```
+HTTP 200
+{
+ "tokens": {
+ "accessToken": {
+ "expiresOn": "2024-11-24T00:24:14.7736408Z",
+ "type": "accessToken",
+ "value": "eyJhbGciOiJodHRwOi8vd3d3L..."
+ },
+ "refreshToken": {
+ "expiresOn": "2024-12-08T00:09:14.7736415Z",
+ "type": "refreshToken",
+ "value": "XZ31YG9lY85_Of1_hb3rJhCh0jIVG7WudpqsqIqeuvI"
+ },
+ "userId": "user_jTGSLJFnyUGUJ4aD9AU5iQ"
+ }
+}
+```
+
+##### 2FA Management
+
+Once successfully signed in, any user may add/remove and change their chosen second factors.
+
+They may choose to set up several second factors. This will mean that the next time they authenticate, they can choose another factor that they can use next (e.g., send me an SMS to my mobile phone, or email me a code to my email).
+
+###### Recovery Codes
+
+Recovery codes are used as a backup for the cases when the second factor is not available to the user.
+
+For example, they lose their mobile phone device, or access to their email inbox.
+
+The client should always allow a user to select the recovery code option to complete an authentication.
+
+Recovery codes are also a OTP (One Time Password) and can only be used once.
+
+There are only ever 16 recovery codes allowed or each user, and they are re-generated when enabling MFA for the user.
+
+There is no way to refresh or renew recovery codes, once issued.
+
+However, there is one workaround that only works if users are permitted to disable MFA for their account.
+
+> This is controlled in `MfaOptions` set in the `PasswordCrdentialRoot` at user creation time.
+
+A user can disable and then re-enable MFA, and when they do this, they will re-generated 16 new recovery codes.
+
+###### Reset MFA
+
+As a last resort, for users, they can of course call the businesses support help desk if they get locked out of their accounts.
+
+There is an API that only operations staff can use in a support context, that can be used to reset a users MFA settings.
+
+``` json
+POST \passwords\mfa\reset
+```
+
+This API call will reset the users account back to the default MFA options that it was created with, removing all MFA configuration, and allowing the user to reset all MFA settings when they next authenticate.
+
### SSO Authentication
We will offer SSO authentication via the `ISingleSignOnApplication`
diff --git a/docs/design-principles/0100-email-delivery.md b/docs/design-principles/0100-email-delivery.md
index c94ebcbf..ad2d8139 100644
--- a/docs/design-principles/0100-email-delivery.md
+++ b/docs/design-principles/0100-email-delivery.md
@@ -2,13 +2,13 @@
## Design Principles
-Many processes in the backend of a SaaS product aim to notify/alert the end user to activities or processes that occur in a SaaS product, that might warrant their attention. Most of these notifications/alerts are ultimately delivered by email (albeit some are delivered by other means, too, i.e., in-app, SMS texts, etc.).
+Many processes in the backend of a SaaS product aim to notify/alert the end user to activities or processes that occur in a SaaS product, that might warrant their attention. Most of these notifications/alerts are ultimately delivered by email/SMS/Push (albeit some are delivered by other means, too, i.e., in-app, etc.).
-Sending emails is often done via 3rd party systems like SendGrid, Mailgun, Postmark, etc., over HTTP.
+Sending emails/SMS is often done via 3rd party systems like SendGrid, Twilio, Mailgun, Postmark, etc., over HTTP.
-> Due to its nature, doing this directly isn't very reliable, especially with systems under load and with rate limits being applied.
+> Due to its nature, doing this directly in a use case, isn't very reliable, nor is it optimal with systems under load, and with 3rd parties applying rate limits. All of these aspects can lead to a poor user experience is the user is waiting for these workloads to complete before they can move on with their work.
-* We need to "broker" between the sending of emails and the delivering of them to make the entire process more reliable, and we need to provide observability when things go wrong.
+* We need to "broker" between the sending of emails/SMS, and the delivering of them, to make the entire process more reliable, and we need to provide observability when things go wrong.
* Since an inbound API request to any API backend can yield of the order ~10 emails per API call, delivering them reliably across HTTP can require minutes of time, if you consider the possibility of retries and back-offs, etc. We simply could not afford to keep API clients blocked and waiting while email delivery takes place, let alone the risk of timing out their connection to the inbound API call in the first place.
* Delivery of some emails is critical to the functioning of the product, and this data cannot be lost. Consider an email to confirm the email of a registered account, for example.
@@ -20,29 +20,37 @@ Thus, we need to take advantage of all these facts and engineer a reliable mecha
## Implementation
-![Email Delivery](../../docs/images/Email-Delivery.png)
+This is how emails and SMS messages are delivered from subdomain, using the Ancillary API:
+
+![Email/SMS Delivery](../../docs/images/Email-Delivery.png)
### Sending notifications
Any API Host (any subdomain) may want to send notifications to users.
-They do this by calling very specific `IUserNotificationsService.NotifyXXXAsync()` methods.
+They do this by calling very specific and custom `IUserNotificationsService.NotifyXXXAsync()` methods.
Injected into the runtime will be an instance of the `IUserNotificationService`, which can then deliver notifications to new and existing users based on communication preferences that those users have set up in the system.
-Without such information present in the system (as is the present case), a simple default implementation of the `IUserNotificationsService` is being used to deliver notifications via email, called the `EmailNotificationService`.
+> At present, this mechanism is pretty rudimentary. It does not abstract the users messaging details much at all. However, it is open to be extended in the future, if you needed to look up the users preferences from another service to decide how to deliver notifications, or if you needed to send notifications to multiple users at once, etc.
+
+Without such information present in the system (as is the present case), a simple default implementation of the
+`IUserNotificationsService` is being used to deliver notifications via email, and via SMS. It is called the:
+`MessageUserNotificationsService`.
+
+> This simple adapter is going to send an email notification to a user based on the email address, and an SMS notification to a user based on their phone number. (future implementations of "notifiers" may behave very differently).
-> This simple adapter is going to send an email notification to a user based on the email address. (future implementations of "notifiers" may behave very differently).
+### Sending emails/SMS
-### Sending emails
+Receiving emails and SMS text messages from a SaaS product often participate in the flows of critical end-user processes, be those just "alert" notifications or instructional "call to action" (CTA) type notifications.
-Receiving emails from a SaaS product often participate in the flows of critical end-user processes, be those just "alert" notifications or instructional "call to action" (CTA) notifications.
+> For example, a user may need to confirm their email address at registration time, or they may need to reset their password, or they may need to confirm a booking, etc.
-These email communications, thus, require reliable delivery in order to ensure the recipient gets the email at some point in time.
+These email/SMS communications, thus, require asynchronous "reliable delivery" in order to improve the chances of the recipient getting the email at some point in time in the future.
-Typically, emails will be actually delivered by 3rd party online services (e.g., SendGrid, Mailgun, or Postmark), and those systems can employ their own management and rules for delivering emails.
+Typically, emails/SMS will be actually delivered by 3rd party online services (e.g., SendGrid, Twilio, Mailgun, or Postmark), and those systems can employ their own management and rules for delivering these messages.
-> For example, they may support rate limits, daily/monthly quotas, and email templates, and may even support blocked-email lists, and things like that that prevent emails from being received by recipients.
+> For example, they may support rate limits, daily/monthly quotas, and email templates, and may even support blocked-email lists, and things like that to prevent emails from being received by recipients.
For most businesses using these services, operationally, they will need to manage those systems directly with whatever tools are available.
@@ -50,15 +58,19 @@ On the automation side, these 3rd party services may support API integration, an
#### Reliable delivery
-The injected implementation of the `EmailNotificationsService` hands off the scheduling of the delivery to an implementation of the `IEmailSchedulingService`. This service packages up the scheduled email and enqueues it to the `emails` queue, and the thread that sent the notification is returned immediately.
+The injected implementation of the
+`MessageUserNotificationsService` hands off the scheduling of the delivery to an implementation of the
+`IEmailSchedulingService`. This service packages up the scheduled email and enqueues it to the
+`emails` queue, and the thread that sent the notification is returned immediately.
-Delivery of the actual email to the 3rd party service is not performed at this point, and the request thread is not blocked waiting for that to occur.
+Delivery of the actual email to the 3rd party service is not performed at this point, and the original request thread is not blocked waiting for that process to occur. This is a key design principle, since the client probably cannot do much in the case when the email/SMS fails to be delivered the first time, and the client certainly does not want to be kept waiting for a response back from the 3rd party email/SMS delivery service. These are typically notifications, not critical synchronous responses.
-A scheduled email message goes onto the `emails` FIFO queue, where a cloud-based trigger (i.e., an Azure Function or AWS Lambda) picks up the message and calls back the Ancillary API to "send" the message using the configured Email Provider (i.e., SendGrid, Mailgun, Postmark, etc).
+A scheduled email message goes onto the `emails` FIFO queue (an SMS goes into the
+`smses` queue), where a cloud-based trigger (i.e., an Azure Function or AWS Lambda) picks up the message and calls back the Ancillary API to "send" the message using the configured Email Provider (i.e., SendGrid, Twilio, Mailgun, Postmark, etc.).
This cloud-based mechanism is designed with queues to be reliable in several ways.
-1. The queues are always FIFO
+1. The queues are always FIFO.
2. When a message is "dequeued" (processed) by a "client" (a piece of code like an Azure Function or a Lambda), the message is not removed/deleted from the queue immediately, but it becomes "invisible" to further processing by either the same client or another client. However, this message is only "invisible" for a [configurable] period of time (by default: 30 seconds).
3. The message is only "deleted" from the queue when the client explicitly instructs the queue to delete the message after successfully processing it. Failing to explicitly delete the message from the queue (by the client) returns the message to the queue (making it "visible" again) after the visibility timeout has expired.
4. Also, any exception raised by the client while processing the message will automatically return the message to the queue, making it "visible" to be consumed by another client again.
diff --git a/docs/images/Authentication-MFA-Associate.png b/docs/images/Authentication-MFA-Associate.png
new file mode 100644
index 00000000..afde7a48
Binary files /dev/null and b/docs/images/Authentication-MFA-Associate.png differ
diff --git a/docs/images/Authentication-MFA-Challenge.png b/docs/images/Authentication-MFA-Challenge.png
new file mode 100644
index 00000000..afbd3205
Binary files /dev/null and b/docs/images/Authentication-MFA-Challenge.png differ
diff --git a/docs/images/Multitenancy.png b/docs/images/Multitenancy.png
index 58bd251a..55158140 100644
Binary files a/docs/images/Multitenancy.png and b/docs/images/Multitenancy.png differ
diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx
index e6e145b8..001376ae 100644
Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ
diff --git a/iac/AzureSQLServer-Eventing-Seed.sql b/iac/AzureSQLServer-Eventing-Seed.sql
deleted file mode 100644
index 6f9bf0d4..00000000
--- a/iac/AzureSQLServer-Eventing-Seed.sql
+++ /dev/null
@@ -1,58 +0,0 @@
--- To be used to use your SQL database as the EventStore for all event-sourcing aggregates.
---
--- Note: Column Definitions:
--- We are deliberately defining most textual columns (in most of the read-model tables) as "string nvarchar(max)" by default
--- this is to allow for future expansion of the content of the column as you code evolves, without having to change the sizes of the columns.
--- you are free to modify that default to some nominal value (across the board) as you wish (i.e. "nvarchar(800)").
--- columns with indexes cannot be "nvarchar(max)" because of the index size limit of 900 bytes.
--- We are only specifically using other datatypes, where we know they are very un-likely to change over time.
--- if you want to be more specific about data types and want to optimize column design early, you will need to be very careful not to change the code in the future.
--- we recommend optimizing for change, rather than optimizing for performance, until your product has matured and fully developed, or has scaled dramatically.
--- most read-model columns will change from string to JSON(ValueObject) as things change in your domain models, so limiting them too early to specific datatypes can backfire later on in production workloads.
--- We are deliberately defining most columns as NULL for the same reason. To avoid, as much as possible, having to make changes to the schema in the future, when the code changes.
--- clearly there are limits to this strategy, so this strategy is simply minimizing them, since we don't care at this stage about optimizing SQL storage in the cloud (i.e. no longer depend on spinning hard disks, tracks and sectors).
---
--- noinspection SqlDialectInspectionForFile
-
-USE
- [SaaStack]
-GO
-
-IF EXISTS(SELECT *
- FROM sys.objects
- WHERE object_id = OBJECT_ID(N'[dbo].[EventStore]')
- AND type in (N'U'))
- DROP TABLE [dbo].[EventStore]
-GO
-
-SET ANSI_NULLS ON
-GO
-
-SET QUOTED_IDENTIFIER ON
-GO
-
-CREATE TABLE [dbo].[EventStore]
-(
- [Id] [nvarchar](100) NOT NULL,
- [LastPersistedAtUtc] [datetime] NULL,
- [IsDeleted] [bit] NULL,
- [Data] [nvarchar](max) NULL,
- [EntityName] [nvarchar](max) NULL,
- [EntityType] [nvarchar](max) NULL,
- [EventType] [nvarchar](max) NULL,
- [Metadata] [nvarchar](max) NULL,
- [StreamName] [nvarchar](450) NULL,
- [Version] [bigint] NULL,
-) ON [PRIMARY]
-GO
-
-CREATE INDEX Id
- ON [dbo].[EventStore]
- (
- [Id]
- );
-CREATE INDEX StreamName
- ON [dbo].[EventStore]
- (
- [StreamName]
- );
\ No newline at end of file
diff --git a/iac/AzureSQLServer-Seed-Eventing-Generic.sql b/iac/AzureSQLServer-Seed-Eventing-Generic.sql
index 162b5e64..3c920849 100644
--- a/iac/AzureSQLServer-Seed-Eventing-Generic.sql
+++ b/iac/AzureSQLServer-Seed-Eventing-Generic.sql
@@ -86,6 +86,13 @@ IF EXISTS(SELECT *
DROP TABLE [dbo].[Membership]
GO
+IF EXISTS(SELECT *
+ FROM sys.objects
+ WHERE object_id = OBJECT_ID(N'[dbo].[MfaAuthenticator]')
+ AND type in (N'U'))
+ DROP TABLE [dbo].[MfaAuthenticator]
+GO
+
IF EXISTS(SELECT *
FROM sys.objects
WHERE object_id = OBJECT_ID(N'[dbo].[Organization]')
@@ -387,6 +394,30 @@ CREATE INDEX Id
[Id]
);
+CREATE TABLE [dbo].[MfaAuthenticator]
+(
+ [Id] [nvarchar](100) NOT NULL,
+ [LastPersistedAtUtc] [datetime] NULL,
+ [IsDeleted] [bit] NULL,
+ [BarCodeUri] [nvarchar](max) NULL,
+ [VerifiedState] [nvarchar](max) NULL,
+ [IsActive] [bit] NULL,
+ [State] [nvarchar](max) NULL,
+ [OobChannelValue] [nvarchar](max) NULL,
+ [OobCode] [nvarchar](max) NULL,
+ [PasswordCredentialId] [nvarchar](100) NULL,
+ [Secret] [nvarchar](max) NULL,
+ [Type] [nvarchar](max) NULL,
+ [UserId] [nvarchar](100) NULL,
+) ON [PRIMARY]
+GO
+
+CREATE INDEX Id
+ ON [dbo].[MfaAuthenticator]
+ (
+ [Id]
+ );
+
CREATE TABLE [dbo].[Organization]
(
[Id] [nvarchar](100) NOT NULL,
@@ -419,6 +450,10 @@ CREATE TABLE [dbo].[PasswordCredential]
[LastPersistedAtUtc] [datetime] NULL,
[IsDeleted] [bit] NULL,
[AccountLocked] [bit] NULL,
+ [IsMfaEnabled] [bit] NULL,
+ [MfaAuthenticationExpiresAt] [datetime] NULL,
+ [MfaAuthenticationToken] [nvarchar](max) NULL,
+ [MfaCanBeDisabled] [bit] NULL,
[PasswordResetToken] [nvarchar](450) NULL,
[RegistrationVerificationToken] [nvarchar](max) NULL,
[RegistrationVerified] [bit] NULL,
@@ -448,6 +483,11 @@ CREATE INDEX UserName
(
[UserName]
);
+CREATE INDEX MfaAuthenticationToken
+ ON [dbo].[PasswordCredential]
+ (
+ [MfaAuthenticationToken]
+ );
CREATE TABLE [dbo].[ProjectionCheckpoints]
(
diff --git a/iac/AzureSQLServer-Snapshotting-Seed.sql b/iac/AzureSQLServer-Snapshotting-Seed.sql
deleted file mode 100644
index d0a959cc..00000000
--- a/iac/AzureSQLServer-Snapshotting-Seed.sql
+++ /dev/null
@@ -1,64 +0,0 @@
--- To be used to use your SQL database to define the storage for all snapshotting aggregates.
--- To be used to keep your SQL database up to date as you change your platform, and subdomains evolve.
---
--- Note: Normalization:
--- We deliberately do NOT define any referential integrity, or associated structure, in this database, because that violates the architectural rules.
--- The tables pertaining to each subdomain should always remain independent of each other
--- Individual subdomains could be split and deployed into separate databases at any time
--- you are not to be joining across subdomains
--- you are not to be creating joins across read-models, you can just write the full de-normalized table from your projection
--- you can write multiple read-models from multiple projections (if you need different representations)
--- Note: Column Definitions:
--- We are deliberately defining most textual columns (in most of the read-model tables) as "string nvarchar(max)" by default
--- this is to allow for future expansion of the content of the column as you code evolves, without having to change the sizes of the columns.
--- you are free to modify that default to some nominal value (across the board) as you wish (i.e. "nvarchar(800)").
--- columns with indexes cannot be "nvarchar(max)" because of the index size limit of 900 bytes.
--- We are only specifically using other datatypes, where we know they are very un-likely to change over time.
--- if you want to be more specific about data types and want to optimize column design early, you will need to be very careful not to change the code in the future.
--- we recommend optimizing for change, rather than optimizing for performance, until your product has matured and fully developed, or has scaled dramatically.
--- most read-model columns will change from string to JSON(ValueObject) as things change in your domain models, so limiting them too early to specific datatypes can backfire later on in production workloads.
--- We are deliberately defining most columns as NULL for the same reason. To avoid, as much as possible, having to make changes to the schema in the future, when the code changes.
--- clearly there are limits to this strategy, so this strategy is simply minimizing them, since we don't care at this stage about optimizing SQL storage in the cloud (i.e. no longer depend on spinning hard disks, tracks and sectors).
---
--- noinspection SqlDialectInspectionForFile
-
-USE
- [SaaStack]
-GO
-
-IF EXISTS(SELECT *
- FROM sys.objects
- WHERE object_id = OBJECT_ID(N'[dbo].[Booking]')
- AND type in (N'U'))
- DROP TABLE [dbo].[Booking]
-GO
-
-SET ANSI_NULLS ON
-GO
-
-SET QUOTED_IDENTIFIER ON
-GO
-
-CREATE TABLE [dbo].[Booking]
-(
- [Id] [nvarchar](100) NOT NULL,
- [LastPersistedAtUtc] [datetime] NULL,
- [IsDeleted] [bit] NULL,
- [BorrowerId] [nvarchar](100) NULL,
- [CarId] [nvarchar](100) NULL,
- [End] [datetime] NULL,
- [OrganizationId] [nvarchar](100) NULL,
- [Start] [datetime] NULL,
-) ON [PRIMARY]
-GO
-
-CREATE INDEX Id
- ON [dbo].[Booking]
- (
- [Id]
- );
-CREATE INDEX OrganizationId
- ON [dbo].[Booking]
- (
- [OrganizationId]
- );
\ No newline at end of file
diff --git a/src/.idea/.idea.SaaStack/.idea/prettier.xml b/src/.idea/.idea.SaaStack/.idea/prettier.xml
index 0c83ac4e..b0c1c68f 100644
--- a/src/.idea/.idea.SaaStack/.idea/prettier.xml
+++ b/src/.idea/.idea.SaaStack/.idea/prettier.xml
@@ -2,6 +2,5 @@
-
\ No newline at end of file
diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json
index 71a30d1b..f93fcb32 100644
--- a/src/ApiHost1/appsettings.json
+++ b/src/ApiHost1/appsettings.json
@@ -11,6 +11,9 @@
"DomainServices": {
"TenantSettingService": {
"AesSecret": "V7z5SZnhHRa7z68adsvazQjeIbSiWWcR+4KuAUikhe0=::u4ErEVotb170bM8qKWyT8A=="
+ },
+ "MfaService": {
+ "IssuerName": "SaaStack"
}
},
"ApplicationServices": {
diff --git a/src/Application.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs
index 46180d00..4cfbd499 100644
--- a/src/Application.Interfaces/Audits.Designer.cs
+++ b/src/Application.Interfaces/Audits.Designer.cs
@@ -248,6 +248,33 @@ public static string PasswordCredentialsApplication_Authenticate_Succeeded {
}
}
+ ///
+ /// Looks up a localized string similar to Authentication.Password.Mfa.Failed.InvalidMfa.
+ ///
+ public static string PasswordCredentialsApplication_MfaAuthenticate_Failed {
+ get {
+ return ResourceManager.GetString("PasswordCredentialsApplication_MfaAuthenticate_Failed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Authentication.Password.Mfa.Passed.
+ ///
+ public static string PasswordCredentialsApplication_MfaAuthenticate_Succeeded {
+ get {
+ return ResourceManager.GetString("PasswordCredentialsApplication_MfaAuthenticate_Succeeded", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Authentication.Password.Mfa.Reset.
+ ///
+ public static string PasswordCredentialsApplication_MfaReset {
+ get {
+ return ResourceManager.GetString("PasswordCredentialsApplication_MfaReset", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to SingleSignOn.AutoRegistered.
///
diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx
index 9eb35d13..4207006c 100644
--- a/src/Application.Interfaces/Audits.resx
+++ b/src/Application.Interfaces/Audits.resx
@@ -39,6 +39,15 @@
Authentication.Password.Passed
+
+ Authentication.Password.Mfa.Passed
+
+
+ Authentication.Password.Mfa.Failed.InvalidMfa
+
+
+ Authentication.Password.Mfa.Reset
+
EndUser.Registered.TermsAccepted
diff --git a/src/Application.Interfaces/UsageConstants.cs b/src/Application.Interfaces/UsageConstants.cs
index e69a02f2..49167b3f 100644
--- a/src/Application.Interfaces/UsageConstants.cs
+++ b/src/Application.Interfaces/UsageConstants.cs
@@ -26,6 +26,7 @@ public static class Properties
public const string DefaultOrganizationId = "DefaultOrganizationId";
public const string Duration = "Duration";
public const string EmailAddress = "EmailAddress";
+ public const string Enabled = "Enabled";
public const string EndPoint = "EndPoint";
public const string HttpMethod = "Method";
public const string HttpPath = "Path";
@@ -34,6 +35,7 @@ public static class Properties
public const string Id = "ResourceId";
public const string IpAddress = "IpAddress";
public const string MetricEventName = "Metric";
+ public const string MfaAuthenticatorType = "MfaAuthenticatorType";
public const string Name = "Name";
public const string Ownership = "Ownership";
public const string Path = "Path";
@@ -77,6 +79,11 @@ public static class Generic
public const string UserLogin = "User Login";
public const string UserLogout = "User Logout";
public const string UserPasswordForgotten = "User Password Forgotten";
+ public const string UserPasswordMfaAuthenticated = "User 2FA Login";
+ public const string UserPasswordMfaAssociationStarted = "User MFA Association Started";
+ public const string UserPasswordMfaChallenge = "User MFA Challenge";
+ public const string UserPasswordMfaDisassociated = "User MFA Disassociated";
+ public const string UserPasswordMfaEnabled = "User MFA Enabled";
public const string UserPasswordReset = "User Password Reset";
public const string UserProfileChanged = "User Profile Updated";
}
diff --git a/src/Application.Resources.Shared/PasswordCredential.cs b/src/Application.Resources.Shared/PasswordCredential.cs
index 53cf7a20..c7b76cbb 100644
--- a/src/Application.Resources.Shared/PasswordCredential.cs
+++ b/src/Application.Resources.Shared/PasswordCredential.cs
@@ -4,6 +4,8 @@ namespace Application.Resources.Shared;
public class PasswordCredential : IIdentifiableResource
{
+ public bool IsMfaEnabled { get; set; }
+
public required EndUser User { get; set; }
public required string Id { get; set; }
@@ -14,4 +16,49 @@ public class PasswordCredentialConfirmation
public required string Token { get; set; }
public required string Url { get; set; }
+}
+
+public enum PasswordCredentialMfaAuthenticatorType
+{
+ None = 0,
+ RecoveryCodes = 1, // Recovery codes
+ OobSms = 2, // Code is sent "Out of Band" in an SMS message
+ OobEmail = 3, // Code is sent "Out of Band" in an email message
+ TotpAuthenticator = 4 // "Time-based One Time Password" is generated by a supported authenticator App
+}
+
+public class PasswordCredentialMfaAuthenticator : IIdentifiableResource
+{
+ public bool IsActive { get; set; }
+
+ public required PasswordCredentialMfaAuthenticatorType Type { get; set; }
+
+ public required string Id { get; set; }
+}
+
+public class PasswordCredentialMfaAssociation
+{
+ public string? BarCodeUri { get; set; }
+
+ public string? OobCode { get; set; }
+
+ public List? RecoveryCodes { get; set; }
+
+ public string? Secret { get; set; }
+
+ public required PasswordCredentialMfaAuthenticatorType Type { get; set; }
+}
+
+public class PasswordCredentialMfaChallenge
+{
+ public string? OobCode { get; set; }
+
+ public PasswordCredentialMfaAuthenticatorType Type { get; set; }
+}
+
+public class PasswordCredentialMfaConfirmation
+{
+ public AuthenticateTokens? Tokens { get; set; }
+
+ public List? Authenticators { get; set; }
}
\ No newline at end of file
diff --git a/src/Application.Services.Shared/IWebsiteUiService.cs b/src/Application.Services.Shared/IWebsiteUiService.cs
index 97c62fe1..09b0adb1 100644
--- a/src/Application.Services.Shared/IWebsiteUiService.cs
+++ b/src/Application.Services.Shared/IWebsiteUiService.cs
@@ -5,7 +5,7 @@ namespace Application.Services.Shared;
///
public interface IWebsiteUiService
{
- string ConstructPasswordMfaOobCompletionPageUrl(string code);
+ string ConstructPasswordMfaOobConfirmationPageUrl(string code);
string ConstructPasswordRegistrationConfirmationPageUrl(string token);
diff --git a/src/CarsDomain/CarRoot.cs b/src/CarsDomain/CarRoot.cs
index f66c9af1..6cb53ffa 100644
--- a/src/CarsDomain/CarRoot.cs
+++ b/src/CarsDomain/CarRoot.cs
@@ -168,7 +168,7 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco
{
Unavailabilities.Remove(deleted.UnavailabilityId.ToId());
Recorder.TraceDebug(null, "Car {Id} has had unavailability {UnavailabilityId} removed", Id,
- deleted.RootId);
+ deleted.UnavailabilityId);
return Result.Ok;
}
diff --git a/src/Common/Extensions/ObjectExtensions.cs b/src/Common/Extensions/ObjectExtensions.cs
index 3db527b4..540a0f40 100644
--- a/src/Common/Extensions/ObjectExtensions.cs
+++ b/src/Common/Extensions/ObjectExtensions.cs
@@ -1,6 +1,7 @@
#if COMMON_PROJECT
using AutoMapper;
#endif
+using System.Diagnostics;
#if COMMON_PROJECT || GENERATORS_WEB_API_PROJECT || ANALYZERS_NONPLATFORM || ANALYZERS_PLATFORM
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
@@ -8,6 +9,7 @@
namespace Common.Extensions;
+[DebuggerStepThrough]
public static class ObjectExtensions
{
#if COMMON_PROJECT
diff --git a/src/Common/Optional.cs b/src/Common/Optional.cs
index 93ee8734..426d901d 100644
--- a/src/Common/Optional.cs
+++ b/src/Common/Optional.cs
@@ -7,6 +7,7 @@ namespace Common;
///
/// Provides shortcuts for
///
+[DebuggerStepThrough]
public static class Optional
{
///
@@ -189,6 +190,7 @@ private static object ChangeOptionalType(object? value, Type targetType)
/// Provides an optional type that combines a and a which indicates
/// whether or not the is meaningful.
///
+[DebuggerStepThrough]
public readonly struct Optional : IEquatable>
{
public const string NoValueStringValue = nameof(None);
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/Created.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/Created.cs
index f89a6ae9..07f78592 100644
--- a/src/Domain.Events.Shared/Identities/PasswordCredentials/Created.cs
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/Created.cs
@@ -15,5 +15,9 @@ public Created()
{
}
+ public required bool IsMfaEnabled { get; set; }
+
+ public required bool MfaCanBeDisabled { get; set; }
+
public required string UserId { get; set; }
}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticationInitiated.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticationInitiated.cs
new file mode 100644
index 00000000..5d4f00a0
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticationInitiated.cs
@@ -0,0 +1,23 @@
+using Domain.Common;
+using Domain.Common.ValueObjects;
+using JetBrains.Annotations;
+
+namespace Domain.Events.Shared.Identities.PasswordCredentials;
+
+public sealed class MfaAuthenticationInitiated : DomainEvent
+{
+ public MfaAuthenticationInitiated(Identifier id) : base(id)
+ {
+ }
+
+ [UsedImplicitly]
+ public MfaAuthenticationInitiated()
+ {
+ }
+
+ public required DateTime AuthenticationExpiresAt { get; set; }
+
+ public required string AuthenticationToken { get; set; }
+
+ public required string UserId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorAdded.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorAdded.cs
new file mode 100644
index 00000000..9610bf37
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorAdded.cs
@@ -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 MfaAuthenticatorAdded : DomainEvent
+{
+ public MfaAuthenticatorAdded(Identifier id) : base(id)
+ {
+ }
+
+ [UsedImplicitly]
+ public MfaAuthenticatorAdded()
+ {
+ }
+
+ public string? AuthenticatorId { get; set; }
+
+ public required bool IsActive { get; set; }
+
+ public required MfaAuthenticatorType Type { get; set; }
+
+ public required string UserId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorAssociated.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorAssociated.cs
new file mode 100644
index 00000000..5b2ea6ce
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorAssociated.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorChallenged.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorChallenged.cs
new file mode 100644
index 00000000..20cd5c7f
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorChallenged.cs
@@ -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 MfaAuthenticatorChallenged : DomainEvent
+{
+ public MfaAuthenticatorChallenged(Identifier id) : base(id)
+ {
+ }
+
+ [UsedImplicitly]
+ public MfaAuthenticatorChallenged()
+ {
+ }
+
+ 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; }
+}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorConfirmed.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorConfirmed.cs
new file mode 100644
index 00000000..1e03dbbf
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorConfirmed.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorRemoved.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorRemoved.cs
new file mode 100644
index 00000000..c123d8f1
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorRemoved.cs
@@ -0,0 +1,24 @@
+using Domain.Common;
+using Domain.Common.ValueObjects;
+using Domain.Shared.Identities;
+using JetBrains.Annotations;
+
+namespace Domain.Events.Shared.Identities.PasswordCredentials;
+
+public sealed class MfaAuthenticatorRemoved : DomainEvent
+{
+ public MfaAuthenticatorRemoved(Identifier id) : base(id)
+ {
+ }
+
+ [UsedImplicitly]
+ public MfaAuthenticatorRemoved()
+ {
+ }
+
+ public required string AuthenticatorId { get; set; }
+
+ public required MfaAuthenticatorType Type { get; set; }
+
+ public required string UserId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorVerified.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorVerified.cs
new file mode 100644
index 00000000..41fcc90a
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaAuthenticatorVerified.cs
@@ -0,0 +1,30 @@
+using Domain.Common;
+using Domain.Common.ValueObjects;
+using Domain.Shared.Identities;
+using JetBrains.Annotations;
+
+namespace Domain.Events.Shared.Identities.PasswordCredentials;
+
+public sealed class MfaAuthenticatorVerified : DomainEvent
+{
+ public MfaAuthenticatorVerified(Identifier id) : base(id)
+ {
+ }
+
+ [UsedImplicitly]
+ public MfaAuthenticatorVerified()
+ {
+ }
+
+ public required string AuthenticatorId { get; set; }
+
+ public string? ConfirmationCode { get; set; }
+
+ public string? VerifiedState { get; set; }
+
+ public string? OobCode { get; set; }
+
+ public required MfaAuthenticatorType Type { get; set; }
+
+ public required string UserId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaOptionsChanged.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaOptionsChanged.cs
new file mode 100644
index 00000000..ac5863c7
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaOptionsChanged.cs
@@ -0,0 +1,23 @@
+using Domain.Common;
+using Domain.Common.ValueObjects;
+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 CanBeDisabled { get; set; }
+
+ public required bool IsEnabled { get; set; }
+
+ public required string UserId { get; set; }
+}
\ No newline at end of file
diff --git a/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaStateReset.cs b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaStateReset.cs
new file mode 100644
index 00000000..82cdd28a
--- /dev/null
+++ b/src/Domain.Events.Shared/Identities/PasswordCredentials/MfaStateReset.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Domain.Services.Shared/ITokensService.cs b/src/Domain.Services.Shared/ITokensService.cs
index 3a54303e..9eced8d0 100644
--- a/src/Domain.Services.Shared/ITokensService.cs
+++ b/src/Domain.Services.Shared/ITokensService.cs
@@ -11,9 +11,13 @@ public interface ITokensService
string CreateJWTRefreshToken();
+ string CreateMfaAuthenticationToken();
+
string CreatePasswordResetToken();
string CreateRegistrationVerificationToken();
+ string GenerateRandomToken();
+
Optional ParseApiKey(string apiKey);
}
\ No newline at end of file
diff --git a/src/Domain.Shared/Identities/MfaAuthenticatorType.cs b/src/Domain.Shared/Identities/MfaAuthenticatorType.cs
new file mode 100644
index 00000000..3cbb11dc
--- /dev/null
+++ b/src/Domain.Shared/Identities/MfaAuthenticatorType.cs
@@ -0,0 +1,10 @@
+namespace Domain.Shared.Identities;
+
+public enum MfaAuthenticatorType
+{
+ None = 0,
+ RecoveryCodes = 1, // Recovery codes
+ OobSms = 2, // Code is sent "Out of Band" in an SMS message
+ OobEmail = 3, // Code is sent "Out of Band" in an email message
+ TotpAuthenticator = 4 // "Time-based One Time Password" is generated by a supported authenticator App
+}
\ No newline at end of file
diff --git a/src/EndUsersDomain/Resources.Designer.cs b/src/EndUsersDomain/Resources.Designer.cs
index 055bc847..f220cc08 100644
--- a/src/EndUsersDomain/Resources.Designer.cs
+++ b/src/EndUsersDomain/Resources.Designer.cs
@@ -195,7 +195,7 @@ internal static string EndUserRoot_NoMembership {
}
///
- /// Looks up a localized string similar to The assigner is not a member of the operations team.
+ /// Looks up a localized string similar to This user is not a member of the operations team.
///
internal static string EndUserRoot_NotOperator {
get {
diff --git a/src/EndUsersDomain/Resources.resx b/src/EndUsersDomain/Resources.resx
index beb7fa22..fd0adac7 100644
--- a/src/EndUsersDomain/Resources.resx
+++ b/src/EndUsersDomain/Resources.resx
@@ -88,7 +88,7 @@
A membership must always have at least the feature '{0}'
- The assigner is not a member of the operations team
+ This user is not a member of the operations teamThis invitation has not been sent yet
diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplication.MfaSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplication.MfaSpec.cs
index db92f182..2b53832c 100644
--- a/src/IdentityApplication.UnitTests/PasswordCredentialsApplication.MfaSpec.cs
+++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplication.MfaSpec.cs
@@ -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;
@@ -27,8 +28,10 @@ namespace IdentityApplication.UnitTests;
public class PasswordCredentialsApplicationMfaSpec
{
private readonly PasswordCredentialsApplication _application;
+ private readonly Mock _authTokensService;
private readonly Mock _caller;
private readonly Mock _emailAddressService;
+ private readonly Mock _encryptionService;
private readonly Mock _endUsersService;
private readonly Mock _idFactory;
private readonly Mock _mfaService;
@@ -95,6 +98,11 @@ public PasswordCredentialsApplicationMfaSpec()
.Returns("averificationtoken");
_tokensService.Setup(ts => ts.CreateMfaAuthenticationToken())
.Returns("anmfatoken");
+ _encryptionService = new Mock();
+ _encryptionService.Setup(es => es.Encrypt(It.IsAny()))
+ .Returns((string value) => value);
+ _encryptionService.Setup(es => es.Decrypt(It.IsAny()))
+ .Returns((string value) => value);
_passwordHasherService = new Mock();
_passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny()))
.Returns(true);
@@ -105,9 +113,19 @@ public PasswordCredentialsApplicationMfaSpec()
_passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny()))
.Returns(true);
_mfaService = new Mock();
- _mfaService.Setup(ts => ts.GenerateOobCode())
+ _mfaService.Setup(ms => ms.GenerateOobCode())
.Returns("anoobcode");
- var authTokensService = new Mock();
+ _mfaService.Setup(ms => ms.GenerateOobSecret())
+ .Returns("anoobsecret");
+ _mfaService.Setup(ms => ms.GenerateOtpSecret())
+ .Returns("anotpsecret");
+ _mfaService.Setup(ms => ms.GenerateOtpBarcodeUri(It.IsAny(), It.IsAny()))
+ .Returns("abarcodeuri");
+ _mfaService.Setup(ms => ms.VerifyTotp(It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Returns(TotpResult.Valid(1));
+ _mfaService.Setup(ms => ms.GetTotpMaxTimeSteps())
+ .Returns(3);
+ _authTokensService = new Mock();
var websiteUiService = new Mock();
_repository = new Mock();
_repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny()))
@@ -121,13 +139,12 @@ public PasswordCredentialsApplicationMfaSpec()
_application = new PasswordCredentialsApplication(_recorder.Object, _idFactory.Object, _endUsersService.Object,
_userProfilesService.Object, _userNotificationsService.Object, _settings.Object,
_emailAddressService.Object,
- _tokensService.Object, _passwordHasherService.Object, _mfaService.Object, authTokensService.Object,
- websiteUiService.Object,
- _repository.Object);
+ _tokensService.Object, _encryptionService.Object, _passwordHasherService.Object, _mfaService.Object,
+ _authTokensService.Object, websiteUiService.Object, _repository.Object);
}
[Fact]
- public async Task WhenChangeMfaAsyncAndNotExists_ThenReturnsError()
+ public async Task WhenChangeMfaAsyncAndUserNotExists_ThenReturnsError()
{
_repository.Setup(s =>
s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
@@ -165,7 +182,7 @@ public async Task WhenChangeMfaAsyncAndNotAPerson_ThenReturnsError()
result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsApplication_NotPerson);
_endUsersService.Verify(eus =>
- eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(),
+ eus.GetUserPrivateAsync(_caller.Object, "acallerid",
It.IsAny()));
_repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()),
Times.Never);
@@ -193,8 +210,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(), It.IsAny(),
+ eus.GetUserPrivateAsync(_caller.Object, "auserid",
It.IsAny()));
_repository.Verify(s => s.SaveAsync(It.Is(root =>
root.MfaOptions.IsEnabled
@@ -240,10 +258,10 @@ public async Task WhenListMfaAuthenticatorsAsyncByAuthenticatedUserButNotExists_
.ReturnsAsync(Optional.None);
var result =
- await _application.ListMfaAuthenticatorsAsync(_caller.Object, "anmfatoken",
+ await _application.ListMfaAuthenticatorsAsync(_caller.Object, null,
CancellationToken.None);
- result.Should().BeError(ErrorCode.NotAuthenticated);
+ result.Should().BeError(ErrorCode.EntityNotFound);
}
[Fact]
@@ -291,6 +309,8 @@ await _application.ListMfaAuthenticatorsAsync(_caller.Object, null,
[Fact]
public async Task WhenDisassociateMfaAuthenticatorAsyncAndNotExists_ThenReturnsError()
{
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(true);
_repository.Setup(s =>
s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(Optional.None);
@@ -307,14 +327,16 @@ await _application.DisassociateMfaAuthenticatorAsync(_caller.Object, "anauthenti
[Fact]
public async Task WhenDisassociateMfaAuthenticatorAsync_ThenDeletes()
{
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(true);
_caller.Setup(cc => cc.CallerId)
.Returns("auserid");
var credential = CreateVerifiedCredential();
credential.ChangeMfaEnabled("auserid".ToId(), true);
- credential.InitiateMfaAuthentication();
- var authenticator = await credential.AssociateMfaAuthenticatorAsync("auserid".ToId(),
- MfaAuthenticatorType.TotpAuthenticator,
- Optional.None, Optional.None, _ => Task.FromResult(Result.Ok));
+ var authenticator = await credential.AssociateMfaAuthenticatorAsync(
+ MfaCaller.Create("auserid".ToId(), null).Value,
+ MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None,
+ EmailAddress.Create("auser@company.com").Value, _ => Task.FromResult(Result.Ok));
_repository.Setup(s =>
s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(credential.ToOptional());
@@ -370,11 +392,11 @@ public async Task WhenAssociateMfaAuthenticatorAsyncByAuthenticatedUserButNotExi
.ReturnsAsync(Optional.None);
var result =
- await _application.AssociateMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, null,
PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, null,
CancellationToken.None);
- result.Should().BeError(ErrorCode.NotAuthenticated);
+ result.Should().BeError(ErrorCode.EntityNotFound);
}
[Fact]
@@ -439,15 +461,16 @@ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
result.Value.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator);
result.Value.RecoveryCodes.Should().NotBeEmpty();
result.Value.OobCode.Should().BeNull();
- result.Value.BarCodeUri.Should().BeNull();
+ result.Value.BarCodeUri.Should().Be("abarcodeuri");
+ result.Value.Secret.Should().Be("anotpsecret");
_repository.Verify(s => s.SaveAsync(It.Is(cred =>
cred.MfaAuthenticators.Count == 2
- && cred.MfaAuthenticators[0].IsActive
+ && cred.MfaAuthenticators[0].IsActive == true
&& cred.MfaAuthenticators[0].Type == MfaAuthenticatorType.RecoveryCodes
- && cred.MfaAuthenticators[1].IsActive
+ && cred.MfaAuthenticators[1].IsActive == false
&& cred.MfaAuthenticators[1].Type == MfaAuthenticatorType.TotpAuthenticator
&& cred.MfaAuthenticators[1].OobCode == Optional.None
- && cred.MfaAuthenticators[1].BarCodeUri == Optional.None
+ && cred.MfaAuthenticators[1].BarCodeUri == "abarcodeuri"
), It.IsAny()));
_endUsersService.Verify(eus =>
eus.GetUserPrivateAsync(_caller.Object, "auserid",
@@ -482,15 +505,16 @@ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, null,
result.Value.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator);
result.Value.RecoveryCodes.Should().NotBeEmpty();
result.Value.OobCode.Should().BeNull();
- result.Value.BarCodeUri.Should().BeNull();
+ result.Value.BarCodeUri.Should().Be("abarcodeuri");
+ result.Value.Secret.Should().Be("anotpsecret");
_repository.Verify(s => s.SaveAsync(It.Is(cred =>
cred.MfaAuthenticators.Count == 2
- && cred.MfaAuthenticators[0].IsActive
+ && cred.MfaAuthenticators[0].IsActive == true
&& cred.MfaAuthenticators[0].Type == MfaAuthenticatorType.RecoveryCodes
- && cred.MfaAuthenticators[1].IsActive
+ && cred.MfaAuthenticators[1].IsActive == false
&& cred.MfaAuthenticators[1].Type == MfaAuthenticatorType.TotpAuthenticator
&& cred.MfaAuthenticators[1].OobCode == Optional.None
- && cred.MfaAuthenticators[1].BarCodeUri == Optional.None
+ && cred.MfaAuthenticators[1].BarCodeUri == "abarcodeuri"
), It.IsAny()));
_endUsersService.Verify(eus =>
eus.GetUserPrivateAsync(_caller.Object, "auserid",
@@ -526,11 +550,12 @@ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, null,
result.Value.RecoveryCodes.Should().NotBeEmpty();
result.Value.OobCode.Should().Be("anoobcode");
result.Value.BarCodeUri.Should().BeNull();
+ result.Value.Secret.Should().BeNull();
_repository.Verify(s => s.SaveAsync(It.Is(cred =>
cred.MfaAuthenticators.Count == 2
- && cred.MfaAuthenticators[0].IsActive
+ && cred.MfaAuthenticators[0].IsActive == true
&& cred.MfaAuthenticators[0].Type == MfaAuthenticatorType.RecoveryCodes
- && cred.MfaAuthenticators[1].IsActive
+ && cred.MfaAuthenticators[1].IsActive == false
&& cred.MfaAuthenticators[1].Type == MfaAuthenticatorType.OobSms
&& cred.MfaAuthenticators[1].OobCode == "anoobcode"
&& cred.MfaAuthenticators[1].BarCodeUri == Optional.None
@@ -543,7 +568,7 @@ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, null,
It.IsAny()));
_userNotificationsService.Verify(ns =>
ns.NotifyPasswordMfaOobSmsAsync(_caller.Object, "+6498876986",
- "anoobcode", It.IsAny>(), It.IsAny()));
+ "anoobsecret", It.IsAny>(), It.IsAny()));
}
[Fact]
@@ -572,11 +597,12 @@ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, null,
result.Value.RecoveryCodes.Should().NotBeEmpty();
result.Value.OobCode.Should().Be("anoobcode");
result.Value.BarCodeUri.Should().BeNull();
+ result.Value.Secret.Should().BeNull();
_repository.Verify(s => s.SaveAsync(It.Is(cred =>
cred.MfaAuthenticators.Count == 2
- && cred.MfaAuthenticators[0].IsActive
+ && cred.MfaAuthenticators[0].IsActive == true
&& cred.MfaAuthenticators[0].Type == MfaAuthenticatorType.RecoveryCodes
- && cred.MfaAuthenticators[1].IsActive
+ && cred.MfaAuthenticators[1].IsActive == false
&& cred.MfaAuthenticators[1].Type == MfaAuthenticatorType.OobEmail
&& cred.MfaAuthenticators[1].OobCode == "anoobcode"
&& cred.MfaAuthenticators[1].BarCodeUri == Optional.None
@@ -589,7 +615,861 @@ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, null,
It.IsAny()));
_userNotificationsService.Verify(ns =>
ns.NotifyPasswordMfaOobEmailAsync(_caller.Object, "auser@company.com",
- "anoobcode", It.IsAny>(), It.IsAny()));
+ "anoobsecret", It.IsAny>(), It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenAssociateMfaAuthenticatorAsyncForSecondAuthenticator_ThenAssociatesWithoutRecoveryCodes()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(true);
+ _caller.Setup(cc => cc.CallId)
+ .Returns("acallid");
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ _repository.Setup(s =>
+ s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, null,
+ PasswordCredentialMfaAuthenticatorType.OobSms, "auser@company.com",
+ CancellationToken.None);
+
+ var result =
+ await _application.AssociateMfaAuthenticatorAsync(_caller.Object, null,
+ PasswordCredentialMfaAuthenticatorType.OobEmail, null,
+ CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail);
+ result.Value.RecoveryCodes.Should().BeNull();
+ _endUsersService.Verify(eus =>
+ eus.GetUserPrivateAsync(_caller.Object, "auserid",
+ It.IsAny()));
+ _userProfilesService.Verify(ups =>
+ ups.GetProfilePrivateAsync(It.Is(cc => cc.CallId == "acallid"), "auserid",
+ It.IsAny()));
+ _userNotificationsService.Verify(ns =>
+ ns.NotifyPasswordMfaOobEmailAsync(_caller.Object, "auser@company.com",
+ "anoobsecret", It.IsAny>(), It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenConfirmMfaAuthenticatorAssociationAsyncByAnonymousAndNoMfaToken_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(false);
+
+ var result =
+ await _application.ConfirmMfaAuthenticatorAssociationAsync(_caller.Object, null,
+ PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, "anoobcode", "aconfirmationcode",
+ CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.NotAuthenticated);
+ }
+
+ [Fact]
+ public async Task WhenConfirmMfaAuthenticatorAssociationAsyncByAnonymousAndNotExists_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(false);
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+
+ var result =
+ await _application.ConfirmMfaAuthenticatorAssociationAsync(_caller.Object, "anmfatoken",
+ PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, "anoobcode", "aconfirmationcode",
+ CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.NotAuthenticated);
+ }
+
+ [Fact]
+ public async Task WhenConfirmMfaAuthenticatorAssociationAsyncByAuthenticatedUserButNotExists_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(true);
+ _repository.Setup(s =>
+ s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+
+ var result =
+ await _application.ConfirmMfaAuthenticatorAssociationAsync(_caller.Object, null,
+ PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, "anoobcode", "aconfirmationcode",
+ CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.EntityNotFound);
+ }
+
+ [Fact]
+ public async Task WhenConfirmMfaAuthenticatorAssociationAsyncByAnonymousForTotp_ThenConfirms()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(false);
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.TotpAuthenticator,
+ Optional.None, Optional.None, EmailAddress.Create("auser@company.com").Value,
+ _ => Task.FromResult(Result.Ok));
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ var user = new EndUserWithMemberships
+ {
+ Id = "auserid",
+ Memberships =
+ [
+ new Membership
+ {
+ Id = "amembershipid",
+ IsDefault = true,
+ OrganizationId = "anorganizationid",
+ UserId = "auserid"
+ }
+ ]
+ };
+ _endUsersService.Setup(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(user);
+ var expiresOn = DateTime.UtcNow;
+ _authTokensService.Setup(jts =>
+ jts.IssueTokensAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn,
+ "arefreshtoken", expiresOn));
+
+ var result =
+ await _application.ConfirmMfaAuthenticatorAssociationAsync(_caller.Object, "anmfatoken",
+ PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, null, "aconfirmationcode",
+ CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Tokens!.UserId.Should().Be("auserid");
+ result.Value.Tokens!.AccessToken.Value.Should().Be("anaccesstoken");
+ result.Value.Tokens!.RefreshToken.Value.Should().Be("arefreshtoken");
+ result.Value.Tokens!.AccessToken.ExpiresOn.Should().Be(expiresOn);
+ result.Value.Tokens!.RefreshToken.ExpiresOn.Should().Be(expiresOn);
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[0].IsActive == true
+ && cred.MfaAuthenticators[1].VerifiedState == "1"
+ && cred.MfaAuthenticators[1].IsActive == true
+ ), It.IsAny()));
+ _endUsersService.Verify(eus =>
+ eus.GetMembershipsPrivateAsync(_caller.Object, "auserid",
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenConfirmMfaAuthenticatorAssociationAsyncByAuthenticatedUserForTotp_ThenConfirms()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(true);
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.TotpAuthenticator,
+ Optional.None, Optional.None, EmailAddress.Create("auser@company.com").Value,
+ _ => Task.FromResult(Result.Ok));
+ _repository.Setup(s =>
+ s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ var user = new EndUserWithMemberships
+ {
+ Id = "auserid",
+ Memberships =
+ [
+ new Membership
+ {
+ Id = "amembershipid",
+ IsDefault = true,
+ OrganizationId = "anorganizationid",
+ UserId = "auserid"
+ }
+ ]
+ };
+ _endUsersService.Setup(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(user);
+ var expiresOn = DateTime.UtcNow;
+ _authTokensService.Setup(jts =>
+ jts.IssueTokensAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn,
+ "arefreshtoken", expiresOn));
+
+ var result =
+ await _application.ConfirmMfaAuthenticatorAssociationAsync(_caller.Object, null,
+ PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, null, "aconfirmationcode",
+ CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Tokens.Should().BeNull();
+ result.Value.Authenticators!.Count.Should().Be(2);
+ result.Value.Authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes);
+ result.Value.Authenticators[0].IsActive.Should().BeTrue();
+ result.Value.Authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator);
+ result.Value.Authenticators[1].IsActive.Should().BeTrue();
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[0].IsActive == true
+ && cred.MfaAuthenticators[1].VerifiedState == "1"
+ && cred.MfaAuthenticators[1].IsActive == true
+ ), It.IsAny()));
+ _endUsersService.Verify(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task WhenConfirmMfaAuthenticatorAssociationAsyncByAuthenticatedUserForOobSms_ThenConfirms()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(true);
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.OobSms,
+ PhoneNumber.Create("+6498876986").Value, Optional.None, Optional.None,
+ _ => Task.FromResult(Result.Ok));
+ _repository.Setup(s =>
+ s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ var user = new EndUserWithMemberships
+ {
+ Id = "auserid",
+ Memberships =
+ [
+ new Membership
+ {
+ Id = "amembershipid",
+ IsDefault = true,
+ OrganizationId = "anorganizationid",
+ UserId = "auserid"
+ }
+ ]
+ };
+ _endUsersService.Setup(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(user);
+ var expiresOn = DateTime.UtcNow;
+ _authTokensService.Setup(jts =>
+ jts.IssueTokensAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn,
+ "arefreshtoken", expiresOn));
+
+ var result =
+ await _application.ConfirmMfaAuthenticatorAssociationAsync(_caller.Object, null,
+ PasswordCredentialMfaAuthenticatorType.OobSms, "anoobcode", "anoobsecret",
+ CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Tokens.Should().BeNull();
+ result.Value.Authenticators!.Count.Should().Be(2);
+ result.Value.Authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes);
+ result.Value.Authenticators[0].IsActive.Should().BeTrue();
+ result.Value.Authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms);
+ result.Value.Authenticators[1].IsActive.Should().BeTrue();
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[0].IsActive == true
+ && cred.MfaAuthenticators[1].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[1].IsActive == true
+ ), It.IsAny()));
+ _endUsersService.Verify(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task WhenConfirmMfaAuthenticatorAssociationAsyncByAuthenticatedUserForOobEmail_ThenConfirms()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(true);
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.OobEmail,
+ Optional.None, EmailAddress.Create("auser@company.com").Value, Optional.None,
+ _ => Task.FromResult(Result.Ok));
+ _repository.Setup(s =>
+ s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ var user = new EndUserWithMemberships
+ {
+ Id = "auserid",
+ Memberships =
+ [
+ new Membership
+ {
+ Id = "amembershipid",
+ IsDefault = true,
+ OrganizationId = "anorganizationid",
+ UserId = "auserid"
+ }
+ ]
+ };
+ _endUsersService.Setup(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(user);
+ var expiresOn = DateTime.UtcNow;
+ _authTokensService.Setup(jts =>
+ jts.IssueTokensAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn,
+ "arefreshtoken", expiresOn));
+
+ var result =
+ await _application.ConfirmMfaAuthenticatorAssociationAsync(_caller.Object, null,
+ PasswordCredentialMfaAuthenticatorType.OobEmail, "anoobcode", "anoobsecret",
+ CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Tokens.Should().BeNull();
+ result.Value.Authenticators!.Count.Should().Be(2);
+ result.Value.Authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes);
+ result.Value.Authenticators[0].IsActive.Should().BeTrue();
+ result.Value.Authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail);
+ result.Value.Authenticators[1].IsActive.Should().BeTrue();
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[0].IsActive == true
+ && cred.MfaAuthenticators[1].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[1].IsActive == true
+ ), It.IsAny()));
+ _endUsersService.Verify(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task WhenChallengeMfaAuthenticatorAsyncByAnonymousAndNotExists_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(false);
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+
+ var result =
+ await _application.ChallengeMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ "anauthenticatorid", CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.NotAuthenticated);
+ }
+
+ [Fact]
+ public async Task WhenChallengeMfaAuthenticatorAsyncByAnonymousForTotpAuthenticator_ThenChallenges()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.TotpAuthenticator,
+ Optional.None, Optional.None, EmailAddress.Create("auser@company.com").Value,
+ _ => Task.FromResult(Result.Ok));
+ credential.ConfirmMfaAuthenticatorAssociation(MfaCaller.Create("auserid".ToId(), null).Value,
+ MfaAuthenticatorType.TotpAuthenticator,
+ null, "aconfirmationcode");
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+
+ var result =
+ await _application.ChallengeMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ "anauthenticatorid2", CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator);
+ result.Value.OobCode.Should().BeNull();
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[1].VerifiedState == "1"
+ ), It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenChallengeMfaAuthenticatorAsyncByAnonymousForOobSms_ThenChallenges()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.OobSms,
+ PhoneNumber.Create("+6498876986").Value, Optional.None, Optional.None,
+ _ => Task.FromResult(Result.Ok));
+ credential.ConfirmMfaAuthenticatorAssociation(MfaCaller.Create("auserid".ToId(), null).Value,
+ MfaAuthenticatorType.OobSms,
+ "anoobcode", "anoobsecret");
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+
+ var result =
+ await _application.ChallengeMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ "anauthenticatorid2", CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms);
+ result.Value.OobCode.Should().Be("anoobcode");
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[1].VerifiedState == Optional.None
+ ), It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenChallengeMfaAuthenticatorAsyncByAnonymousForOobEmail_ThenChallenges()
+ {
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.OobEmail,
+ Optional.None, EmailAddress.Create("auser@company.com").Value, Optional.None,
+ _ => Task.FromResult(Result.Ok));
+ credential.ConfirmMfaAuthenticatorAssociation(MfaCaller.Create("auserid".ToId(), null).Value,
+ MfaAuthenticatorType.OobEmail,
+ "anoobcode", "anoobsecret");
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+
+ var result =
+ await _application.ChallengeMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ "anauthenticatorid2", CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail);
+ result.Value.OobCode.Should().Be("anoobcode");
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[1].VerifiedState == Optional.None
+ ), It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenVerifyMfaAuthenticatorAsyncByAnonymousAndNoMfaToken_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(false);
+
+ var result = await _application.VerifyMfaAuthenticatorAsync(_caller.Object, null!,
+ PasswordCredentialMfaAuthenticatorType.OobEmail, "anoobcode", "aconfirmationcode", CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.NotAuthenticated);
+ }
+
+ [Fact]
+ public async Task WhenVerifyMfaAuthenticatorAsyncByAnonymousAndNotExists_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(false);
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+
+ var result =
+ await _application.VerifyMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ PasswordCredentialMfaAuthenticatorType.OobEmail, "anoobcode", "aconfirmationcode",
+ CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.NotAuthenticated);
+ }
+
+ [Fact]
+ public async Task WhenVerifyMfaAuthenticatorAsyncByAuthenticatedUser_ThenReturnsError()
+ {
+ _caller.Setup(cc => cc.IsAuthenticated)
+ .Returns(true);
+ _caller.Setup(cc => cc.CallerId)
+ .Returns("auserid");
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.TotpAuthenticator,
+ Optional.None, Optional.None, EmailAddress.Create("auser@company.com").Value,
+ _ => Task.FromResult(Result.Ok));
+ _repository.Setup(s =>
+ s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+
+ var result =
+ await _application.VerifyMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ PasswordCredentialMfaAuthenticatorType.OobEmail, "anoobcode", "aconfirmationcode",
+ CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.ForbiddenAccess);
+ }
+
+ [Fact]
+ public async Task WhenVerifyMfaAuthenticatorAsyncByAuthenticatedUserForRecoveryCodes_ThenConfirms()
+ {
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ //We need to create another authenticator to get recovery codes created
+ await credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.TotpAuthenticator,
+ Optional.None, Optional.None, EmailAddress.Create("auser@company.com").Value,
+ _ => Task.FromResult(Result.Ok));
+ credential.ConfirmMfaAuthenticatorAssociation(MfaCaller.Create("auserid".ToId(), null).Value,
+ MfaAuthenticatorType.TotpAuthenticator, null,
+ "aconfirmationcode");
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ var user = new EndUserWithMemberships
+ {
+ Id = "auserid",
+ Memberships =
+ [
+ new Membership
+ {
+ Id = "amembershipid",
+ IsDefault = true,
+ OrganizationId = "anorganizationid",
+ UserId = "auserid"
+ }
+ ]
+ };
+ _endUsersService.Setup(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(user);
+ var expiresOn = DateTime.UtcNow;
+ _authTokensService.Setup(jts =>
+ jts.IssueTokensAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn,
+ "arefreshtoken", expiresOn));
+ var recoveryCode = credential.MfaAuthenticators.ToRecoveryCodes(_encryptionService.Object)[0];
+
+ var result =
+ await _application.VerifyMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ PasswordCredentialMfaAuthenticatorType.RecoveryCodes, null, recoveryCode, CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.UserId.Should().Be("auserid");
+ result.Value.AccessToken.Value.Should().Be("anaccesstoken");
+ result.Value.RefreshToken.Value.Should().Be("arefreshtoken");
+ result.Value.AccessToken.ExpiresOn.Should().Be(expiresOn);
+ result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn);
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == recoveryCode
+ && cred.MfaAuthenticators[1].VerifiedState == "1"
+ ), It.IsAny()));
+ _endUsersService.Verify(eus =>
+ eus.GetMembershipsPrivateAsync(_caller.Object, "auserid",
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenVerifyMfaAuthenticatorAsyncByAuthenticatedUserForTotp_ThenConfirms()
+ {
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ var authenticator = await credential.AssociateMfaAuthenticatorAsync(
+ MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None,
+ EmailAddress.Create("auser@company.com").Value, _ => Task.FromResult(Result.Ok));
+ credential.ConfirmMfaAuthenticatorAssociation(MfaCaller.Create("auserid".ToId(), null).Value,
+ MfaAuthenticatorType.TotpAuthenticator, null,
+ "aconfirmationcode");
+ await credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value,
+ authenticator.Value.Id,
+ _ => Task.FromResult(Result.Ok));
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ var user = new EndUserWithMemberships
+ {
+ Id = "auserid",
+ Memberships =
+ [
+ new Membership
+ {
+ Id = "amembershipid",
+ IsDefault = true,
+ OrganizationId = "anorganizationid",
+ UserId = "auserid"
+ }
+ ]
+ };
+ _endUsersService.Setup(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(user);
+ var expiresOn = DateTime.UtcNow;
+ _authTokensService.Setup(jts =>
+ jts.IssueTokensAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn,
+ "arefreshtoken", expiresOn));
+
+ var result =
+ await _application.VerifyMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, null, "aconfirmationcode",
+ CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.UserId.Should().Be("auserid");
+ result.Value.AccessToken.Value.Should().Be("anaccesstoken");
+ result.Value.RefreshToken.Value.Should().Be("arefreshtoken");
+ result.Value.AccessToken.ExpiresOn.Should().Be(expiresOn);
+ result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn);
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[1].VerifiedState == "1"
+ ), It.IsAny()));
+ _endUsersService.Verify(eus =>
+ eus.GetMembershipsPrivateAsync(_caller.Object, "auserid",
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenVerifyMfaAuthenticatorAsyncByAuthenticatedUserForOobSms_ThenConfirms()
+ {
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ var authenticator = await credential.AssociateMfaAuthenticatorAsync(
+ MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.OobSms, PhoneNumber.Create("+6498876986").Value, Optional.None,
+ Optional.None, _ => Task.FromResult(Result.Ok));
+ credential.ConfirmMfaAuthenticatorAssociation(MfaCaller.Create("auserid".ToId(), null).Value,
+ MfaAuthenticatorType.OobSms, "anoobcode",
+ "anoobsecret");
+ await credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value,
+ authenticator.Value.Id,
+ _ => Task.FromResult(Result.Ok));
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ var user = new EndUserWithMemberships
+ {
+ Id = "auserid",
+ Memberships =
+ [
+ new Membership
+ {
+ Id = "amembershipid",
+ IsDefault = true,
+ OrganizationId = "anorganizationid",
+ UserId = "auserid"
+ }
+ ]
+ };
+ _endUsersService.Setup(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(user);
+ var expiresOn = DateTime.UtcNow;
+ _authTokensService.Setup(jts =>
+ jts.IssueTokensAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn,
+ "arefreshtoken", expiresOn));
+
+ var result =
+ await _application.VerifyMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ PasswordCredentialMfaAuthenticatorType.OobSms, "anoobcode", "anoobsecret", CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.UserId.Should().Be("auserid");
+ result.Value.AccessToken.Value.Should().Be("anaccesstoken");
+ result.Value.RefreshToken.Value.Should().Be("arefreshtoken");
+ result.Value.AccessToken.ExpiresOn.Should().Be(expiresOn);
+ result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn);
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[1].VerifiedState == Optional.None
+ ), It.IsAny()));
+ _endUsersService.Verify(eus =>
+ eus.GetMembershipsPrivateAsync(_caller.Object, "auserid",
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenVerifyMfaAuthenticatorAsyncByAuthenticatedUserForOobEmail_ThenConfirms()
+ {
+ var credential = CreateVerifiedCredential();
+ credential.ChangeMfaEnabled("auserid".ToId(), true);
+ credential.InitiateMfaAuthentication();
+ var authenticator = await credential.AssociateMfaAuthenticatorAsync(
+ MfaCaller.Create("auserid".ToId(), "anmfatoken").Value,
+ MfaAuthenticatorType.OobEmail, Optional.None, EmailAddress.Create("auser@company.com").Value,
+ Optional.None,
+ _ => Task.FromResult(Result.Ok));
+ credential.ConfirmMfaAuthenticatorAssociation(MfaCaller.Create("auserid".ToId(), null).Value,
+ MfaAuthenticatorType.OobEmail, "anoobcode",
+ "anoobsecret");
+ await credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value,
+ authenticator.Value.Id,
+ _ => Task.FromResult(Result.Ok));
+ _repository.Setup(s =>
+ s.FindCredentialsByMfaAuthenticationTokenAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(credential.ToOptional());
+ var user = new EndUserWithMemberships
+ {
+ Id = "auserid",
+ Memberships =
+ [
+ new Membership
+ {
+ Id = "amembershipid",
+ IsDefault = true,
+ OrganizationId = "anorganizationid",
+ UserId = "auserid"
+ }
+ ]
+ };
+ _endUsersService.Setup(eus =>
+ eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(user);
+ var expiresOn = DateTime.UtcNow;
+ _authTokensService.Setup(jts =>
+ jts.IssueTokensAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn,
+ "arefreshtoken", expiresOn));
+
+ var result =
+ await _application.VerifyMfaAuthenticatorAsync(_caller.Object, "anmfatoken",
+ PasswordCredentialMfaAuthenticatorType.OobEmail, "anoobcode", "anoobsecret", CancellationToken.None);
+
+ result.Should().BeSuccess();
+ result.Value.UserId.Should().Be("auserid");
+ result.Value.AccessToken.Value.Should().Be("anaccesstoken");
+ result.Value.RefreshToken.Value.Should().Be("arefreshtoken");
+ result.Value.AccessToken.ExpiresOn.Should().Be(expiresOn);
+ result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn);
+ _repository.Verify(s => s.SaveAsync(It.Is(cred =>
+ cred.MfaAuthenticators.Count == 2
+ && cred.MfaAuthenticators[0].VerifiedState == Optional.None
+ && cred.MfaAuthenticators[1].VerifiedState == Optional.None
+ ), It.IsAny()));
+ _endUsersService.Verify(eus =>
+ eus.GetMembershipsPrivateAsync(_caller.Object, "auserid",
+ It.IsAny()));
+ }
+
+ [Fact]
+ public async Task WhenResetPasswordMfaAsyncByOperatorAndUserNotExist_ThenReturnsError()
+ {
+ _repository.Setup(s =>
+ s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Optional.None);
+
+ var result =
+ await _application.ResetPasswordMfaAsync(_caller.Object, "auserid", CancellationToken.None);
+
+ result.Should().BeError(ErrorCode.EntityNotFound);
+ _endUsersService.Verify(eus =>
+ eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task WhenResetPasswordMfaAsyncAndNotAPerson_ThenReturnsError()
+ {
+ var credential = CreateVerifiedCredential();
+ _repository.Setup(s =>
+ s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny