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 team This 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())) + .ReturnsAsync(credential.ToOptional()); + _endUsersService.Setup(eus => + eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUser + { + Id = "auserid", + Classification = EndUserClassification.Machine + }); + + var result = + await _application.ResetPasswordMfaAsync(_caller.Object, "auserid", CancellationToken.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsApplication_NotPerson); + _endUsersService.Verify(eus => + eus.GetUserPrivateAsync(_caller.Object, "auserid", + It.IsAny())); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenResetPasswordMfaAsync_ThenResetsMfaState() + { + _caller.Setup(cc => cc.CallerId) + .Returns("anoperatorid"); + _caller.Setup(cc => cc.Roles).Returns(new ICallerContext.CallerRoles([PlatformRoles.Operations], [])); + var credential = CreateVerifiedCredential(); + _repository.Setup(s => + s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(credential.ToOptional()); + _endUsersService.Setup(eus => + eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUser + { + Id = "auserid", + Classification = EndUserClassification.Person + }); + + var result = + await _application.ResetPasswordMfaAsync(_caller.Object, "auserid", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.IsMfaEnabled.Should().BeFalse(); + _endUsersService.Verify(eus => + eus.GetUserPrivateAsync(_caller.Object, "auserid", + It.IsAny())); + _repository.Verify(s => s.SaveAsync(It.Is(root => + root.MfaOptions.IsEnabled == false + && root.MfaOptions.CanBeDisabled == true + ), It.IsAny())); } private PasswordCredentialRoot CreateUnVerifiedCredential() @@ -613,7 +1493,8 @@ private PasswordCredentialRoot CreateVerifiedCredential() private PasswordCredentialRoot CreateCredential() { return PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, _settings.Object, - _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object, + _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, _mfaService.Object, "auserid".ToId()).Value; } } \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplication.PasswordResetSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplication.PasswordResetSpec.cs new file mode 100644 index 00000000..6326474f --- /dev/null +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplication.PasswordResetSpec.cs @@ -0,0 +1,283 @@ +using Application.Interfaces; +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Services.Shared; +using Domain.Shared; +using IdentityApplication.ApplicationServices; +using IdentityApplication.Persistence; +using IdentityDomain; +using IdentityDomain.DomainServices; +using Moq; +using UnitTesting.Common; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace IdentityApplication.UnitTests; + +[Trait("Category", "Unit")] +public class PasswordCredentialsApplicationPasswordResetSpec +{ + private const string TestingToken = "Ll4qhv77XhiXSqsTUc6icu56ZLrqu5p1gH9kT5IlHio"; + private readonly PasswordCredentialsApplication _application; + private readonly Mock _caller; + private readonly Mock _emailAddressService; + private readonly Mock _idFactory; + private readonly Mock _mfaService; + private readonly Mock _notificationsService; + private readonly Mock _passwordHasherService; + private readonly Mock _recorder; + private readonly Mock _repository; + private readonly Mock _settings; + private readonly Mock _tokensService; + private readonly Mock _encryptionService; + + public PasswordCredentialsApplicationPasswordResetSpec() + { + _recorder = new Mock(); + _idFactory = new Mock(); + _idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + _caller = new Mock(); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); + var endUsersService = new Mock(); + var userProfilesService = new Mock(); + _notificationsService = new Mock(); + _settings = new Mock(); + _settings.Setup(s => s.Platform.GetString(It.IsAny(), It.IsAny())) + .Returns((string?)null!); + _settings.Setup(s => s.Platform.GetNumber(It.IsAny(), It.IsAny())) + .Returns(5); + _emailAddressService = new Mock(); + _emailAddressService.Setup(eas => eas.EnsureUniqueAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _tokensService = new Mock(); + _tokensService.Setup(ts => ts.CreateRegistrationVerificationToken()) + .Returns("averificationtoken"); + _tokensService.Setup(ts => ts.CreateMfaAuthenticationToken()) + .Returns("anmfatoken"); + _encryptionService = new Mock(); + _passwordHasherService = new Mock(); + _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.HashPassword(It.IsAny())) + .Returns("apasswordhash"); + _passwordHasherService.Setup(phs => phs.ValidatePasswordHash(It.IsAny())) + .Returns(true); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + _mfaService = new Mock(); + var authTokensService = new Mock(); + var websiteUiService = new Mock(); + _repository = new Mock(); + _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .Returns((PasswordCredentialRoot root, CancellationToken _) => + Task.FromResult>(root)); + _repository.Setup(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((PasswordCredentialRoot root, bool _, CancellationToken _) => + Task.FromResult>(root)); + + _application = new PasswordCredentialsApplication(_recorder.Object, _idFactory.Object, endUsersService.Object, + userProfilesService.Object, _notificationsService.Object, _settings.Object, _emailAddressService.Object, + _tokensService.Object, _encryptionService.Object, _passwordHasherService.Object, _mfaService.Object, + authTokensService.Object, + websiteUiService.Object, + _repository.Object); + } + + [Fact] + public async Task WhenInitiatePasswordRequestAndUnknownEmailAddress_ThenSendsCourtesyNotification() + { + _repository.Setup(s => s.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.InitiatePasswordResetAsync(_caller.Object, "user@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetUnknownUserCourtesyAsync(_caller.Object, "user@company.com", + UserNotificationConstants.EmailTags.PasswordResetUnknownUser, CancellationToken.None)); + } + + [Fact] + public async Task WhenInitiatePasswordRequest_ThenSendsNotification() + { + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(TestingToken); + _repository.Setup(s => s.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateVerifiedCredential().ToOptional()); + + var result = + await _application.InitiatePasswordResetAsync(_caller.Object, "user@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.Is(cred => + cred.IsPasswordSet + ), It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(_caller.Object, "aname", "user@company.com", TestingToken, + UserNotificationConstants.EmailTags.PasswordResetInitiated, It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetUnknownUserCourtesyAsync(It.IsAny(), "user@company.com", + It.IsAny>(), CancellationToken.None), Times.Never); + } + + [Fact] + public async Task WhenResendPasswordRequestAndUnknownToken_ThenReturnsError() + { + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.ResendPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenResendPasswordRequest_ThenResendsNotification() + { + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(TestingToken); + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateVerifiedCredential().ToOptional()); + + var result = + await _application.ResendPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.Is(cred => + cred.IsPasswordResetInitiated + ), It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(_caller.Object, "aname", "auser@company.com", TestingToken, + UserNotificationConstants.EmailTags.PasswordResetResend, It.IsAny())); + } + + [Fact] + public async Task WhenVerifyPasswordResetAsyncAndUnknownToken_ThenReturnsError() + { + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.VerifyPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenVerifyPasswordResetAsync_ThenVerifies() + { + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(TestingToken); + var credential = CreateVerifiedCredential(); + credential.InitiatePasswordReset(); + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(credential.ToOptional()); + + var result = + await _application.VerifyPasswordResetAsync(_caller.Object, TestingToken, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenCompletePasswordResetAsyncAndUnknownToken_ThenReturnsError() + { + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _application.CompletePasswordResetAsync(_caller.Object, "atoken", "apassword", + CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenCompletePasswordResetAsync_ThenCompletes() + { + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(TestingToken); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(false); + + var credential = CreateVerifiedCredential(); + credential.InitiatePasswordReset(); + _repository.Setup(s => + s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(credential.ToOptional()); + + var result = + await _application.CompletePasswordResetAsync(_caller.Object, TestingToken, "2Password!", + CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(s => s.SaveAsync(It.Is(creds => + !creds.IsPasswordResetInitiated + ), It.IsAny())); + _notificationsService.Verify(ns => + ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + private PasswordCredentialRoot CreateUnVerifiedCredential() + { + var credential = CreateCredential(); + credential.SetPasswordCredential("apassword"); + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + credential.InitiateRegistrationVerification(); + + return credential; + } + + private PasswordCredentialRoot CreateVerifiedCredential() + { + var credential = CreateUnVerifiedCredential(); + credential.VerifyRegistration(); + return credential; + } + + private PasswordCredentialRoot CreateCredential() + { + return PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, _settings.Object, + _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId()).Value; + } +} \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs index e0c2e901..b0055670 100644 --- a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs @@ -25,13 +25,13 @@ namespace IdentityApplication.UnitTests; [Trait("Category", "Unit")] public class PasswordCredentialsApplicationSpec { - private const string TestingToken = "Ll4qhv77XhiXSqsTUc6icu56ZLrqu5p1gH9kT5IlHio"; private readonly PasswordCredentialsApplication _application; private readonly Mock _authTokensService; private readonly Mock _caller; private readonly Mock _emailAddressService; private readonly Mock _endUsersService; private readonly Mock _idFactory; + private readonly Mock _mfaService; private readonly Mock _notificationsService; private readonly Mock _passwordHasherService; private readonly Mock _recorder; @@ -39,6 +39,7 @@ public class PasswordCredentialsApplicationSpec private readonly Mock _settings; private readonly Mock _tokensService; private readonly Mock _userProfilesService; + private readonly Mock _encryptionService; public PasswordCredentialsApplicationSpec() { @@ -63,6 +64,9 @@ public PasswordCredentialsApplicationSpec() _tokensService = new Mock(); _tokensService.Setup(ts => ts.CreateRegistrationVerificationToken()) .Returns("averificationtoken"); + _tokensService.Setup(ts => ts.CreateMfaAuthenticationToken()) + .Returns("anmfatoken"); + _encryptionService = new Mock(); _passwordHasherService = new Mock(); _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) .Returns(true); @@ -72,16 +76,23 @@ public PasswordCredentialsApplicationSpec() .Returns(true); _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) .Returns(true); + _mfaService = new Mock(); _authTokensService = new Mock(); var websiteUiService = new Mock(); _repository = new Mock(); _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) .Returns((PasswordCredentialRoot root, CancellationToken _) => Task.FromResult>(root)); + _repository.Setup(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((PasswordCredentialRoot root, bool _, CancellationToken _) => + Task.FromResult>(root)); _application = new PasswordCredentialsApplication(_recorder.Object, _idFactory.Object, _endUsersService.Object, _userProfilesService.Object, _notificationsService.Object, _settings.Object, _emailAddressService.Object, - _tokensService.Object, _passwordHasherService.Object, _authTokensService.Object, websiteUiService.Object, + _tokensService.Object, _encryptionService.Object, _passwordHasherService.Object, _mfaService.Object, + _authTokensService.Object, + websiteUiService.Object, _repository.Object); } @@ -254,7 +265,8 @@ public async Task WhenAuthenticateAsyncAndWrongPassword_ThenReturnsError() await _application.AuthenticateAsync(_caller.Object, "ausername", "awrongpassword", CancellationToken.None); result.Should().BeError(ErrorCode.NotAuthenticated); - _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny())); + _repository.Verify(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())); _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", Audits.PasswordCredentialsApplication_Authenticate_InvalidCredentials, It.IsAny(), It.IsAny())); @@ -282,14 +294,87 @@ public async Task WhenAuthenticateAsyncAndCredentialsNotYetVerified_ThenReturnsE result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsApplication_RegistrationNotVerified); - _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny())); + _repository.Verify(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())); _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", Audits.PasswordCredentialsApplication_Authenticate_BeforeVerified, It.IsAny(), It.IsAny())); } [Fact] - public async Task WhenAuthenticateAsyncWithCorrectPassword_ThenAuthenticates() + public async Task WhenAuthenticateAsyncWithCorrectPasswordAndMfa_ThenReturnsError() + { + _caller.Setup(cc => cc.CallId) + .Returns("acallid"); + var credential = CreateVerifiedCredential(); + credential.ChangeMfaEnabled("auserid".ToId(), true); + _repository.Setup(rep => rep.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(credential.ToOptional()); + var user = new EndUserWithMemberships + { + Id = "auserid", + Status = EndUserStatus.Registered, + Classification = EndUserClassification.Person, + Access = EndUserAccess.Enabled, + Memberships = + [ + new Membership + { + Id = "amembershipid", + IsDefault = true, + OrganizationId = "anorganizationid", + UserId = "auserid" + } + ] + }; + _endUsersService.Setup(eus => + eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(user); + _userProfilesService.Setup(ups => + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UserProfile + { + Id = "aprofileid", + UserId = "auserid", + Name = new PersonName + { + FirstName = "afirstname", + LastName = "alastname" + }, + DisplayName = "adisplayname" + }); + 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.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); + + result.Should().BeError(ErrorCode.ForbiddenAccess, Resources.PasswordCredentialsApplication_MfaRequired, + error => error.AdditionalCode == PasswordCredentialsApplication.MfaRequiredCode + && error.AdditionalData!.Count == 1 + && (string)error.AdditionalData[PasswordCredentialsApplication.MfaTokenName] == "anmfatoken"); + _userProfilesService.Verify(ups => + ups.GetProfilePrivateAsync(It.Is(cc => + cc.CallId == "acallid" + ), "auserid", It.IsAny())); + _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny())); + _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", + Audits.PasswordCredentialsApplication_Authenticate_Succeeded, It.IsAny(), + It.IsAny())); + _tokensService.Verify(ts => ts.CreateMfaAuthenticationToken()); + _authTokensService.Verify(jts => + jts.IssueTokensAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenAuthenticateAsyncWithCorrectPasswordAndNotMfa_ThenAuthenticates() { _caller.Setup(cc => cc.CallId) .Returns("acallid"); @@ -350,7 +435,8 @@ public async Task WhenAuthenticateAsyncWithCorrectPassword_ThenAuthenticates() ups.GetProfilePrivateAsync(It.Is(cc => cc.CallId == "acallid" ), "auserid", It.IsAny())); - _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny())); + _repository.Verify(rep => + rep.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())); _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", Audits.PasswordCredentialsApplication_Authenticate_Succeeded, It.IsAny(), It.IsAny())); @@ -359,9 +445,9 @@ public async Task WhenAuthenticateAsyncWithCorrectPassword_ThenAuthenticates() } [Fact] - public async Task WhenRegisterPersonAsyncAndAlreadyExists_ThenDoesNothing() + public async Task WhenAuthenticateAsyncAndSendingEmailFails_ThenReturnsError() { - var endUser = new EndUser + var registeredAccount = new EndUser { Id = "auserid" }; @@ -369,26 +455,34 @@ public async Task WhenRegisterPersonAsyncAndAlreadyExists_ThenDoesNothing() It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(endUser); - var credential = CreateUnVerifiedCredential(); + .ReturnsAsync(registeredAccount); _repository.Setup(s => s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(credential.ToOptional()); + .ReturnsAsync(Optional + .None); + _notificationsService.Setup(ns => + ns.NotifyPasswordRegistrationConfirmationAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(Error.Unexpected()); var result = await _application.RegisterPersonAsync(_caller.Object, "aninvitationtoken", "afirstname", "alastname", "auser@company.com", "apassword", "atimezone", "acountrycode", true, CancellationToken.None); - result.Value.User.Should().Be(endUser); + result.Should().BeError(ErrorCode.Unexpected); _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); - _endUsersService.Verify(uas => uas.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", + _notificationsService.Verify(ns => + ns.NotifyPasswordRegistrationConfirmationAsync(_caller.Object, "auser@company.com", "afirstname", + "averificationtoken", It.IsAny>(), It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", "auser@company.com", "afirstname", "alastname", "atimezone", "acountrycode", true, It.IsAny())); } [Fact] - public async Task WhenAuthenticateAsyncAndSendingEmailFails_ThenReturnsError() + public async Task WhenRegisterPersonAsyncAndAlreadyExists_ThenDoesNothing() { - var registeredAccount = new EndUser + var endUser = new EndUser { Id = "auserid" }; @@ -396,26 +490,18 @@ public async Task WhenAuthenticateAsyncAndSendingEmailFails_ThenReturnsError() It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(registeredAccount); + .ReturnsAsync(endUser); + var credential = CreateUnVerifiedCredential(); _repository.Setup(s => s.FindCredentialsByUserIdAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional - .None); - _notificationsService.Setup(ns => - ns.NotifyPasswordRegistrationConfirmationAsync(It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny>(), - It.IsAny())) - .ReturnsAsync(Error.Unexpected()); + .ReturnsAsync(credential.ToOptional()); var result = await _application.RegisterPersonAsync(_caller.Object, "aninvitationtoken", "afirstname", "alastname", "auser@company.com", "apassword", "atimezone", "acountrycode", true, CancellationToken.None); - result.Should().BeError(ErrorCode.Unexpected); + result.Value.User.Should().Be(endUser); _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); - _notificationsService.Verify(ns => - ns.NotifyPasswordRegistrationConfirmationAsync(_caller.Object, "auser@company.com", "afirstname", - "averificationtoken", It.IsAny>(), It.IsAny())); - _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", + _endUsersService.Verify(uas => uas.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", "auser@company.com", "afirstname", "alastname", "atimezone", "acountrycode", true, It.IsAny())); } @@ -494,171 +580,6 @@ public async Task WhenConfirmPersonRegistrationAsync_ThenReturnsSuccess() ), It.IsAny())); } - [Fact] - public async Task WhenInitiatePasswordRequestAndUnknownEmailAddress_ThenSendsCourtesyNotification() - { - _repository.Setup(s => s.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional.None); - - var result = - await _application.InitiatePasswordResetAsync(_caller.Object, "user@company.com", CancellationToken.None); - - result.Should().BeSuccess(); - _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), - Times.Never); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetUnknownUserCourtesyAsync(_caller.Object, "user@company.com", - UserNotificationConstants.EmailTags.PasswordResetUnknownUser, CancellationToken.None)); - } - - [Fact] - public async Task WhenInitiatePasswordRequest_ThenSendsNotification() - { - _tokensService.Setup(ts => ts.CreatePasswordResetToken()) - .Returns(TestingToken); - _repository.Setup(s => s.FindCredentialsByUsernameAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(CreateVerifiedCredential().ToOptional()); - - var result = - await _application.InitiatePasswordResetAsync(_caller.Object, "user@company.com", CancellationToken.None); - - result.Should().BeSuccess(); - _repository.Verify(s => s.SaveAsync(It.Is(cred => - cred.IsPasswordSet - ), It.IsAny())); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetInitiatedAsync(_caller.Object, "aname", "user@company.com", TestingToken, - UserNotificationConstants.EmailTags.PasswordResetInitiated, It.IsAny())); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetUnknownUserCourtesyAsync(It.IsAny(), "user@company.com", - It.IsAny>(), CancellationToken.None), Times.Never); - } - - [Fact] - public async Task WhenResendPasswordRequestAndUnknownToken_ThenReturnsError() - { - _repository.Setup(s => - s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional.None); - - var result = - await _application.ResendPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); - - result.Should().BeError(ErrorCode.EntityNotFound); - _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), - Times.Never); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task WhenResendPasswordRequest_ThenResendsNotification() - { - _tokensService.Setup(ts => ts.CreatePasswordResetToken()) - .Returns(TestingToken); - _repository.Setup(s => - s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(CreateVerifiedCredential().ToOptional()); - - var result = - await _application.ResendPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); - - result.Should().BeSuccess(); - _repository.Verify(s => s.SaveAsync(It.Is(cred => - cred.IsPasswordResetInitiated - ), It.IsAny())); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetInitiatedAsync(_caller.Object, "aname", "auser@company.com", TestingToken, - UserNotificationConstants.EmailTags.PasswordResetResend, It.IsAny())); - } - - [Fact] - public async Task WhenVerifyPasswordResetAsyncAndUnknownToken_ThenReturnsError() - { - _repository.Setup(s => - s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional.None); - - var result = - await _application.VerifyPasswordResetAsync(_caller.Object, "atoken", CancellationToken.None); - - result.Should().BeError(ErrorCode.EntityNotFound); - _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), - Times.Never); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task WhenVerifyPasswordResetAsync_ThenVerifies() - { - _tokensService.Setup(ts => ts.CreatePasswordResetToken()) - .Returns(TestingToken); - var credential = CreateVerifiedCredential(); - credential.InitiatePasswordReset(); - _repository.Setup(s => - s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(credential.ToOptional()); - - var result = - await _application.VerifyPasswordResetAsync(_caller.Object, TestingToken, CancellationToken.None); - - result.Should().BeSuccess(); - _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task WhenCompletePasswordResetAsyncAndUnknownToken_ThenReturnsError() - { - _repository.Setup(s => - s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional.None); - - var result = - await _application.CompletePasswordResetAsync(_caller.Object, "atoken", "apassword", - CancellationToken.None); - - result.Should().BeError(ErrorCode.EntityNotFound); - _repository.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), - Times.Never); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task WhenCompletePasswordResetAsync_ThenCompletes() - { - _tokensService.Setup(ts => ts.CreatePasswordResetToken()) - .Returns(TestingToken); - _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) - .Returns(false); - - var credential = CreateVerifiedCredential(); - credential.InitiatePasswordReset(); - _repository.Setup(s => - s.FindCredentialsByPasswordResetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(credential.ToOptional()); - - var result = - await _application.CompletePasswordResetAsync(_caller.Object, TestingToken, "2Password!", - CancellationToken.None); - - result.Should().BeSuccess(); - _repository.Verify(s => s.SaveAsync(It.Is(creds => - !creds.IsPasswordResetInitiated - ), It.IsAny())); - _notificationsService.Verify(ns => - ns.NotifyPasswordResetInitiatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); - } - private PasswordCredentialRoot CreateUnVerifiedCredential() { var credential = CreateCredential(); @@ -680,7 +601,8 @@ private PasswordCredentialRoot CreateVerifiedCredential() private PasswordCredentialRoot CreateCredential() { return PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, _settings.Object, - _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object, - "auserid".ToId()).Value; + _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId()).Value; } } \ No newline at end of file diff --git a/src/IdentityApplication/IPasswordCredentialsApplication.Mfa.cs b/src/IdentityApplication/IPasswordCredentialsApplication.Mfa.cs new file mode 100644 index 00000000..af54d0a0 --- /dev/null +++ b/src/IdentityApplication/IPasswordCredentialsApplication.Mfa.cs @@ -0,0 +1,36 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication; + +partial interface IPasswordCredentialsApplication +{ + Task> AssociateMfaAuthenticatorAsync( + ICallerContext caller, string? mfaToken, PasswordCredentialMfaAuthenticatorType authenticatorType, + string? phoneNumber, CancellationToken cancellationToken); + + Task> ChallengeMfaAuthenticatorAsync(ICallerContext caller, + string mfaToken, string authenticatorId, CancellationToken cancellationToken); + + Task> ChangeMfaAsync(ICallerContext caller, bool isEnabled, + CancellationToken cancellationToken); + + Task> ConfirmMfaAuthenticatorAssociationAsync( + ICallerContext caller, + string? mfaToken, PasswordCredentialMfaAuthenticatorType authenticatorType, string? oobCode, + string confirmationCode, CancellationToken cancellationToken); + + Task> DisassociateMfaAuthenticatorAsync(ICallerContext caller, string authenticatorId, + CancellationToken cancellationToken); + + Task, Error>> ListMfaAuthenticatorsAsync(ICallerContext caller, + string? mfaToken, CancellationToken cancellationToken); + + Task> ResetPasswordMfaAsync(ICallerContext caller, string userId, + CancellationToken cancellationToken); + + Task> VerifyMfaAuthenticatorAsync(ICallerContext caller, string mfaToken, + PasswordCredentialMfaAuthenticatorType authenticatorType, string? oobCode, string confirmationCode, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/IPasswordCredentialsApplication.PasswordReset.cs b/src/IdentityApplication/IPasswordCredentialsApplication.PasswordReset.cs new file mode 100644 index 00000000..178f58bc --- /dev/null +++ b/src/IdentityApplication/IPasswordCredentialsApplication.PasswordReset.cs @@ -0,0 +1,19 @@ +using Application.Interfaces; +using Common; + +namespace IdentityApplication; + +public partial interface IPasswordCredentialsApplication +{ + Task> CompletePasswordResetAsync(ICallerContext caller, string token, string password, + CancellationToken cancellationToken); + + Task> InitiatePasswordResetAsync(ICallerContext caller, string emailAddress, + CancellationToken cancellationToken); + + Task> ResendPasswordResetAsync(ICallerContext caller, string token, + CancellationToken cancellationToken); + + Task> VerifyPasswordResetAsync(ICallerContext caller, string token, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/IPasswordCredentialsApplication.cs b/src/IdentityApplication/IPasswordCredentialsApplication.cs index 6e9bd2f2..3ecb2091 100644 --- a/src/IdentityApplication/IPasswordCredentialsApplication.cs +++ b/src/IdentityApplication/IPasswordCredentialsApplication.cs @@ -4,14 +4,11 @@ namespace IdentityApplication; -public interface IPasswordCredentialsApplication +public partial interface IPasswordCredentialsApplication { Task> AuthenticateAsync(ICallerContext caller, string username, string password, CancellationToken cancellationToken); - Task> CompletePasswordResetAsync(ICallerContext caller, string token, string password, - CancellationToken cancellationToken); - Task> ConfirmPersonRegistrationAsync(ICallerContext caller, string token, CancellationToken cancellationToken); @@ -20,17 +17,7 @@ Task> GetPersonRegistrationConfirm string userId, CancellationToken cancellationToken); #endif - Task> InitiatePasswordResetAsync(ICallerContext caller, string emailAddress, - CancellationToken cancellationToken); - Task> RegisterPersonAsync(ICallerContext caller, string? invitationToken, - string firstName, - string lastName, string emailAddress, string password, string? timezone, string? countryCode, + string firstName, string lastName, string emailAddress, string password, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken); - - Task> ResendPasswordResetAsync(ICallerContext caller, string token, - CancellationToken cancellationToken); - - Task> VerifyPasswordResetAsync(ICallerContext caller, string token, CancellationToken - cancellationToken); } \ No newline at end of file diff --git a/src/IdentityApplication/PasswordCredentialsApplication.Mfa.cs b/src/IdentityApplication/PasswordCredentialsApplication.Mfa.cs index 0d14379a..bb06458e 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.Mfa.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.Mfa.cs @@ -6,6 +6,7 @@ using Common; using Common.Extensions; using Domain.Common.ValueObjects; +using Domain.Services.Shared; using Domain.Shared; using Domain.Shared.Identities; using IdentityDomain; @@ -17,19 +18,22 @@ partial class PasswordCredentialsApplication public const string MfaRequiredCode = "mfa_required"; public const string MfaTokenName = "MfaToken"; - public async Task> AssociateMfaAuthenticatorAsync( + public async Task> AssociateMfaAuthenticatorAsync( ICallerContext caller, string? mfaToken, PasswordCredentialMfaAuthenticatorType authenticatorType, string? phoneNumber, CancellationToken cancellationToken) { - var authenticated = await AuthenticateUserForMfaInternalAsync(caller, mfaToken, cancellationToken); + var authenticated = + await AuthenticateUserForMfaInternalAsync(caller, mfaToken, MfaPermittedAccessibility.Both, + cancellationToken); if (authenticated.IsFailure) { return authenticated.Error; } - var credential = authenticated.Value; - var callerId = authenticated.Value.UserId; - var retrievedUser = await _endUsersService.GetUserPrivateAsync(caller, callerId, cancellationToken); + var credential = authenticated.Value.Credential; + var mfaCaller = authenticated.Value.Caller; + var userId = mfaCaller.CallerId; + var retrievedUser = await _endUsersService.GetUserPrivateAsync(caller, userId, cancellationToken); if (retrievedUser.IsFailure) { return retrievedUser.Error; @@ -43,7 +47,7 @@ public async Task> A var maintenance = Caller.CreateAsMaintenance(caller.CallId); var retrievedProfile = - await _userProfilesService.GetProfilePrivateAsync(maintenance, callerId, cancellationToken); + await _userProfilesService.GetProfilePrivateAsync(maintenance, userId, cancellationToken); if (retrievedProfile.IsFailure) { return retrievedProfile.Error; @@ -52,21 +56,25 @@ public async Task> A var userProfile = retrievedProfile.Value; var oobPhoneNumber = DerivePhoneNumber(phoneNumber, userProfile); var oobEmailAddress = DeriveEmailAddress(userProfile); - var associated = await credential.AssociateMfaAuthenticatorAsync(callerId, + var otpUsername = oobEmailAddress; + var associated = await credential.AssociateMfaAuthenticatorAsync(mfaCaller, authenticatorType.ToEnumOrDefault(MfaAuthenticatorType.None), oobPhoneNumber, oobEmailAddress.Value, - OnAssociate); + otpUsername.Value, + challengedAuthenticator => NotifyUser(caller, challengedAuthenticator, cancellationToken)); if (associated.IsFailure) { return associated.Error; } - var authenticator = associated.Value; var saved = await _repository.SaveAsync(credential, cancellationToken); if (saved.IsFailure) { return saved.Error; } + credential = saved.Value; + var authenticator = associated.Value; + var isFirstAuthenticator = credential.MfaAuthenticators.HasOnlyOneUnconfirmedPlusRecoveryCodes; _recorder.TraceInformation(caller.ToCall(), "Password credentials for {UserId} is associating MFA authenticator {AuthenticatorType}", credential.UserId, authenticatorType); @@ -78,28 +86,7 @@ public async Task> A { UsageConstants.Properties.MfaAuthenticatorType, authenticatorType } }); - return credential.ToAssociatedAuthenticator(authenticator); - - async Task> OnAssociate(MfaAuthenticator associatedAuthenticator) - { - await Task.CompletedTask; - - switch (associatedAuthenticator.Type.Value) - { - case MfaAuthenticatorType.OobSms: - return await _userNotificationsService.NotifyPasswordMfaOobSmsAsync(caller, - associatedAuthenticator.OobChannelValue, associatedAuthenticator.OobCode, - UserNotificationConstants.EmailTags.PasswordMfaOob, cancellationToken); - - case MfaAuthenticatorType.OobEmail: - return await _userNotificationsService.NotifyPasswordMfaOobEmailAsync(caller, - associatedAuthenticator.OobChannelValue, associatedAuthenticator.OobCode, - UserNotificationConstants.EmailTags.PasswordMfaOob, cancellationToken); - - default: - return Result.Ok; - } - } + return credential.ToAssociatedAuthenticator(authenticator, isFirstAuthenticator, _encryptionService); static Optional DerivePhoneNumber(string? phoneNumber, UserProfile userProfile) { @@ -145,6 +132,48 @@ static Optional DeriveEmailAddress(UserProfile userProfile) } } + public async Task> ChallengeMfaAuthenticatorAsync( + ICallerContext caller, + string mfaToken, string authenticatorId, CancellationToken cancellationToken) + { + var authenticated = await AuthenticateUserForMfaInternalAsync(caller, mfaToken, + MfaPermittedAccessibility.UnauthenticatedOnly, cancellationToken); + if (authenticated.IsFailure) + { + return authenticated.Error; + } + + var credential = authenticated.Value.Credential; + var mfaCaller = authenticated.Value.Caller; + var challenged = await credential.ChallengeMfaAuthenticatorAsync(mfaCaller, authenticatorId.ToId(), + challengedAuthenticator => NotifyUser(caller, challengedAuthenticator, cancellationToken)); + if (challenged.IsFailure) + { + return challenged.Error; + } + + var saved = await _repository.SaveAsync(credential, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + credential = saved.Value; + var authenticator = challenged.Value; + _recorder.TraceInformation(caller.ToCall(), + "Password credentials for {UserId} is challenging MFA authenticator {AuthenticatorType}", + credential.UserId, authenticator.Type); + _recorder.TrackUsage(caller.ToCall(), + UsageConstants.Events.UsageScenarios.Generic.UserPasswordMfaChallenge, + new Dictionary + { + { nameof(credential.Id), credential.UserId }, + { UsageConstants.Properties.MfaAuthenticatorType, authenticator.Type } + }); + + return authenticator.ToChallengedAuthenticator(); + } + public async Task> ChangeMfaAsync(ICallerContext caller, bool isEnabled, CancellationToken cancellationToken) { @@ -160,7 +189,7 @@ public async Task> ChangeMfaAsync(ICallerConte return Error.EntityNotFound(); } - var retrievedUser = await _endUsersService.GetUserPrivateAsync(caller, caller.ToCallerId(), cancellationToken); + var retrievedUser = await _endUsersService.GetUserPrivateAsync(caller, caller.CallerId, cancellationToken); if (retrievedUser.IsFailure) { return retrievedUser.Error; @@ -198,22 +227,33 @@ public async Task> ChangeMfaAsync(ICallerConte return credential.ToCredential(user); } - public async Task> CompleteMfaAuthenticatorAssociationAsync(ICallerContext caller, + public async Task> ConfirmMfaAuthenticatorAssociationAsync( + ICallerContext caller, string? mfaToken, PasswordCredentialMfaAuthenticatorType authenticatorType, string? oobCode, - string completionCode, CancellationToken cancellationToken) + string confirmationCode, CancellationToken cancellationToken) { - var authenticated = await AuthenticateUserForMfaInternalAsync(caller, mfaToken, cancellationToken); + var authenticated = + await AuthenticateUserForMfaInternalAsync(caller, mfaToken, MfaPermittedAccessibility.Both, + cancellationToken); if (authenticated.IsFailure) { return authenticated.Error; } - var credential = authenticated.Value; - var completed = credential.CompleteMfaAuthenticatorAssociation(caller.ToCallerId(), - authenticatorType.ToEnumOrDefault(MfaAuthenticatorType.None), oobCode, completionCode); - if (completed.IsFailure) + var credential = authenticated.Value.Credential; + var mfaCaller = authenticated.Value.Caller; + var confirmed = credential.ConfirmMfaAuthenticatorAssociation(mfaCaller, + authenticatorType.ToEnumOrDefault(MfaAuthenticatorType.None), oobCode, confirmationCode); + if (confirmed.IsFailure) { - return completed.Error; + if (confirmed.Error.Code == ErrorCode.NotAuthenticated) + { + _recorder.AuditAgainst(caller.ToCall(), credential.UserId, + Audits.PasswordCredentialsApplication_MfaAuthenticate_Failed, + "User {Id} failed to authenticate with invalid 2FA", credential.UserId); + } + + return confirmed.Error; } var saved = await _repository.SaveAsync(credential, cancellationToken); @@ -224,16 +264,29 @@ public async Task> CompleteMfaAuthenticatorAss credential = saved.Value; _recorder.TraceInformation(caller.ToCall(), - "Password credentials for {UserId} has completed association for MFA authenticator {AuthenticatorType}", + "Password credentials for {UserId} has successfully authenticated for MFA authenticator {AuthenticatorType}", credential.UserId, authenticatorType); + _recorder.AuditAgainst(caller.ToCall(), credential.UserId, + Audits.PasswordCredentialsApplication_MfaAuthenticate_Succeeded, + "User {Id} succeeded to authenticate with MFA factor {AuthenticatorType}", credential.UserId, + authenticatorType); _recorder.TrackUsage(caller.ToCall(), - UsageConstants.Events.UsageScenarios.Generic.UserPasswordMfaAssociationCompleted, + UsageConstants.Events.UsageScenarios.Generic.UserPasswordMfaAuthenticated, new Dictionary { { nameof(credential.Id), credential.UserId }, { UsageConstants.Properties.MfaAuthenticatorType, authenticatorType } }); + if (caller.IsAuthenticated) + { + return new PasswordCredentialMfaConfirmation + { + Tokens = null, + Authenticators = credential.ToMfaAuthenticators() + }; + } + var retrievedUser = await _endUsersService.GetMembershipsPrivateAsync(caller, credential.UserId, cancellationToken); if (retrievedUser.IsFailure) @@ -242,26 +295,32 @@ public async Task> CompleteMfaAuthenticatorAss } var user = retrievedUser.Value; - return await IssueAuthenticationTokensAsync(caller, user, cancellationToken); + var tokens = await IssueAuthenticationTokensAsync(caller, user, cancellationToken); + if (tokens.IsFailure) + { + return tokens.Error; + } + + return new PasswordCredentialMfaConfirmation + { + Tokens = tokens.Value, + Authenticators = credential.ToMfaAuthenticators() + }; } public async Task> DisassociateMfaAuthenticatorAsync(ICallerContext caller, string authenticatorId, CancellationToken cancellationToken) { - var retrieved = - await _repository.FindCredentialsByUserIdAsync(caller.ToCallerId(), cancellationToken); - if (retrieved.IsFailure) - { - return retrieved.Error; - } - - if (!retrieved.Value.HasValue) + var authenticated = await AuthenticateUserForMfaInternalAsync(caller, null, + MfaPermittedAccessibility.AuthenticatedOnly, cancellationToken); + if (authenticated.IsFailure) { - return Error.EntityNotFound(); + return authenticated.Error; } - var credential = retrieved.Value.Value; - var disassociated = credential.DisassociateMfaAuthenticator(caller.ToCallerId(), authenticatorId.ToId()); + var credential = authenticated.Value.Credential; + var mfaCaller = authenticated.Value.Caller; + var disassociated = credential.DisassociateMfaAuthenticator(mfaCaller, authenticatorId.ToId()); if (disassociated.IsFailure) { return disassociated.Error; @@ -290,13 +349,22 @@ public async Task> DisassociateMfaAuthenticatorAsync(ICallerContex public async Task, Error>> ListMfaAuthenticatorsAsync( ICallerContext caller, string? mfaToken, CancellationToken cancellationToken) { - var authenticated = await AuthenticateUserForMfaInternalAsync(caller, mfaToken, cancellationToken); + var authenticated = + await AuthenticateUserForMfaInternalAsync(caller, mfaToken, MfaPermittedAccessibility.Both, + cancellationToken); if (authenticated.IsFailure) { return authenticated.Error; } - var credential = authenticated.Value; + var credential = authenticated.Value.Credential; + var mfaCaller = authenticated.Value.Caller; + var viewed = credential.ViewMfaAuthenticators(mfaCaller); + if (viewed.IsFailure) + { + return viewed.Error; + } + _recorder.TraceInformation(caller.ToCall(), "Password credentials for {UserId} have had the MFA authenticators retrieved", credential.UserId); @@ -304,77 +372,320 @@ public async Task, Error>> ListM return credential.ToMfaAuthenticators(); } - /// - /// Authenticates the caller for MFA using either the provided MFA token, or caller - /// - /// - /// If the caller is not authenticated, the uMFA token must be provided - /// - private async Task> AuthenticateUserForMfaInternalAsync(ICallerContext caller, - string? mfaToken, + public async Task> ResetPasswordMfaAsync(ICallerContext caller, string userId, CancellationToken cancellationToken) { - if (!caller.IsAuthenticated - && mfaToken.HasNoValue()) + var retrievedCredential = + await _repository.FindCredentialsByUserIdAsync(userId.ToId(), cancellationToken); + if (retrievedCredential.IsFailure) { - return Error.NotAuthenticated(); + return retrievedCredential.Error; } - PasswordCredentialRoot credential; - if (caller.IsAuthenticated) + if (!retrievedCredential.Value.HasValue) { - var retrieved = - await _repository.FindCredentialsByUserIdAsync(caller.ToCallerId(), cancellationToken); - if (retrieved.IsFailure) + return Error.EntityNotFound(); + } + + var retrievedUser = await _endUsersService.GetUserPrivateAsync(caller, userId, cancellationToken); + if (retrievedUser.IsFailure) + { + return retrievedUser.Error; + } + + var user = retrievedUser.Value; + if (user.Classification != EndUserClassification.Person) + { + return Error.PreconditionViolation(Resources.PasswordCredentialsApplication_NotPerson); + } + + var credential = retrievedCredential.Value.Value; + var resetterRoles = Roles.Create(caller.Roles.All); + if (resetterRoles.IsFailure) + { + return resetterRoles.Error; + } + + var reset = credential.ResetMfa(resetterRoles.Value); + if (reset.IsFailure) + { + return reset.Error; + } + + var saved = await _repository.SaveAsync(credential, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Password credentials for {UserId} has had MFA reset by {Operator}", + credential.UserId, caller.CallerId); + _recorder.AuditAgainst(caller.ToCall(), credential.UserId, + Audits.PasswordCredentialsApplication_MfaReset, + "User {Id} had their MFA state reset by {Operator}", credential.UserId, caller.CallerId); + + return credential.ToCredential(user); + } + + public async Task> VerifyMfaAuthenticatorAsync(ICallerContext caller, + string mfaToken, + PasswordCredentialMfaAuthenticatorType authenticatorType, string? oobCode, string confirmationCode, + CancellationToken cancellationToken) + { + var authenticated = await AuthenticateUserForMfaInternalAsync(caller, mfaToken, + MfaPermittedAccessibility.UnauthenticatedOnly, cancellationToken); + if (authenticated.IsFailure) + { + return authenticated.Error; + } + + var credential = authenticated.Value.Credential; + var mfaCaller = authenticated.Value.Caller; + var verified = credential.VerifyMfaAuthenticator(mfaCaller, + authenticatorType.ToEnumOrDefault(MfaAuthenticatorType.None), oobCode, confirmationCode); + if (verified.IsFailure) + { + if (verified.Error.Code == ErrorCode.NotAuthenticated) { - return retrieved.Error; + _recorder.AuditAgainst(caller.ToCall(), credential.UserId, + Audits.PasswordCredentialsApplication_MfaAuthenticate_Failed, + "User {Id} failed to authenticate with invalid 2FA", credential.UserId); } - if (!retrieved.Value.HasValue) + return verified.Error; + } + + var saved = await _repository.SaveAsync(credential, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + credential = saved.Value; + _recorder.TraceInformation(caller.ToCall(), + "Password credentials for {UserId} has successfully authenticated for MFA authenticator {AuthenticatorType}", + credential.UserId, authenticatorType); + _recorder.AuditAgainst(caller.ToCall(), credential.UserId, + Audits.PasswordCredentialsApplication_MfaAuthenticate_Succeeded, + "User {Id} succeeded to authenticate with MFA factor {AuthenticatorType}", credential.UserId, + authenticatorType); + _recorder.TrackUsage(caller.ToCall(), + UsageConstants.Events.UsageScenarios.Generic.UserPasswordMfaAuthenticated, + new Dictionary { - return Error.NotAuthenticated(); - } + { nameof(credential.Id), credential.UserId }, + { UsageConstants.Properties.MfaAuthenticatorType, authenticatorType } + }); - credential = retrieved.Value.Value; + var retrievedUser = + await _endUsersService.GetMembershipsPrivateAsync(caller, credential.UserId, cancellationToken); + if (retrievedUser.IsFailure) + { + return Error.NotAuthenticated(); } - else + + var user = retrievedUser.Value; + return await IssueAuthenticationTokensAsync(caller, user, cancellationToken); + } + + private async Task> NotifyUser(ICallerContext caller, MfaAuthenticator authenticator, + CancellationToken cancellationToken) + { + switch (authenticator.Type) { - var retrieved = - await _repository.FindCredentialsByMfaAuthenticationTokenAsync(mfaToken!, cancellationToken); - if (retrieved.IsFailure) + case MfaAuthenticatorType.OobSms: { - return retrieved.Error; + var secret = _encryptionService.Decrypt(authenticator.Secret); + return await _userNotificationsService.NotifyPasswordMfaOobSmsAsync(caller, + authenticator.OobChannelValue, secret, + UserNotificationConstants.EmailTags.PasswordMfaOob, cancellationToken); } - if (!retrieved.Value.HasValue) + case MfaAuthenticatorType.OobEmail: { - return Error.NotAuthenticated(); + var secret = _encryptionService.Decrypt(authenticator.Secret); + return await _userNotificationsService.NotifyPasswordMfaOobEmailAsync(caller, + authenticator.OobChannelValue, secret, + UserNotificationConstants.EmailTags.PasswordMfaOob, cancellationToken); } - credential = retrieved.Value.Value; + default: + return Result.Ok; } + } - var authenticated = credential.MfaAuthenticate(caller.IsAuthenticated, mfaToken!); - if (authenticated.IsFailure) + /// + /// Authenticates the caller for MFA using either the provided MFA token, or caller + /// + /// + /// If the caller is not authenticated, the uMFA token must be provided + /// + private async Task> + AuthenticateUserForMfaInternalAsync(ICallerContext caller, + string? mfaToken, MfaPermittedAccessibility accessibility, CancellationToken cancellationToken) + { + switch (accessibility) { - return authenticated.Error; + case MfaPermittedAccessibility.UnauthenticatedOnly: + { + if (caller.IsAuthenticated) + { + return Error.ForbiddenAccess(); + } + + if (mfaToken.HasNoValue()) + { + return Error.NotAuthenticated(); + } + + var retrieved = + await _repository.FindCredentialsByMfaAuthenticationTokenAsync(mfaToken, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.NotAuthenticated(); + } + + var credential = retrieved.Value.Value; + var mfaCaller = MfaCaller.Create(credential.UserId, mfaToken); + if (mfaCaller.IsFailure) + { + return mfaCaller.Error; + } + + return (credential, mfaCaller.Value); + } + + case MfaPermittedAccessibility.AuthenticatedOnly: + { + if (!caller.IsAuthenticated) + { + return Error.ForbiddenAccess(); + } + + var userId = caller.ToCallerId(); + var retrieved = + await _repository.FindCredentialsByUserIdAsync(userId, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var credential = retrieved.Value.Value; + var mfaCaller = MfaCaller.Create(credential.UserId, null); + if (mfaCaller.IsFailure) + { + return mfaCaller.Error; + } + + return (credential, mfaCaller.Value); + } + + case MfaPermittedAccessibility.Both: + { + if (caller.IsAuthenticated) + { + var userId = caller.ToCallerId(); + var retrieved = + await _repository.FindCredentialsByUserIdAsync(userId, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var credential = retrieved.Value.Value; + var mfaCaller = MfaCaller.Create(credential.UserId, null); + if (mfaCaller.IsFailure) + { + return mfaCaller.Error; + } + + return (credential, mfaCaller.Value); + } + else + { + if (mfaToken.HasNoValue()) + { + return Error.NotAuthenticated(); + } + + var retrieved = + await _repository.FindCredentialsByMfaAuthenticationTokenAsync(mfaToken, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.NotAuthenticated(); + } + + var credential = retrieved.Value.Value; + var mfaCaller = MfaCaller.Create(credential.UserId, mfaToken); + if (mfaCaller.IsFailure) + { + return mfaCaller.Error; + } + + return (credential, mfaCaller.Value); + } + } + + default: + throw new ArgumentOutOfRangeException(nameof(accessibility), accessibility, null); } + } - return credential; + private enum MfaPermittedAccessibility + { + UnauthenticatedOnly = 0, + AuthenticatedOnly = 1, + Both = 2 } } internal static class PasswordCredentialMfaConversionExtensions { - public static AssociatedPasswordCredentialMfaAuthenticator ToAssociatedAuthenticator( - this PasswordCredentialRoot credential, MfaAuthenticator authenticator) + public static PasswordCredentialMfaAssociation ToAssociatedAuthenticator( + this PasswordCredentialRoot credential, MfaAuthenticator authenticator, bool showRecoveryCodes, + IEncryptionService encryptionService) { - return new AssociatedPasswordCredentialMfaAuthenticator + var secret = authenticator.Type == MfaAuthenticatorType.TotpAuthenticator + ? encryptionService.Decrypt(authenticator.Secret) + : null; + return new PasswordCredentialMfaAssociation { - Type = authenticator.Type.Value.ToEnum(), - RecoveryCodes = credential.MfaAuthenticators.GetRecoveryCodes(), + Type = authenticator.Type.ToEnum(), + RecoveryCodes = showRecoveryCodes + ? credential.MfaAuthenticators.ToRecoveryCodes(encryptionService) + : null, BarCodeUri = authenticator.BarCodeUri, - OobCode = authenticator.OobCode + OobCode = authenticator.OobCode, + Secret = secret + }; + } + + public static PasswordCredentialMfaChallenge ToChallengedAuthenticator(this MfaAuthenticator authenticator) + { + return new PasswordCredentialMfaChallenge + { + OobCode = authenticator.OobCode, + Type = authenticator.Type.ToEnum() }; } @@ -384,7 +695,7 @@ public static List ToMfaAuthenticators(this .Select(auth => new PasswordCredentialMfaAuthenticator { Id = auth.Id, - Type = auth.Type.Value.ToEnumOrDefault(PasswordCredentialMfaAuthenticatorType.None), + Type = auth.Type.ToEnumOrDefault(PasswordCredentialMfaAuthenticatorType.None), IsActive = auth.IsActive }) .ToList(); diff --git a/src/IdentityApplication/PasswordCredentialsApplication.PasswordReset.cs b/src/IdentityApplication/PasswordCredentialsApplication.PasswordReset.cs new file mode 100644 index 00000000..0671902b --- /dev/null +++ b/src/IdentityApplication/PasswordCredentialsApplication.PasswordReset.cs @@ -0,0 +1,171 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; + +namespace IdentityApplication; + +partial class PasswordCredentialsApplication +{ + public async Task> InitiatePasswordResetAsync(ICallerContext caller, string emailAddress, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindCredentialsByUsernameAsync(emailAddress, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + var warned = + await _userNotificationsService.NotifyPasswordResetUnknownUserCourtesyAsync(caller, emailAddress, + UserNotificationConstants.EmailTags.PasswordResetUnknownUser, cancellationToken); + if (warned.IsFailure) + { + return warned.Error; + } + + return Result.Ok; + } + + var credentials = retrieved.Value.Value; + var initiated = credentials.InitiatePasswordReset(); + if (initiated.IsFailure) + { + return initiated.Error; + } + + var registration = credentials.Registration.Value; + var notified = await _userNotificationsService.NotifyPasswordResetInitiatedAsync(caller, registration.Name, + emailAddress, credentials.Password.ResetToken, UserNotificationConstants.EmailTags.PasswordResetInitiated, + cancellationToken); + if (notified.IsFailure) + { + return notified.Error; + } + + var saved = await _repository.SaveAsync(credentials, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + credentials = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Password reset initiated for {Id}", credentials.UserId); + _recorder.TrackUsage(caller.ToCall(), UsageConstants.Events.UsageScenarios.Generic.UserPasswordForgotten, + new Dictionary + { + { nameof(PasswordCredential.Id), credentials.UserId } + }); + + return Result.Ok; + } + + public async Task> ResendPasswordResetAsync(ICallerContext caller, string token, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var credentials = retrieved.Value.Value; + var initiated = credentials.InitiatePasswordReset(); + if (initiated.IsFailure) + { + return initiated.Error; + } + + var registration = credentials.Registration.Value; + var notified = await _userNotificationsService.NotifyPasswordResetInitiatedAsync(caller, registration.Name, + registration.EmailAddress, credentials.Password.ResetToken, + UserNotificationConstants.EmailTags.PasswordResetResend, cancellationToken); + if (notified.IsFailure) + { + return notified.Error; + } + + var saved = await _repository.SaveAsync(credentials, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + credentials = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Password reset re-initiated for {Id}", credentials.UserId); + + return Result.Ok; + } + + public async Task> VerifyPasswordResetAsync(ICallerContext caller, string token, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var credentials = retrieved.Value.Value; + var verified = credentials.VerifyPasswordReset(token); + if (verified.IsFailure) + { + return verified.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Password reset verified for {Id}", credentials.UserId); + + return Result.Ok; + } + + public async Task> CompletePasswordResetAsync(ICallerContext caller, string token, string password, + CancellationToken cancellationToken) + { + var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var credentials = retrieved.Value.Value; + var reset = credentials.CompletePasswordReset(token, password); + if (reset.IsFailure) + { + return reset.Error; + } + + var saved = await _repository.SaveAsync(credentials, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + credentials = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Password was reset for {Id}", credentials.UserId); + _recorder.TrackUsage(caller.ToCall(), UsageConstants.Events.UsageScenarios.Generic.UserPasswordReset, + new Dictionary + { + { nameof(credentials.Id), credentials.UserId } + }); + + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs index 3a6e23fd..5dd16e27 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -18,7 +18,7 @@ namespace IdentityApplication; -public class PasswordCredentialsApplication : IPasswordCredentialsApplication +public partial class PasswordCredentialsApplication : IPasswordCredentialsApplication { public const string ProviderName = "credentials"; #if TESTINGONLY @@ -34,6 +34,7 @@ public class PasswordCredentialsApplication : IPasswordCredentialsApplication private readonly IEmailAddressService _emailAddressService; private readonly ITokensService _tokensService; private readonly IPasswordHasherService _passwordHasherService; + private readonly IMfaService _mfaService; private readonly IAuthTokensService _authTokensService; private readonly IWebsiteUiService _websiteUiService; private readonly IRecorder _recorder; @@ -41,17 +42,18 @@ public class PasswordCredentialsApplication : IPasswordCredentialsApplication private readonly IPasswordCredentialsRepository _repository; private readonly IDelayGenerator _delayGenerator; private readonly IUserProfilesService _userProfilesService; + private readonly IEncryptionService _encryptionService; public PasswordCredentialsApplication(IRecorder recorder, IIdentifierFactory identifierFactory, IEndUsersService endUsersService, IUserProfilesService userProfilesService, IUserNotificationsService userNotificationsService, IConfigurationSettings settings, - IEmailAddressService emailAddressService, ITokensService tokensService, - IPasswordHasherService passwordHasherService, IAuthTokensService authTokensService, + IEmailAddressService emailAddressService, ITokensService tokensService, IEncryptionService encryptionService, + IPasswordHasherService passwordHasherService, IMfaService mfaService, IAuthTokensService authTokensService, IWebsiteUiService websiteUiService, IPasswordCredentialsRepository repository) : this(recorder, identifierFactory, endUsersService, - userProfilesService, userNotificationsService, settings, emailAddressService, tokensService, - passwordHasherService, authTokensService, websiteUiService, repository, new DelayGenerator()) + userProfilesService, userNotificationsService, settings, emailAddressService, tokensService, encryptionService, + passwordHasherService, mfaService, authTokensService, websiteUiService, repository, new DelayGenerator()) { _recorder = recorder; _endUsersService = endUsersService; @@ -62,8 +64,8 @@ private PasswordCredentialsApplication(IRecorder recorder, IIdentifierFactory id IEndUsersService endUsersService, IUserProfilesService userProfilesService, IUserNotificationsService userNotificationsService, IConfigurationSettings settings, - IEmailAddressService emailAddressService, ITokensService tokensService, - IPasswordHasherService passwordHasherService, IAuthTokensService authTokensService, + IEmailAddressService emailAddressService, ITokensService tokensService, IEncryptionService encryptionService, + IPasswordHasherService passwordHasherService, IMfaService mfaService, IAuthTokensService authTokensService, IWebsiteUiService websiteUiService, IPasswordCredentialsRepository repository, IDelayGenerator delayGenerator) @@ -76,7 +78,9 @@ private PasswordCredentialsApplication(IRecorder recorder, IIdentifierFactory id _settings = settings; _emailAddressService = emailAddressService; _tokensService = tokensService; + _encryptionService = encryptionService; _passwordHasherService = passwordHasherService; + _mfaService = mfaService; _authTokensService = authTokensService; _websiteUiService = websiteUiService; _repository = repository; @@ -99,9 +103,9 @@ public async Task> AuthenticateAsync(ICallerCo return Error.NotAuthenticated(); } - var credentials = retrievedCredentials.Value.Value; + var credential = retrievedCredentials.Value.Value; var retrievedUser = - await _endUsersService.GetMembershipsPrivateAsync(caller, credentials.UserId, cancellationToken); + await _endUsersService.GetMembershipsPrivateAsync(caller, credential.UserId, cancellationToken); if (retrievedUser.IsFailure) { return Error.NotAuthenticated(); @@ -126,7 +130,7 @@ public async Task> AuthenticateAsync(ICallerCo return Error.EntityLocked(Resources.PasswordCredentialsApplication_AccountSuspended); } - if (credentials.IsLocked) + if (credential.IsLocked) { _recorder.AuditAgainst(caller.ToCall(), user.Id, Audits.PasswordCredentialsApplication_Authenticate_AccountLocked, @@ -134,13 +138,21 @@ public async Task> AuthenticateAsync(ICallerCo return Error.EntityLocked(Resources.PasswordCredentialsApplication_AccountLocked); } - var verifyPassword = await VerifyPasswordAsync(); - if (verifyPassword.IsFailure) + var verified = credential.VerifyPassword(password); + if (verified.IsFailure) + { + return verified.Error; + } + + var saved = await _repository.SaveAsync(credential, true, cancellationToken); + if (saved.IsFailure) { - return verifyPassword.Error; + return saved.Error; } - var isVerified = verifyPassword.Value; + _recorder.TraceInformation(caller.ToCall(), "Credentials were verified for {Id}", saved.Value.Id); + credential = saved.Value; + var isVerified = verified.Value; if (!isVerified) { _recorder.AuditAgainst(caller.ToCall(), user.Id, @@ -149,7 +161,7 @@ public async Task> AuthenticateAsync(ICallerCo return Error.NotAuthenticated(); } - if (!credentials.IsVerified) + if (!credential.IsVerified) { _recorder.AuditAgainst(caller.ToCall(), user.Id, Audits.PasswordCredentialsApplication_Authenticate_BeforeVerified, @@ -171,110 +183,34 @@ public async Task> AuthenticateAsync(ICallerCo _recorder.TrackUsageFor(caller.ToCall(), user.Id, UsageConstants.Events.UsageScenarios.Generic.UserLogin, user.ToLoginUserUsage(ProviderName, profile)); - var issued = await _authTokensService.IssueTokensAsync(caller, user, cancellationToken); - if (issued.IsFailure) - { - return issued.Error; - } - - var tokens = issued.Value; - return new Result(new AuthenticateTokens - { - AccessToken = new AuthenticationToken - { - Value = tokens.AccessToken, - ExpiresOn = tokens.AccessTokenExpiresOn, - Type = TokenType.AccessToken - }, - RefreshToken = new AuthenticationToken - { - Value = tokens.RefreshToken, - ExpiresOn = tokens.RefreshTokenExpiresOn, - Type = TokenType.RefreshToken - }, - UserId = user.Id - }); - - async Task> VerifyPasswordAsync() + if (credential.IsMfaEnabled) { - var verify = credentials.VerifyPassword(password); - if (verify.IsFailure) + var initiated = credential.InitiateMfaAuthentication(); + if (initiated.IsFailure) { - return verify.Error; + return initiated.Error; } - var saved1 = await _repository.SaveAsync(credentials, cancellationToken); - if (saved1.IsFailure) + var mfaToken = initiated.Value; + saved = await _repository.SaveAsync(credential, cancellationToken); + if (saved.IsFailure) { - return saved1.Error; + return saved.Error; } - _recorder.TraceInformation(caller.ToCall(), "Credentials were verified for {Id}", saved1.Value.Id); - - return verify.Value; + return Error.ForbiddenAccess(Resources.PasswordCredentialsApplication_MfaRequired, MfaRequiredCode, + new Dictionary + { + { MfaTokenName, mfaToken } + }); } - } - public async Task> InitiatePasswordResetAsync(ICallerContext caller, string emailAddress, - CancellationToken cancellationToken) - { - var retrieved = await _repository.FindCredentialsByUsernameAsync(emailAddress, cancellationToken); - if (retrieved.IsFailure) - { - return retrieved.Error; - } - - if (!retrieved.Value.HasValue) - { - var warned = - await _userNotificationsService.NotifyPasswordResetUnknownUserCourtesyAsync(caller, emailAddress, - UserNotificationConstants.EmailTags.PasswordResetUnknownUser, cancellationToken); - if (warned.IsFailure) - { - return warned.Error; - } - - return Result.Ok; - } - - var credentials = retrieved.Value.Value; - var initiated = credentials.InitiatePasswordReset(); - if (initiated.IsFailure) - { - return initiated.Error; - } - - var registration = credentials.Registration.Value; - var notified = await _userNotificationsService.NotifyPasswordResetInitiatedAsync(caller, registration.Name, - emailAddress, credentials.Password.ResetToken, UserNotificationConstants.EmailTags.PasswordResetInitiated, - cancellationToken); - if (notified.IsFailure) - { - return notified.Error; - } - - var saved = await _repository.SaveAsync(credentials, cancellationToken); - if (saved.IsFailure) - { - return saved.Error; - } - - credentials = saved.Value; - _recorder.TraceInformation(caller.ToCall(), "Password reset initiated for {Id}", credentials.UserId); - _recorder.TrackUsage(caller.ToCall(), UsageConstants.Events.UsageScenarios.Generic.UserPasswordForgotten, - new Dictionary - { - { nameof(PasswordCredential.Id), credentials.UserId } - }); - - return Result.Ok; + return await IssueAuthenticationTokensAsync(caller, user, cancellationToken); } public async Task> RegisterPersonAsync(ICallerContext caller, - string? invitationToken, string firstName, - string lastName, string emailAddress, string password, string? timezone, string? countryCode, - bool termsAndConditionsAccepted, - CancellationToken cancellationToken) + string? invitationToken, string firstName, string lastName, string emailAddress, string password, + string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) { var registered = await _endUsersService.RegisterPersonPrivateAsync(caller, invitationToken, emailAddress, firstName, lastName, timezone, countryCode, termsAndConditionsAccepted, cancellationToken); @@ -287,112 +223,6 @@ public async Task> RegisterPersonAsync(ICaller cancellationToken); } - public async Task> ResendPasswordResetAsync(ICallerContext caller, string token, - CancellationToken cancellationToken) - { - var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); - if (retrieved.IsFailure) - { - return retrieved.Error; - } - - if (!retrieved.Value.HasValue) - { - return Error.EntityNotFound(); - } - - var credentials = retrieved.Value.Value; - var initiated = credentials.InitiatePasswordReset(); - if (initiated.IsFailure) - { - return initiated.Error; - } - - var registration = credentials.Registration.Value; - var notified = await _userNotificationsService.NotifyPasswordResetInitiatedAsync(caller, registration.Name, - registration.EmailAddress, credentials.Password.ResetToken, - UserNotificationConstants.EmailTags.PasswordResetResend, cancellationToken); - if (notified.IsFailure) - { - return notified.Error; - } - - var saved = await _repository.SaveAsync(credentials, cancellationToken); - if (saved.IsFailure) - { - return saved.Error; - } - - credentials = saved.Value; - _recorder.TraceInformation(caller.ToCall(), "Password reset re-initiated for {Id}", credentials.UserId); - - return Result.Ok; - } - - public async Task> VerifyPasswordResetAsync(ICallerContext caller, string token, - CancellationToken cancellationToken) - { - var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); - if (retrieved.IsFailure) - { - return retrieved.Error; - } - - if (!retrieved.Value.HasValue) - { - return Error.EntityNotFound(); - } - - var credentials = retrieved.Value.Value; - var verified = credentials.VerifyPasswordReset(token); - if (verified.IsFailure) - { - return verified.Error; - } - - _recorder.TraceInformation(caller.ToCall(), "Password reset verified for {Id}", credentials.UserId); - - return Result.Ok; - } - - public async Task> CompletePasswordResetAsync(ICallerContext caller, string token, string password, - CancellationToken cancellationToken) - { - var retrieved = await _repository.FindCredentialsByPasswordResetTokenAsync(token, cancellationToken); - if (retrieved.IsFailure) - { - return retrieved.Error; - } - - if (!retrieved.Value.HasValue) - { - return Error.EntityNotFound(); - } - - var credentials = retrieved.Value.Value; - var reset = credentials.CompletePasswordReset(token, password); - if (reset.IsFailure) - { - return reset.Error; - } - - var saved = await _repository.SaveAsync(credentials, cancellationToken); - if (saved.IsFailure) - { - return saved.Error; - } - - credentials = saved.Value; - _recorder.TraceInformation(caller.ToCall(), "Password was reset for {Id}", credentials.UserId); - _recorder.TrackUsage(caller.ToCall(), UsageConstants.Events.UsageScenarios.Generic.UserPasswordReset, - new Dictionary - { - { nameof(credentials.Id), credentials.UserId } - }); - - return Result.Ok; - } - #if TESTINGONLY public async Task> GetPersonRegistrationConfirmationAsync( ICallerContext caller, string userId, CancellationToken cancellationToken) @@ -422,6 +252,7 @@ public async Task> GetPersonRegist Url = _websiteUiService.ConstructPasswordRegistrationConfirmationPageUrl(credential.VerificationKeep.Token) }; } + #endif public async Task> ConfirmPersonRegistrationAsync(ICallerContext caller, string token, @@ -463,6 +294,34 @@ public async Task> ConfirmPersonRegistrationAsync(ICallerContext c return Result.Ok; } + private async Task> IssueAuthenticationTokensAsync(ICallerContext caller, + EndUserWithMemberships user, CancellationToken cancellationToken) + { + var issued = await _authTokensService.IssueTokensAsync(caller, user, cancellationToken); + if (issued.IsFailure) + { + return issued.Error; + } + + var tokens = issued.Value; + return new Result(new AuthenticateTokens + { + AccessToken = new AuthenticationToken + { + Value = tokens.AccessToken, + ExpiresOn = tokens.AccessTokenExpiresOn, + Type = TokenType.AccessToken + }, + RefreshToken = new AuthenticationToken + { + Value = tokens.RefreshToken, + ExpiresOn = tokens.RefreshTokenExpiresOn, + Type = TokenType.RefreshToken + }, + UserId = user.Id + }); + } + private async Task> RegisterPersonInternalAsync(ICallerContext caller, string emailAddress, string password, string displayName, EndUser user, CancellationToken cancellationToken) { @@ -478,7 +337,7 @@ private async Task> RegisterPersonInternalAsyn } var created = PasswordCredentialRoot.Create(_recorder, _identifierFactory, _settings, _emailAddressService, - _tokensService, _passwordHasherService, user.Id.ToId()); + _tokensService, _encryptionService, _passwordHasherService, _mfaService, user.Id.ToId()); if (created.IsFailure) { return created.Error; @@ -558,6 +417,7 @@ public static PasswordCredential ToCredential(this PasswordCredentialRoot creden return new PasswordCredential { Id = credential.Id, + IsMfaEnabled = credential.IsMfaEnabled, User = user }; } diff --git a/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs b/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs index 27f86c80..1b890899 100644 --- a/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs +++ b/src/IdentityApplication/Persistence/IPasswordCredentialsRepository.cs @@ -7,6 +7,9 @@ namespace IdentityApplication.Persistence; public interface IPasswordCredentialsRepository : IApplicationRepository { + Task, Error>> FindCredentialsByMfaAuthenticationTokenAsync(string token, + CancellationToken cancellationToken); + Task, Error>> FindCredentialsByPasswordResetTokenAsync(string token, CancellationToken cancellationToken); @@ -22,4 +25,7 @@ Task, Error>> FindCredentialsByUsernameA Task> SaveAsync(PasswordCredentialRoot credential, CancellationToken cancellationToken); + + Task> SaveAsync(PasswordCredentialRoot credential, bool reload, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/IdentityApplication/Persistence/ReadModels/MfaAuthenticator.cs b/src/IdentityApplication/Persistence/ReadModels/MfaAuthenticator.cs new file mode 100644 index 00000000..2f968d2a --- /dev/null +++ b/src/IdentityApplication/Persistence/ReadModels/MfaAuthenticator.cs @@ -0,0 +1,31 @@ +using Application.Persistence.Common; +using Common; +using Domain.Shared.Identities; +using IdentityDomain; +using QueryAny; + +namespace IdentityApplication.Persistence.ReadModels; + +[EntityName("MfaAuthenticator")] +public class MfaAuthenticator : ReadModelEntity +{ + public Optional BarCodeUri { get; set; } + + public Optional VerifiedState { get; set; } + + public bool IsActive { get; set; } + + public Optional OobChannelValue { get; set; } + + public Optional OobCode { get; set; } + + public Optional PasswordCredentialId { get; set; } + + public Optional Secret { get; set; } + + public MfaAuthenticatorState State { get; set; } + + public MfaAuthenticatorType Type { get; set; } = MfaAuthenticatorType.None; + + public Optional UserId { get; set; } +} \ No newline at end of file diff --git a/src/IdentityApplication/Persistence/ReadModels/PasswordCredential.cs b/src/IdentityApplication/Persistence/ReadModels/PasswordCredential.cs index 753bed5a..262eaec4 100644 --- a/src/IdentityApplication/Persistence/ReadModels/PasswordCredential.cs +++ b/src/IdentityApplication/Persistence/ReadModels/PasswordCredential.cs @@ -9,6 +9,14 @@ public class PasswordCredential : ReadModelEntity { public Optional AccountLocked { get; set; } + public Optional IsMfaEnabled { get; set; } + + public Optional MfaAuthenticationExpiresAt { get; set; } + + public Optional MfaAuthenticationToken { get; set; } + + public Optional MfaCanBeDisabled { get; set; } + public Optional PasswordResetToken { get; set; } public Optional RegistrationVerificationToken { get; set; } diff --git a/src/IdentityApplication/Resources.Designer.cs b/src/IdentityApplication/Resources.Designer.cs index 572091a2..5e027d64 100644 --- a/src/IdentityApplication/Resources.Designer.cs +++ b/src/IdentityApplication/Resources.Designer.cs @@ -113,6 +113,24 @@ internal static string PasswordCredentialsApplication_InvalidUsername { } } + /// + /// Looks up a localized string similar to Authentication requires another factor. + /// + internal static string PasswordCredentialsApplication_MfaRequired { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_MfaRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user account ID is not a person. + /// + internal static string PasswordCredentialsApplication_NotPerson { + get { + return ResourceManager.GetString("PasswordCredentialsApplication_NotPerson", resourceCulture); + } + } + /// /// Looks up a localized string similar to The user account has already been verified. /// diff --git a/src/IdentityApplication/Resources.resx b/src/IdentityApplication/Resources.resx index c079dfcb..df4b8a35 100644 --- a/src/IdentityApplication/Resources.resx +++ b/src/IdentityApplication/Resources.resx @@ -42,6 +42,9 @@ The user account ID is not a valid identifier + + The user account ID is not a person + The user account is suspended @@ -54,4 +57,7 @@ The user account is suspended + + Authentication requires another factor + \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/MfaAuthenticatorSpec.cs b/src/IdentityDomain.UnitTests/MfaAuthenticatorSpec.cs new file mode 100644 index 00000000..e85318bc --- /dev/null +++ b/src/IdentityDomain.UnitTests/MfaAuthenticatorSpec.cs @@ -0,0 +1,804 @@ +using Common; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Services.Shared; +using Domain.Shared; +using Domain.Shared.Identities; +using FluentAssertions; +using IdentityDomain.DomainServices; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class MfaAuthenticatorSpec +{ + private readonly MfaAuthenticator _authenticator; + private readonly Mock _encryptionService; + private readonly Mock _mfaService; + + public MfaAuthenticatorSpec() + { + var recorder = new Mock(); + var idFactory = new Mock(); + idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + _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); + _mfaService = new Mock(); + _mfaService.Setup(ms => ms.GenerateOobCode()) + .Returns("anoobcode"); + _mfaService.Setup(ms => ms.GenerateOobSecret()) + .Returns("anoobsecret"); + _mfaService.Setup(ms => ms.GenerateOtpSecret()) + .Returns("anotpsecret"); + _mfaService.Setup(ms => ms.GenerateOtpBarcodeUri(It.IsAny(), It.IsAny())) + .Returns("anotpbarcodeuri"); + _mfaService.Setup(ms => ms.GetTotpMaxTimeSteps()) + .Returns(3); + + _authenticator = MfaAuthenticator.Create(recorder.Object, idFactory.Object, + _encryptionService.Object, _mfaService.Object, _ => Result.Ok).Value; + } + + [Fact] + public void WhenAdded_ThenAdded() + { + CreateAuthenticator(MfaAuthenticatorType.None); + + _authenticator.RootId.Should().BeSome("anid".ToId()); + _authenticator.UserId.Should().BeSome("auserid".ToId()); + _authenticator.IsActive.Should().BeFalse(); + _authenticator.State.Should().Be(MfaAuthenticatorState.Created); + _authenticator.Type.Should().Be(MfaAuthenticatorType.None); + _authenticator.OobCode.Should().BeNone(); + _authenticator.Secret.Should().BeNone(); + _authenticator.BarCodeUri.Should().BeNone(); + _authenticator.OobChannelValue.Should().BeNone(); + _authenticator.VerifiedState.Should().BeNone(); + _authenticator.HasBeenConfirmed.Should().BeFalse(); + } + + [Fact] + public void WhenAssociateAndAlreadyConfirmed_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.None); +#if TESTINGONLY + _authenticator.TestingOnly_Confirm(); +#endif + + var result = _authenticator.Associate(Optional.None, Optional.None, + Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.MfaAuthenticator_ConfirmAssociation_NotAssociated); + } + + [Fact] + public void WhenAssociateForNone_ThenThrows() + { + CreateAuthenticator(MfaAuthenticatorType.None); + + _authenticator.Invoking(x => x.Associate(Optional.None, Optional.None, + Optional.None, Optional.None)) + .Should().Throw(); + } + + [Fact] + public void WhenAssociateForRecoveryCodesAndNone_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); + + var result = _authenticator.Associate(Optional.None, Optional.None, + Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaAuthenticator_Associate_NoRecoveryCodes); + } + + [Fact] + public void WhenAssociateForRecoveryCodesAndCodes_ThenAssociates() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); + + var result = _authenticator.Associate(Optional.None, Optional.None, + Optional.None, "arecoverycode"); + + result.Should().BeSuccess(); + _authenticator.OobCode.Should().BeNone(); + _authenticator.OobChannelValue.Should().BeNone(); + _authenticator.Secret.Should().BeSome("arecoverycode"); + _encryptionService.Verify(es => es.Encrypt("arecoverycode")); + } + + [Fact] + public void WhenAssociateForOobSmsAndNoPhoneNumber_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + + var result = _authenticator.Associate(Optional.None, Optional.None, + Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaAuthenticator_Associate_OobSms_NoPhoneNumber); + } + + [Fact] + public void WhenAssociateForOobSmsAndPhoneNumber_ThenAssociates() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + + var result = _authenticator.Associate(PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, Optional.None); + + result.Should().BeSuccess(); + _authenticator.IsActive.Should().BeFalse(); + _authenticator.OobCode.Should().BeSome("anoobcode"); + _authenticator.OobChannelValue.Should().BeSome("+6498876986"); + _authenticator.Secret.Should().BeSome("anoobsecret"); + _mfaService.Verify(ms => ms.GenerateOobSecret()); + _mfaService.Verify(ms => ms.GenerateOobCode()); + _encryptionService.Verify(es => es.Encrypt("anoobsecret")); + } + + [Fact] + public void WhenAssociateForOobEmailAndNoEmailAddress_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + + var result = _authenticator.Associate(Optional.None, Optional.None, + Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaAuthenticator_Associate_OobEmail_NoEmailAddress); + } + + [Fact] + public void WhenAssociateForOobEmailAndPhoneNumber_ThenAssociates() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + + var result = _authenticator.Associate(Optional.None, + EmailAddress.Create("auser@company.com").Value, Optional.None, Optional.None); + + result.Should().BeSuccess(); + _authenticator.IsActive.Should().BeFalse(); + _authenticator.OobCode.Should().BeSome("anoobcode"); + _authenticator.OobChannelValue.Should().BeSome("auser@company.com"); + _authenticator.Secret.Should().BeSome("anoobsecret"); + _mfaService.Verify(ms => ms.GenerateOobSecret()); + _mfaService.Verify(ms => ms.GenerateOobCode()); + _encryptionService.Verify(es => es.Encrypt("anoobsecret")); + } + + [Fact] + public void WhenAssociateForTotpAuthenticatorAndNoUsername_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.TotpAuthenticator); + + var result = _authenticator.Associate(Optional.None, Optional.None, + Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaAuthenticator_Associate_OtpAuthenticator_NoUsername); + } + + [Fact] + public void WhenAssociateForTotpAuthenticator_ThenAssociates() + { + CreateAuthenticator(MfaAuthenticatorType.TotpAuthenticator); + + var result = _authenticator.Associate(Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, Optional.None); + + result.Should().BeSuccess(); + _authenticator.IsActive.Should().BeFalse(); + _authenticator.OobCode.Should().BeNone(); + _authenticator.OobChannelValue.Should().BeNone(); + _authenticator.BarCodeUri.Should().BeSome("anotpbarcodeuri"); + _authenticator.Secret.Should().BeSome("anotpsecret"); + _encryptionService.Verify(es => es.Encrypt("anotpsecret")); + _mfaService.Verify(ms => ms.GenerateOtpSecret()); + _mfaService.Verify(ms => ms.GenerateOtpBarcodeUri("auser@company.com", "anotpsecret")); + } + + [Fact] + public void WhenConfirmAssociationAndIsConfirmed_ThenReturnsError() + { +#if TESTINGONLY + _authenticator.TestingOnly_Confirm(); +#endif + + var result = _authenticator.ConfirmAssociation("oobCode", "aconfirmationcode"); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.MfaAuthenticator_ConfirmAssociation_NotAssociated); + } + + [Fact] + public void WhenConfirmAssociationForNoneAuthenticator_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.None); + _authenticator.RaiseChangeEvent(Events.PasswordCredentials.MfaAuthenticatorAssociated("anid".ToId(), + _authenticator, "anoobcode", Optional.None, Optional.None, + Optional.None)); + + var result = _authenticator.ConfirmAssociation("anotheroobcode", "aconfirmationcode"); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.MfaAuthenticator_ConfirmAssociation_InvalidType); + } + + [Fact] + public void WhenConfirmAssociationForRecoveryCodesAuthenticator_ThenConfirms() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); + _authenticator.Associate(Optional.None, Optional.None, Optional.None, + "arecoverycode"); + + var result = _authenticator.ConfirmAssociation("anotheroobcode", "aconfirmationcode"); + + result.Should().BeSuccess(); + _authenticator.IsActive.Should().BeTrue(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("arecoverycode"); + _authenticator.VerifiedState.Should().BeNone(); + } + + [Fact] + public void WhenConfirmAssociationForOobSmsAndOobCodeDoesNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + _authenticator.Associate(PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, Optional.None); + + var result = _authenticator.ConfirmAssociation("anotheroobcode", "aconfirmationcode"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenConfirmAssociationForOobSmsAndConfirmationCodeNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + _authenticator.Associate(PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, Optional.None); + + var result = _authenticator.ConfirmAssociation("anoobcode", "anothersecret"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenConfirmAssociationForOobSms_ThenConfirms() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + _authenticator.Associate(PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, Optional.None); + + var result = _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + result.Should().BeSuccess(); + _authenticator.IsActive.Should().BeTrue(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("anoobsecret"); + _authenticator.VerifiedState.Should().BeNone(); + } + + [Fact] + public void WhenConfirmAssociationForOobEmailAndOobCodeDoesNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + _authenticator.Associate(Optional.None, EmailAddress.Create("auser@company.com").Value, + Optional.None, Optional.None); + + var result = _authenticator.ConfirmAssociation("anotheroobcode", "aconfirmationcode"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenConfirmAssociationForOobEmailAndConfirmationCodeNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + _authenticator.Associate(Optional.None, EmailAddress.Create("auser@company.com").Value, + Optional.None, Optional.None); + + var result = _authenticator.ConfirmAssociation("anoobcode", "anothersecret"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenConfirmAssociationForOobEmail_ThenConfirms() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + _authenticator.Associate(Optional.None, EmailAddress.Create("auser@company.com").Value, + Optional.None, Optional.None); + + var result = _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + result.Should().BeSuccess(); + _authenticator.IsActive.Should().BeTrue(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("anoobsecret"); + _authenticator.VerifiedState.Should().BeNone(); + } + + [Fact] + public void WhenConfirmAssociationForTotpAndNotMatchCode_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.TotpAuthenticator); + _authenticator.Associate(Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, Optional.None); + _mfaService.Setup(ms => ms.VerifyTotp(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(TotpResult.Invalid); + + var result = _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => list.Count == 0), "aconfirmationcode")); + } + + [Fact] + public void WhenConfirmAssociationForTotp_ThenConfirms() + { + CreateAuthenticator(MfaAuthenticatorType.TotpAuthenticator); + _authenticator.Associate(Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, Optional.None); + _mfaService.Setup(ms => ms.VerifyTotp(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(TotpResult.Valid(1)); + + var result = _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + + result.Should().BeSuccess(); + _authenticator.IsActive.Should().BeTrue(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("anotpsecret"); + _authenticator.VerifiedState.Should().Be("1"); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => list.Count == 0), "aconfirmationcode")); + _encryptionService.Verify(es => es.Encrypt("1")); + } + + [Fact] + public void WhenChallengeAndNotConfirmed_ThenReturnsError() + { + var result = _authenticator.Challenge(); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.MfaAuthenticator_Challenge_NotConfirmed); + } + + [Fact] + public void WhenChallengeForNoneThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.None); +#if TESTINGONLY + _authenticator.TestingOnly_Confirm(); +#endif + + var result = _authenticator.Challenge(); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaAuthenticator_Challenge_InvalidAuthenticator); + } + + [Fact] + public void WhenChallengeForRecoveryCodes_ThenDoesNothing() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); +#if TESTINGONLY + _authenticator.TestingOnly_Confirm(); +#endif + + var result = _authenticator.Challenge(); + + result.Should().BeSuccess(); + } + + [Fact] + public void WhenChallengeForTotpAuthenticator_ThenDoesNothing() + { + CreateAuthenticator(MfaAuthenticatorType.TotpAuthenticator); +#if TESTINGONLY + _authenticator.TestingOnly_Confirm(); +#endif + + var result = _authenticator.Challenge(); + + result.Should().BeSuccess(); + } + + [Fact] + public void WhenChallengeForOobSmsAndMissingPhoneNumber_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); +#if TESTINGONLY + _authenticator.TestingOnly_Confirm(); +#endif + + var result = _authenticator.Challenge(); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaAuthenticator_Associate_OobSms_NoPhoneNumber); + } + + [Fact] + public void WhenChallengeForOobSms_ThenChallenges() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + _authenticator.Associate(PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, Optional.None); + _mfaService.Setup(ms => ms.GenerateOobCode()) + .Returns("anewoobcode"); + _mfaService.Setup(ms => ms.GenerateOobSecret()) + .Returns("anewoobsecret"); + _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + var result = _authenticator.Challenge(); + + result.Should().BeSuccess(); + _authenticator.OobCode.Should().Be("anewoobcode"); + _authenticator.BarCodeUri.Should().BeNone(); + _authenticator.Secret.Should().BeSome("anewoobsecret"); + _authenticator.OobChannelValue.Should().BeSome("+6498876986"); + _mfaService.Verify(ms => ms.GenerateOobCode()); + } + + [Fact] + public void WhenChallengeForOobEmailAndMissingEmailAddress_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); +#if TESTINGONLY + _authenticator.TestingOnly_Confirm(); +#endif + + var result = _authenticator.Challenge(); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaAuthenticator_Associate_OobEmail_NoEmailAddress); + } + + [Fact] + public void WhenChallengeForOobEmail_ThenChallenges() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + _authenticator.Associate(Optional.None, EmailAddress.Create("auser@company.com").Value, + Optional.None, Optional.None); + _mfaService.Setup(ms => ms.GenerateOobCode()) + .Returns("anewoobcode"); + _mfaService.Setup(ms => ms.GenerateOobSecret()) + .Returns("anewoobsecret"); + _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + var result = _authenticator.Challenge(); + + result.Should().BeSuccess(); + _authenticator.OobCode.Should().Be("anewoobcode"); + _authenticator.BarCodeUri.Should().BeNone(); + _authenticator.Secret.Should().BeSome("anewoobsecret"); + _authenticator.OobChannelValue.Should().BeSome("auser@company.com"); + _mfaService.Verify(ms => ms.GenerateOobCode()); + } + + [Fact] + public void WhenVerifyAndNotConfirmedAndNotChallenged_ThenReturnsError() + { + var result = _authenticator.Verify(Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.MfaAuthenticator_Verify_NotVerifiable); + } + + [Fact] + public void WhenVerifyForNoneAuthenticator_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.None); +#if TESTINGONLY + _authenticator.TestingOnly_Confirm(); +#endif + + var result = _authenticator.Verify(Optional.None, "aconfirmationcode"); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.MfaAuthenticator_Verify_InvalidType); + } + + [Fact] + public void WhenVerifyForRecoveryCodesAndCodeDoesNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); + _authenticator.Associate(Optional.None, Optional.None, Optional.None, + "acode"); + _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + + var result = _authenticator.Verify(Optional.None, "anothercode"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenVerifyForRecoveryCodes_ThenVerifies() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); + _authenticator.Associate(Optional.None, Optional.None, Optional.None, + "acode"); + _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + + var result = _authenticator.Verify(Optional.None, "acode"); + + result.Should().BeSuccess(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("acode"); + _authenticator.VerifiedState.Should().BeSome("acode"); + _encryptionService.Verify(es => es.Encrypt("acode")); + } + + [Fact] + public void WhenVerifyForRecoveryCodesAgain_ThenVerifiesAndAccumulatesUsedCodes() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); + _authenticator.Associate(Optional.None, Optional.None, Optional.None, + "acode1;acode2"); + _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + _authenticator.Verify(Optional.None, "acode1"); + + var result = _authenticator.Verify(Optional.None, "acode2"); + + result.Should().BeSuccess(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("acode1;acode2"); + _authenticator.VerifiedState.Should().BeSome("acode1;acode2"); + _authenticator.VerifiedState.Should().BeSome("acode1;acode2"); + } + + [Fact] + public void WhenVerifyForRecoveryCodesAgainWithUsedCode_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); + _authenticator.Associate(Optional.None, Optional.None, Optional.None, + "acode1"); + _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + _authenticator.Verify(Optional.None, "acode1"); + + var result = _authenticator.Verify(Optional.None, "acode1"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenVerifyForRecoveryCodesAgainWithAllUsedCodes_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.RecoveryCodes); + _authenticator.Associate(Optional.None, Optional.None, Optional.None, + "acode1;acode2;acode3"); + _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + _authenticator.Verify(Optional.None, "acode1"); + _authenticator.Verify(Optional.None, "acode2"); + _authenticator.Verify(Optional.None, "acode3"); + + var result = _authenticator.Verify(Optional.None, "acode2"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenVerifyForOobSmsAndOobCodeDoesNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + _authenticator.Associate(PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, Optional.None); + _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + var result = _authenticator.Verify("anotheroobcode", "anoobsecret"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenVerifyForOobSmsAndConfirmationCodeNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + _authenticator.Associate(PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, Optional.None); + _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + var result = _authenticator.Verify("anoobcode", "anothersecret"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenVerifyForOobSms_ThenVerifies() + { + CreateAuthenticator(MfaAuthenticatorType.OobSms); + _authenticator.Associate(PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, Optional.None); + _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + var result = _authenticator.Verify("anoobcode", "anoobsecret"); + + result.Should().BeSuccess(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("anoobsecret"); + _authenticator.VerifiedState.Should().BeNone(); + } + + [Fact] + public void WhenVerifyForOobEmailAndOobCodeDoesNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + _authenticator.Associate(Optional.None, EmailAddress.Create("auser@company.com").Value, + Optional.None, Optional.None); + _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + var result = _authenticator.Verify("anotheroobcode", "anoobsecret"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenVerifyForOobEmailAndConfirmationCodeNotMatch_ThenReturnsError() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + _authenticator.Associate(Optional.None, EmailAddress.Create("auser@company.com").Value, + Optional.None, Optional.None); + _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + var result = _authenticator.Verify("anoobcode", "anothersecret"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenVerifyForOobEmail_ThenVerifies() + { + CreateAuthenticator(MfaAuthenticatorType.OobEmail); + _authenticator.Associate(Optional.None, EmailAddress.Create("auser@company.com").Value, + Optional.None, Optional.None); + _authenticator.ConfirmAssociation("anoobcode", "anoobsecret"); + + var result = _authenticator.Verify("anoobcode", "anoobsecret"); + + result.Should().BeSuccess(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("anoobsecret"); + _authenticator.VerifiedState.Should().BeNone(); + } + + [Fact] + public void WhenVerifyForTotp_ThenVerifies() + { + CreateAuthenticator(MfaAuthenticatorType.TotpAuthenticator); + _authenticator.Associate(Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, Optional.None); + _mfaService.SetupSequence(ms => + ms.VerifyTotp(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(TotpResult.Valid(1)) + .Returns(TotpResult.Valid(2)); + _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + + var result = _authenticator.Verify(Optional.None, "aconfirmationcode"); + + result.Should().BeSuccess(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("anotpsecret"); + _authenticator.VerifiedState.Should().BeSome("1;2"); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => list.Count == 0), "aconfirmationcode")); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => + list.Count == 1 + && list[0] == 1 + ), "aconfirmationcode")); + _encryptionService.Verify(es => es.Encrypt("1;2")); + } + + [Fact] + public void WhenVerifyForTotpAgain_ThenVerifiesAndAccumulatesUsedTimeSteps() + { + CreateAuthenticator(MfaAuthenticatorType.TotpAuthenticator); + _authenticator.Associate(Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, Optional.None); + _mfaService.SetupSequence(ms => + ms.VerifyTotp(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(TotpResult.Valid(1)) + .Returns(TotpResult.Valid(2)) + .Returns(TotpResult.Valid(3)); + _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + _authenticator.Verify(Optional.None, "aconfirmationcode"); + + var result = _authenticator.Verify(Optional.None, "aconfirmationcode"); + + result.Should().BeSuccess(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("anotpsecret"); + _authenticator.VerifiedState.Should().BeSome("1;2;3"); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => list.Count == 0), "aconfirmationcode")); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => + list.Count == 1 + && list[0] == 1 + ), "aconfirmationcode")); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => + list.Count == 2 + && list[0] == 1 + && list[1] == 2 + ), "aconfirmationcode")); + _encryptionService.Verify(es => es.Encrypt("1")); + _encryptionService.Verify(es => es.Encrypt("1;2")); + _encryptionService.Verify(es => es.Encrypt("1;2;3")); + } + + [Fact] + public void WhenVerifyForTotpAgainManyTimes_ThenVerifiesAndAccumulatesOnlyLatestUsedTimeSteps() + { + CreateAuthenticator(MfaAuthenticatorType.TotpAuthenticator); + _authenticator.Associate(Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, Optional.None); + _mfaService.SetupSequence(ms => + ms.VerifyTotp(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(TotpResult.Valid(1)) + .Returns(TotpResult.Valid(2)) + .Returns(TotpResult.Valid(3)) + .Returns(TotpResult.Valid(4)) + .Returns(TotpResult.Valid(5)); + _mfaService.Setup(ms => ms.GetTotpMaxTimeSteps()) + .Returns(2); + _authenticator.ConfirmAssociation(Optional.None, "aconfirmationcode"); + _authenticator.Verify(Optional.None, "aconfirmationcode"); + _authenticator.Verify(Optional.None, "aconfirmationcode"); + _authenticator.Verify(Optional.None, "aconfirmationcode"); + + var result = _authenticator.Verify(Optional.None, "aconfirmationcode"); + + result.Should().BeSuccess(); + _authenticator.HasBeenConfirmed.Should().BeTrue(); + _authenticator.Secret.Should().BeSome("anotpsecret"); + _authenticator.VerifiedState.Should().BeSome("4;5"); + _mfaService.Verify(ms => ms.VerifyTotp(It.IsAny(), It.IsAny>(), It.IsAny()), + Times.Exactly(5)); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => list.Count == 0), "aconfirmationcode")); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => + list.Count == 1 + && list[0] == 1 + ), "aconfirmationcode")); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => + list.Count == 2 + && list[0] == 1 + && list[1] == 2 + ), "aconfirmationcode")); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => + list.Count == 2 + && list[0] == 2 + && list[1] == 3 + ), "aconfirmationcode")); + _mfaService.Verify(ms => + ms.VerifyTotp("anotpsecret", It.Is>(list => + list.Count == 2 + && list[0] == 3 + && list[1] == 4 + ), "aconfirmationcode")); + _encryptionService.Verify(es => es.Encrypt("1")); + _encryptionService.Verify(es => es.Encrypt("1;2")); + _encryptionService.Verify(es => es.Encrypt("2;3")); + _encryptionService.Verify(es => es.Encrypt("4;5")); + } + + private void CreateAuthenticator(MfaAuthenticatorType type) + { + _authenticator.RaiseChangeEvent(Events.PasswordCredentials.MfaAuthenticatorAdded("anid".ToId(), + "auserid".ToId(), type, true)); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/MfaCallerSpec.cs b/src/IdentityDomain.UnitTests/MfaCallerSpec.cs new file mode 100644 index 00000000..6dc221fc --- /dev/null +++ b/src/IdentityDomain.UnitTests/MfaCallerSpec.cs @@ -0,0 +1,32 @@ +using Domain.Common.ValueObjects; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class MfaCallerSpec +{ + [Fact] + public void WhenCreateWithNoAuthenticationToken_ThenCreates() + { + var result = MfaCaller.Create("acallerid".ToId(), null); + + result.Should().BeSuccess(); + result.Value.AuthenticationToken.Should().BeNone(); + result.Value.CallerId.Should().Be("acallerid".ToId()); + result.Value.IsAuthenticated.Should().BeTrue(); + } + + [Fact] + public void WhenCreateWithAuthenticationToken_ThenCreates() + { + var result = MfaCaller.Create("acallerid".ToId(), "atoken"); + + result.Should().BeSuccess(); + result.Value.AuthenticationToken.Should().BeSome("atoken"); + result.Value.CallerId.Should().Be("acallerid".ToId()); + result.Value.IsAuthenticated.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/MfaOptionsSpec.cs b/src/IdentityDomain.UnitTests/MfaOptionsSpec.cs new file mode 100644 index 00000000..ddc0add6 --- /dev/null +++ b/src/IdentityDomain.UnitTests/MfaOptionsSpec.cs @@ -0,0 +1,163 @@ +using Common; +using Domain.Common.ValueObjects; +using Domain.Services.Shared; +using FluentAssertions; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class MfaOptionsSpec +{ + private readonly MfaOptions _options; + private readonly Mock _tokensService; + + public MfaOptionsSpec() + { + _tokensService = new Mock(); + _tokensService.Setup(x => x.CreateMfaAuthenticationToken()) + .Returns("anmfatoken"); + _options = MfaOptions.Create(true, true).Value; + } + + [Fact] + public void WhenCreate_ThenCreates() + { + var result = MfaOptions.Create(true, true); + + result.Should().BeSuccess(); + result.Value.IsEnabled.Should().BeTrue(); + result.Value.CanBeDisabled.Should().BeTrue(); + } + + [Fact] + public void WhenEnableWithFalseAndCannotBeDisabled_ThenReturnsError() + { + var options = MfaOptions.Create(false, false).Value; + + var result = options.Enable(false); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_Change_CannotBeEnabled); + } + + [Fact] + public void WhenEnableWithFalseAndCanBeDisabled_ThenReturnsDisabled() + { + var options = MfaOptions.Create(true, true).Value; + + var result = options.Enable(false); + + result.Should().BeSuccess(); + result.Value.IsEnabled.Should().BeFalse(); + result.Value.CanBeDisabled.Should().BeTrue(); + } + + [Fact] + public void WhenEnableWithTrue_ThenReturnsEnabled() + { + var options = MfaOptions.Create(false, true).Value; + + var result = options.Enable(true); + + result.Should().BeSuccess(); + result.Value.IsEnabled.Should().BeTrue(); + result.Value.CanBeDisabled.Should().BeTrue(); + } + + [Fact] + public void WhenInitiateAuthenticationAndNotEnabled_ThenReturnsError() + { + var options = MfaOptions.Create(false, true).Value; + + var result = options.InitiateAuthentication(_tokensService.Object); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_NotEnabled); + } + + [Fact] + public void WhenInitiateAuthentication_ThenInitiates() + { + var result = _options.InitiateAuthentication(_tokensService.Object); + + result.Should().BeSuccess(); + result.Value.IsEnabled.Should().BeTrue(); + result.Value.CanBeDisabled.Should().BeTrue(); + result.Value.AuthenticationTokenExpiresAt.Should() + .BeNear(DateTime.UtcNow.Add(MfaOptions.DefaultAuthenticationTokenExpiry)); + result.Value.AuthenticationToken.Should().Be("anmfatoken"); + } + + [Fact] + public void WhenAuthenticateAndNotEnabled_ThenReturnsError() + { + var options = MfaOptions.Create(false, true).Value; + var caller = MfaCaller.Create("acallerid".ToId(), "atoken").Value; + + var result = options.Authenticate(caller); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_NotEnabled); + } + + [Fact] + public void WhenAuthenticateByTokenAndAuthenticationNotInitiated_ThenReturnsError() + { + var caller = MfaCaller.Create("acallerid".ToId(), "atoken").Value; + + var result = _options.Authenticate(caller); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_AuthenticationNotInitiated); + } + + [Fact] + public void WhenAuthenticateByTokenAndTokenMismatch_ThenReturnsError() + { + var options = MfaOptions.Create(true, true, "atoken", + DateTime.UtcNow.AddSeconds(10)).Value; + var caller = MfaCaller.Create("acallerid".ToId(), "anothertoken").Value; + + var result = options.Authenticate(caller); + + result.Should().BeError(ErrorCode.NotAuthenticated, Resources.MfaOptions_AuthenticationFailed); + } + + [Fact] + public void WhenAuthenticateByTokenAndAuthenticationExpired_ThenReturnsError() + { + var options = MfaOptions.Create(true, true, "atoken", + DateTime.UtcNow.AddSeconds(1)).Value; + var caller = MfaCaller.Create("acallerid".ToId(), "atoken").Value; +#if TESTINGONLY + options.TestingOnly_ExpireAuthentication(); +#endif + + var result = options.Authenticate(caller); + + result.Should().BeError(ErrorCode.NotAuthenticated, Resources.MfaOptions_AuthenticationTokenExpired); + } + + [Fact] + public void WhenAuthenticateByToken_ThenAuthenticated() + { + var options = MfaOptions.Create(true, true, "atoken", + DateTime.UtcNow.AddSeconds(10)).Value; + var caller = MfaCaller.Create("acallerid".ToId(), "atoken").Value; + + var result = options.Authenticate(caller); + + result.Should().BeSuccess(); + } + + [Fact] + public void WhenAuthenticateByAuthenticatedUser_ThenAuthenticated() + { + var options = MfaOptions.Create(true, true, "atoken", + DateTime.UtcNow.AddSeconds(10)).Value; + var caller = MfaCaller.Create("acallerid".ToId(), null).Value; + + var result = options.Authenticate(caller); + + result.Should().BeSuccess(); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs b/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs index a595bef0..1a65265c 100644 --- a/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs +++ b/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs @@ -3,9 +3,11 @@ using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.PasswordCredentials; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Services.Shared; using Domain.Shared; +using Domain.Shared.Identities; using FluentAssertions; using IdentityDomain.DomainServices; using Moq; @@ -20,16 +22,34 @@ public class PasswordCredentialRootSpec private const string Token = "5n6nA42SQrsO1UIgc7lIVebR6_3CmZwcthUEx3nF2sM"; private readonly PasswordCredentialRoot _credential; private readonly Mock _emailAddressService; + private readonly Mock _encryptionService; + private readonly Mock _idFactory; + private readonly Mock _mfaService; private readonly Mock _passwordHasherService; + private readonly Mock _recorder; + private readonly Mock _settings; private readonly Mock _tokensService; public PasswordCredentialRootSpec() { - var recorder = new Mock(); - var idFactory = new Mock(); - idFactory.Setup(idf => idf.Create(It.IsAny())) - .Returns("anid".ToId()); - + _recorder = new Mock(); + _idFactory = new Mock(); + var authenticatorCounter = 0; + _idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns((IIdentifiableEntity entity) => + { + if (entity is MfaAuthenticator) + { + return $"anauthenticatorid{++authenticatorCounter}".ToId(); + } + + return "anid".ToId(); + }); + _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.HashPassword(It.IsAny())) .Returns("apasswordhash"); @@ -37,21 +57,38 @@ public PasswordCredentialRootSpec() .Returns(true); _passwordHasherService.Setup(phs => phs.ValidatePasswordHash(It.IsAny())) .Returns(true); + _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) + .Returns(true); + _mfaService = new Mock(); + _mfaService.Setup(ms => ms.GenerateOobCode()) + .Returns("anoobcode"); + _mfaService.Setup(ms => ms.GenerateOobSecret()) + .Returns("anoobsecret"); + _mfaService.Setup( + ms => ms.VerifyTotp(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(TotpResult.Valid(1)); + _mfaService.Setup(ms => ms.GenerateOtpSecret()) + .Returns("anotpsecret"); + _mfaService.Setup(ms => ms.GenerateOtpBarcodeUri(It.IsAny(), It.IsAny())) + .Returns("abarcodeuri"); _tokensService = new Mock(); _tokensService.Setup(ts => ts.CreateRegistrationVerificationToken()) .Returns("averificationtoken"); - var settings = new Mock(); - settings.Setup(s => s.Platform.GetString(It.IsAny(), It.IsAny())) + _tokensService.Setup(ts => ts.CreateMfaAuthenticationToken()) + .Returns("anmfatoken"); + _settings = new Mock(); + _settings.Setup(s => s.Platform.GetString(It.IsAny(), It.IsAny())) .Returns((string?)null!); - settings.Setup(s => s.Platform.GetNumber(It.IsAny(), It.IsAny())) + _settings.Setup(s => s.Platform.GetNumber(It.IsAny(), It.IsAny())) .Returns(5); _emailAddressService = new Mock(); _emailAddressService.Setup(eas => eas.EnsureUniqueAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); - _credential = PasswordCredentialRoot.Create(recorder.Object, idFactory.Object, - settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object, - "auserid".ToId()).Value; + _credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId()).Value; } [Fact] @@ -61,6 +98,9 @@ public void WhenConstructed_ThenInitialized() _credential.Registration.Should().BeNone(); _credential.Login.IsReset.Should().BeTrue(); _credential.Password.PasswordHash.Should().BeNone(); + _credential.MfaOptions.IsEnabled.Should().BeFalse(); + _credential.MfaOptions.CanBeDisabled.Should().BeTrue(); + _credential.MfaAuthenticators.Should().BeEmpty(); } [Fact] @@ -72,7 +112,7 @@ public void WhenInitiateRegistrationVerificationAndAlreadyVerified_ThenReturnsEr var result = _credential.InitiateRegistrationVerification(); result.Should().BeError(ErrorCode.PreconditionViolation, - Resources.PasswordCredentialsRoot_RegistrationVerified); + Resources.PasswordCredentialRoot_RegistrationVerified); } [Fact] @@ -93,7 +133,7 @@ public void WhenSetCredentialAndInvalidPassword_ThenReturnsError() var result = _credential.SetPasswordCredential("notavalidpassword"); - result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword); + result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialRoot_InvalidPassword); } [Fact] @@ -124,7 +164,7 @@ public void WhenVerifyPasswordWithInvalidPassword_ThenReturnsError() var result = _credential.VerifyPassword("1WrongPassword!"); - result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword); + result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialRoot_InvalidPassword); _credential.Login.FailedPasswordAttempts.Should().Be(0); _credential.Login.IsLocked.Should().BeFalse(); _credential.Login.ToggledLocked.Should().BeFalse(); @@ -153,8 +193,6 @@ public void WhenVerifyPasswordAndWrongPasswordAndAudit_ThenAuditsFailedLogin() [Fact] public void WhenVerifyPasswordAndAndAudit_ThenResetsLoginMonitor() { - _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) - .Returns(true); _credential.SetPasswordCredential("apassword"); var result = _credential.VerifyPassword("apassword"); @@ -217,7 +255,7 @@ public void WhenVerifyRegistrationAndRegistered_ThenReturnsError() var result = _credential.VerifyRegistration(); result.Should().BeError(ErrorCode.PreconditionViolation, - Resources.PasswordCredentialsRoot_RegistrationNotVerifying); + Resources.PasswordCredentialRoot_RegistrationNotVerifying); } [Fact] @@ -231,7 +269,7 @@ public void WhenVerifyRegistrationAndExpired_ThenReturnsError() var result = _credential.VerifyRegistration(); result.Should().BeError(ErrorCode.PreconditionViolation, - Resources.PasswordCredentialsRoot_RegistrationVerifyingExpired); + Resources.PasswordCredentialRoot_RegistrationVerifyingExpired); } [Fact] @@ -269,7 +307,7 @@ public void WhenInitiatePasswordResetAndNotVerified_ThenReturnsError() var result = _credential.InitiatePasswordReset(); result.Should().BeError(ErrorCode.PreconditionViolation, - Resources.PasswordCredentialsRoot_RegistrationUnverified); + Resources.PasswordCredentialRoot_RegistrationUnverified); } [Fact] @@ -299,7 +337,7 @@ public void WhenCompletePasswordResetWithInvalidPassword_ThenReturnsError() var result = _credential.CompletePasswordReset(Token, "apassword"); - result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_InvalidPassword); + result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialRoot_InvalidPassword); _passwordHasherService.Verify(ph => ph.VerifyPassword(It.IsAny(), It.IsAny()), Times.Never); } @@ -312,7 +350,7 @@ public void WhenCompletePasswordResetAndNoExistingPassword_ThenReturnsError() var result = _credential.CompletePasswordReset(Token, "apassword"); - result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialsRoot_NoPassword); + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_NoPassword); _passwordHasherService.Verify(ph => ph.VerifyPassword(It.IsAny(), It.IsAny()), Times.Never); } @@ -322,13 +360,11 @@ public void WhenCompletePasswordResetAndSameAsOldPassword_ThenReturnsError() { _passwordHasherService.Setup(phs => phs.ValidatePassword(It.IsAny(), It.IsAny())) .Returns(true); - _passwordHasherService.Setup(phs => phs.VerifyPassword(It.IsAny(), It.IsAny())) - .Returns(true); _credential.SetPasswordCredential("apassword"); var result = _credential.CompletePasswordReset(Token, "apassword"); - result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialsRoot_DuplicatePassword); + result.Should().BeError(ErrorCode.Validation, Resources.PasswordCredentialRoot_DuplicatePassword); _passwordHasherService.Verify(ph => ph.VerifyPassword("apassword", "apasswordhash")); } @@ -349,7 +385,7 @@ public void WhenCompletePasswordResetAndExpired_ThenReturnsError() var result = _credential.CompletePasswordReset("atoken", "apassword"); result.Should().BeError(ErrorCode.PreconditionViolation, - Resources.PasswordCredentialsRoot_PasswordResetTokenExpired); + Resources.PasswordCredentialRoot_PasswordResetTokenExpired); } [Fact] @@ -413,7 +449,7 @@ public void WhenEnsureInvariantsAndRegisteredButEmailNotUnique_ThenReturnsErrors var result = _credential.EnsureInvariants(); - result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordCredentialsRoot_EmailNotUnique); + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordCredentialRoot_EmailNotUnique); } [Fact] @@ -433,6 +469,1534 @@ public void WhenEnsureInvariantsAndInitiatingPasswordResetButUnRegistered_ThenRe var result = _credential.EnsureInvariants(); result.Should().BeError(ErrorCode.RuleViolation, - Resources.PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration); + Resources.PasswordCredentialRoot_PasswordInitiatedWithoutRegistration); + } + + [Fact] + public void WhenChangeMfaEnabledAndNotOwner_ThenReturnsError() + { + var mfaOptions = MfaOptions.Create(false, true).Value; + var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId(), mfaOptions).Value; + + var result = + credential.ChangeMfaEnabled("amodifierid".ToId(), true); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.PasswordCredentialRoot_NotOwner); + } + + [Fact] + public void WhenChangeMfaEnabledAndNotVerified_ThenReturnsError() + { + var mfaOptions = MfaOptions.Create(true, true).Value; + var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId(), mfaOptions).Value; + credential.InitiateRegistrationVerification(); + + var result = + credential.ChangeMfaEnabled("auserid".ToId(), true); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + [Fact] + public void WhenChangeMfaEnabledAndNoPassword_ThenReturnsError() + { + var mfaOptions = MfaOptions.Create(true, true).Value; + var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId(), mfaOptions).Value; + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + credential.InitiateRegistrationVerification(); + credential.VerifyRegistration(); + + var result = + credential.ChangeMfaEnabled("auserid".ToId(), true); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_NoPassword); + } + + [Fact] + public void WhenChangeMfaEnabledAndAlreadyEnabled_ThenDoesNothing() + { + var mfaOptions = MfaOptions.Create(true, true).Value; + var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId(), mfaOptions).Value; + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + credential.InitiateRegistrationVerification(); + credential.VerifyRegistration(); + credential.SetPasswordCredential("apassword"); + + var result = + credential.ChangeMfaEnabled("auserid".ToId(), true); + + result.Should().BeSuccess(); + credential.MfaOptions.IsEnabled.Should().BeTrue(); + credential.MfaOptions.CanBeDisabled.Should().BeTrue(); + credential.Events.Last().Should().NotBeOfType(); + } + + [Fact] + public void WhenChangeMfaOptionsWithEnabled_ThenEnables() + { + var mfaOptions = MfaOptions.Create(false, true).Value; + var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId(), mfaOptions).Value; + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + credential.InitiateRegistrationVerification(); + credential.VerifyRegistration(); + credential.SetPasswordCredential("apassword"); + + var result = credential.ChangeMfaEnabled("auserid".ToId(), true); + + result.Should().BeSuccess(); + credential.MfaOptions.IsEnabled.Should().BeTrue(); + credential.MfaOptions.CanBeDisabled.Should().BeTrue(); + credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenChangeMfaOptionsWithDisabled_ThenDisables() + { + var mfaOptions = MfaOptions.Create(true, true).Value; + var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId(), mfaOptions).Value; + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + credential.InitiateRegistrationVerification(); + credential.VerifyRegistration(); + credential.SetPasswordCredential("apassword"); + + var result = credential.ChangeMfaEnabled("auserid".ToId(), false); + + result.Should().BeSuccess(); + credential.MfaOptions.IsEnabled.Should().BeFalse(); + credential.MfaOptions.CanBeDisabled.Should().BeTrue(); + credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task + WhenChangeMfaOptionsWithDisabledAndExistingAuthenticators_ThenDissociatesAuthenticatorsAndDisables() + { + var mfaOptions = MfaOptions.Create(true, true).Value; + var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId(), mfaOptions).Value; + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + credential.InitiateRegistrationVerification(); + credential.VerifyRegistration(); + credential.SetPasswordCredential("apassword"); + 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(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, "anoobcode", "anoobsecret"); + + credential.MfaAuthenticators.Count.Should().Be(2); + + var result = credential.ChangeMfaEnabled("auserid".ToId(), false); + + result.Should().BeSuccess(); + credential.MfaOptions.IsEnabled.Should().BeFalse(); + credential.MfaOptions.CanBeDisabled.Should().BeTrue(); + credential.MfaAuthenticators.Count.Should().Be(0); + credential.Events[12].Should().BeOfType(); + credential.Events[13].Should().BeOfType(); + credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenInitiateMfaAuthenticationAndNotVerified_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + + var result = _credential.InitiateMfaAuthentication(); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + [Fact] + public void WhenInitiateMfaAuthenticationAndNoPassword_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = _credential.InitiateMfaAuthentication(); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_NoPassword); + } + + [Fact] + public void WhenInitiateMfaAuthentication_ThenInitiates() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(Token); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + + var result = _credential.InitiateMfaAuthentication(); + + result.Should().BeSuccess(); + result.Value.Should().Be("anmfatoken"); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenDisassociateMfaAuthenticatorAndIsNotOwner_ThenReturnsError() + { + var result = _credential.DisassociateMfaAuthenticator(MfaCaller.Create("anotheruserid".ToId(), null).Value, + "anauthenticatorid".ToId()); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.PasswordCredentialRoot_NotOwner); + } + + [Fact] + public void WhenDisassociateMfaAuthenticatorAndIsNotVerified_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + + var result = _credential.DisassociateMfaAuthenticator(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid".ToId()); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + [Fact] + public void WhenDisassociateMfaAuthenticatorAndNoPassword_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = _credential.DisassociateMfaAuthenticator(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid".ToId()); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_NoPassword); + } + + [Fact] + public void WhenDisassociateMfaAuthenticatorAndMfaNotEnabled_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + + var result = _credential.DisassociateMfaAuthenticator(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid".ToId()); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + [Fact] + public void WhenDisassociateMfaAuthenticatorAndNotExists_ThenReturnsError() + { + _tokensService.Setup(ts => ts.CreateMfaAuthenticationToken()) + .Returns("atoken"); + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(Token); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + + var result = _credential.DisassociateMfaAuthenticator(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid".ToId()); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenDisassociateMfaAuthenticatorAndIsRecoveryCodes_ThenReturnsError() + { + _tokensService.Setup(ts => ts.CreateMfaAuthenticationToken()) + .Returns("anmfatoken"); + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(Token); + _credential.SetPasswordCredential("apassword"); + _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)); + var recoveryCodes = _credential.MfaAuthenticators.FindRecoveryCodes(); + + var result = + _credential.DisassociateMfaAuthenticator(MfaCaller.Create("auserid".ToId(), null).Value, + recoveryCodes.Value.Id); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.PasswordCredentialRoot_DisassociateMfaAuthenticator_RecoveryCodesCannotBeDeleted); + } + + [Fact] + public async Task WhenDisassociateMfaAuthenticatorAndNotLastOne_ThenDeletesAuthenticator() + { + _tokensService.Setup(ts => ts.CreateMfaAuthenticationToken()) + .Returns("anmfatoken"); + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(Token); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + var authenticator1 = (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))).Value; + var authenticator2 = (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))) + .Value; + var recoveryCodes = _credential.MfaAuthenticators.FindRecoveryCodes(); + + var result = + _credential.DisassociateMfaAuthenticator(MfaCaller.Create("auserid".ToId(), null).Value, authenticator2.Id); + + result.Should().BeSuccess(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].Id.Should().Be(recoveryCodes.Value.Id); + _credential.MfaAuthenticators[1].Id.Should().Be(authenticator1.Id); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenDisassociateMfaAuthenticatorAndLastOne_ThenDeletesAuthenticatorAndRecoveryCodes() + { + _tokensService.Setup(ts => ts.CreateMfaAuthenticationToken()) + .Returns("anmfatoken"); + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _tokensService.Setup(ts => ts.CreatePasswordResetToken()) + .Returns(Token); + _credential.SetPasswordCredential("apassword"); + _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))).Value; + + var result = + _credential.DisassociateMfaAuthenticator(MfaCaller.Create("auserid".ToId(), null).Value, authenticator.Id); + + result.Should().BeSuccess(); + _credential.MfaAuthenticators.Count.Should().Be(0); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndIsNotOwner_ThenReturnsError() + { + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("anotheruserid".ToId(), "atoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.PasswordCredentialRoot_NotOwner); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndIsNotVerified_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + + var result = 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)); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndNoPassword_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = 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)); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_NoPassword); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndMfaNotEnabled_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + + var result = 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)); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndNotAuthenticated_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + + var result = 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)); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_AuthenticationNotInitiated); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndTypeIsNone_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.None, Optional.None, Optional.None, + Optional.None, _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndTypeIsRecoveryCodes_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.RecoveryCodes, Optional.None, Optional.None, + Optional.None, _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndConfirmedAuthenticatorAlreadyExists_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, + Optional.None, "aconfirmationcode"); + + var result = 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)); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_AssociateMfaAuthenticator_AlreadyExists); + } + + [Fact] + public async Task + WhenAssociateMfaAuthenticatorByUnauthenticatedUserAndAnotherAuthenticatorIsAlreadyConfirmed_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, + Optional.None, "aconfirmationcode"); + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, Optional.None, Optional.None, + Optional.None, _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.ForbiddenAccess); + } + + [Fact] + public async Task + WhenAssociateMfaAuthenticatorByAuthenticatedUserAndAnotherAuthenticatorIsAlreadyAssociated_ThenAssociatesAuthenticator() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + var authenticator1 = 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(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, + Optional.None, "aconfirmationcode"); + + var result = await _credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value, + MfaAuthenticatorType.OobSms, PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, _ => Task.FromResult(Result.Ok)); + + result.Should().BeSuccess(); + result.Value.Id.Should().Be("anauthenticatorid3".ToId()); + result.Value.Type.Should().Be(MfaAuthenticatorType.OobSms); + result.Value.IsActive.Should().BeFalse(); + result.Value.OobChannelValue.Should().BeSome("+6498876986"); + result.Value.OobCode.Should().BeSome("anoobcode"); + result.Value.BarCodeUri.Should().BeNone(); + result.Value.Secret.Should().BeSome("anoobsecret"); + _credential.Events.Last().Should().BeOfType(); + _credential.MfaAuthenticators.Count.Should().Be(3); + _credential.MfaAuthenticators[0].Id.Should().Be(_credential.MfaAuthenticators.FindRecoveryCodes().Value.Id); + _credential.MfaAuthenticators[1].Id.Should().Be(authenticator1.Value.Id); + _credential.MfaAuthenticators[2].Id.Should().Be(result.Value.Id); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorForOobSmsButNoPhoneNumber_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, Optional.None, Optional.None, + Optional.None, _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaAuthenticator_Associate_OobSms_NoPhoneNumber); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorForOobEmailButNoEmailAddress_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobEmail, Optional.None, Optional.None, + Optional.None, _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaAuthenticator_Associate_OobEmail_NoEmailAddress); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndFirstAuthenticator_ThenAssociatesAuthenticatorAndRecoveryCodes() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + var wasCalled = false; + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, _ => + { + wasCalled = true; + return Task.FromResult(Result.Ok); + }); + + result.Should().BeSuccess(); + wasCalled.Should().BeTrue(); + result.Value.Id.Should().Be("anauthenticatorid2".ToId()); + result.Value.Type.Should().Be(MfaAuthenticatorType.TotpAuthenticator); + result.Value.IsActive.Should().BeFalse(); + result.Value.OobChannelValue.Should().BeNone(); + result.Value.OobCode.Should().BeNone(); + result.Value.BarCodeUri.Should().BeSome("abarcodeuri"); + result.Value.Secret.Should().BeSome("anotpsecret"); + _credential.Events.Last().Should().BeOfType(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].Id.Should().Be(_credential.MfaAuthenticators.FindRecoveryCodes().Value.Id); + _credential.MfaAuthenticators[1].Id.Should().Be(result.Value.Id); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAndNextAuthenticator_ThenAssociatesAuthenticator() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + var authenticator1 = 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)); + var wasCalled = false; + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, _ => + { + wasCalled = true; + return Task.FromResult(Result.Ok); + }); + + result.Should().BeSuccess(); + wasCalled.Should().BeTrue(); + result.Value.Id.Should().Be("anauthenticatorid3".ToId()); + result.Value.Type.Should().Be(MfaAuthenticatorType.OobSms); + result.Value.IsActive.Should().BeFalse(); + result.Value.OobChannelValue.Should().BeSome("+6498876986"); + result.Value.OobCode.Should().BeSome("anoobcode"); + result.Value.BarCodeUri.Should().BeNone(); + result.Value.Secret.Should().BeSome("anoobsecret"); + _credential.Events.Last().Should().BeOfType(); + _credential.MfaAuthenticators.Count.Should().Be(3); + _credential.MfaAuthenticators[0].Id.Should().Be(_credential.MfaAuthenticators.FindRecoveryCodes().Value.Id); + _credential.MfaAuthenticators[1].Id.Should().Be(authenticator1.Value.Id); + _credential.MfaAuthenticators[2].Id.Should().Be(result.Value.Id); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorForTotp_ThenAssociatesAuthenticator() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + var wasCalled = false; + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None, + EmailAddress.Create("auser@company.com").Value, _ => + { + wasCalled = true; + return Task.FromResult(Result.Ok); + }); + + result.Should().BeSuccess(); + wasCalled.Should().BeTrue(); + result.Value.Id.Should().Be("anauthenticatorid2".ToId()); + result.Value.Type.Should().Be(MfaAuthenticatorType.TotpAuthenticator); + result.Value.IsActive.Should().BeFalse(); + result.Value.OobChannelValue.Should().BeNone(); + result.Value.OobCode.Should().BeNone(); + result.Value.BarCodeUri.Should().BeSome("abarcodeuri"); + result.Value.Secret.Should().BeSome("anotpsecret"); + _credential.Events.Last().Should().BeOfType(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].Id.Should().Be(_credential.MfaAuthenticators.FindRecoveryCodes().Value.Id); + _credential.MfaAuthenticators[1].Id.Should().Be(result.Value.Id); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorForOobSms_ThenAssociatesAuthenticator() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + var wasCalled = false; + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, PhoneNumber.Create("+6498876986").Value, Optional.None, + Optional.None, _ => + { + wasCalled = true; + return Task.FromResult(Result.Ok); + }); + + result.Should().BeSuccess(); + wasCalled.Should().BeTrue(); + result.Value.Id.Should().Be("anauthenticatorid2".ToId()); + result.Value.Type.Should().Be(MfaAuthenticatorType.OobSms); + result.Value.IsActive.Should().BeFalse(); + result.Value.OobChannelValue.Should().BeSome("+6498876986"); + result.Value.OobCode.Should().BeSome("anoobcode"); + result.Value.BarCodeUri.Should().BeNone(); + result.Value.Secret.Should().BeSome("anoobsecret"); + _credential.Events.Last().Should().BeOfType(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].Id.Should().Be(_credential.MfaAuthenticators.FindRecoveryCodes().Value.Id); + _credential.MfaAuthenticators[1].Id.Should().Be(result.Value.Id); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorForOobEmail_ThenAssociatesAuthenticator() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + var wasCalled = false; + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobEmail, Optional.None, EmailAddress.Create("auser@company.com").Value, + Optional.None, + _ => + { + wasCalled = true; + return Task.FromResult(Result.Ok); + }); + + result.Should().BeSuccess(); + wasCalled.Should().BeTrue(); + result.Value.Id.Should().Be("anauthenticatorid2".ToId()); + result.Value.Type.Should().Be(MfaAuthenticatorType.OobEmail); + result.Value.IsActive.Should().BeFalse(); + result.Value.OobChannelValue.Should().BeSome("auser@company.com"); + result.Value.OobCode.Should().BeSome("anoobcode"); + result.Value.BarCodeUri.Should().BeNone(); + result.Value.Secret.Should().BeSome("anoobsecret"); + _credential.Events.Last().Should().BeOfType(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].Id.Should().Be(_credential.MfaAuthenticators.FindRecoveryCodes().Value.Id); + _credential.MfaAuthenticators[1].Id.Should().Be(result.Value.Id); + } + + [Fact] + public async Task WhenReAssociateMfaAuthenticatorThenUpdatesAssociation() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + await _credential.AssociateMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, + PhoneNumber.Create("+6498876981").Value, Optional.None, Optional.None, + _ => Task.FromResult(Result.Ok)); + + var result = await _credential.AssociateMfaAuthenticatorAsync( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, PhoneNumber.Create("+6498876982").Value, Optional.None, + Optional.None, _ => Task.FromResult(Result.Ok)); + + result.Should().BeSuccess(); + result.Value.Id.Should().Be("anauthenticatorid2".ToId()); + result.Value.Type.Should().Be(MfaAuthenticatorType.OobSms); + result.Value.IsActive.Should().BeFalse(); + result.Value.OobChannelValue.Should().BeSome("+6498876982"); + result.Value.OobCode.Should().BeSome("anoobcode"); + result.Value.BarCodeUri.Should().BeNone(); + result.Value.Secret.Should().BeSome("anoobsecret"); + _credential.Events.Last().Should().BeOfType(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].Id.Should().Be(_credential.MfaAuthenticators.FindRecoveryCodes().Value.Id); + _credential.MfaAuthenticators[1].Id.Should().Be(result.Value.Id); + } + + [Fact] + public void WhenConfirmMfaAuthenticatorAssociationAndNotOwner_ThenReturnsError() + { + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("anotheruserid".ToId(), null).Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.PasswordCredentialRoot_NotOwner); + } + + [Fact] + public void WhenConfirmMfaAuthenticatorAssociationAndIsNotVerified_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + [Fact] + public void WhenConfirmMfaAuthenticatorAssociationAndNoPassword_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_NoPassword); + } + + [Fact] + public void WhenConfirmMfaAuthenticatorAssociationAndMfaNotEnabled_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + [Fact] + public void WhenConfirmMfaAuthenticatorAssociationAndNotAuthenticated_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.MfaOptions_AuthenticationNotInitiated); + } + + [Fact] + public void WhenConfirmMfaAuthenticatorAssociationForNoneAuthenticator_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.None, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType); + } + + [Fact] + public void WhenConfirmMfaAuthenticatorAssociationForRecoveryCodesAuthenticator_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.RecoveryCodes, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType); + } + + [Fact] + public void WhenConfirmMfaAuthenticatorAssociationAndUnknownAuthenticator_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_CompleteMfaAuthenticatorAssociation_NotFound); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationForOobSms_ThenConfirms() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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)); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, "anoobcode", "anoobsecret"); + + result.Should().BeSuccess(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationForOobEmail_ThenConfirms() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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)); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobEmail, "anoobcode", "anoobsecret"); + + result.Should().BeSuccess(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationForTotpAuthenticator_ThenConfirms() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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)); + + var result = _credential.ConfirmMfaAuthenticatorAssociation( + MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, "aconfirmationcode"); + + result.Should().BeSuccess(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorAsyncAndNotOwner_ThenReturnsError() + { + var result = await _credential.ChallengeMfaAuthenticatorAsync( + MfaCaller.Create("anotheruserid".ToId(), "anmfatoken").Value, + "anauthenticatorid".ToId(), _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.PasswordCredentialRoot_NotOwner); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorAsyncAndIsNotVerified_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + + var result = await _credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid".ToId(), _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorAsyncAndNoPassword_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = await _credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid".ToId(), _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_NoPassword); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorAsyncAndMfaNotEnabled_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + + var result = await _credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid".ToId(), _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorAsyncAndUnknownAuthenticator_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + + var result = await _credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid".ToId(), _ => Task.FromResult(Result.Ok)); + + result.Should().BeError(ErrorCode.EntityNotFound); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorAsyncForOobSms_ThenChallenges() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, "anoobcode", "anoobsecret"); + var wasCalled = false; + + var result = await _credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid2".ToId(), _ => + { + wasCalled = true; + return Task.FromResult(Result.Ok); + }); + + result.Should().BeSuccess(); + wasCalled.Should().BeTrue(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].IsChallenged.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorAsyncForOobEmail_ThenChallenges() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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(), "anmfatoken").Value, + MfaAuthenticatorType.OobEmail, "anoobcode", "anoobsecret"); + var wasCalled = false; + + var result = await _credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid2".ToId(), _ => + { + wasCalled = true; + return Task.FromResult(Result.Ok); + }); + + result.Should().BeSuccess(); + wasCalled.Should().BeTrue(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].IsChallenged.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorAsyncForTotpAuthenticator_ThenConfirmed() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, "aconfirmationcode"); + var wasCalled = false; + + var result = await _credential.ChallengeMfaAuthenticatorAsync(MfaCaller.Create("auserid".ToId(), null).Value, + "anauthenticatorid2".ToId(), _ => + { + wasCalled = true; + return Task.FromResult(Result.Ok); + }); + + result.Should().BeSuccess(); + wasCalled.Should().BeTrue(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenVerifyAuthenticatorByAuthenticatedUser_ThenReturnsError() + { + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("anotheruserid".ToId(), null).Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.ForbiddenAccess); + } + + [Fact] + public void WhenVerifyAuthenticatorAndNotOwner_ThenReturnsError() + { + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("anotheruserid".ToId(), "atoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.PasswordCredentialRoot_NotOwner); + } + + [Fact] + public void WhenVerifyMfaAuthenticatorAndIsNotVerified_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + [Fact] + public void WhenVerifyMfaAuthenticatorAndNoPassword_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_NoPassword); + } + + [Fact] + public void WhenVerifyMfaAuthenticatorAndMfaNotEnabled_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + [Fact] + public void WhenVerifyMfaAuthenticatorAndNotAuthenticated_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_AuthenticationNotInitiated); + } + + [Fact] + public void WhenVerifyMfaAuthenticatorForNoneAuthenticator_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.None, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType); + } + + [Fact] + public void WhenVerifyMfaAuthenticatorAndUnknownAuthenticator_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_CompleteMfaAuthenticatorAssociation_NotFound); + } + + [Fact] + public async Task WhenVerifyMfaAuthenticatorForOobSms_ThenAuthenticates() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, "anoobcode", "anoobsecret"); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, "anoobcode", "anoobsecret"); + + result.Should().BeSuccess(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenVerifyMfaAuthenticatorForOobEmail_ThenAuthenticates() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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(), "anmfatoken").Value, + MfaAuthenticatorType.OobEmail, "anoobcode", "anoobsecret"); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.OobEmail, "anoobcode", "anoobsecret"); + + result.Should().BeSuccess(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenVerifyMfaAuthenticatorForTotpAuthenticator_ThenAuthenticates() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _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(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, "aconfirmationcode"); + + var result = _credential.VerifyMfaAuthenticator(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value, + MfaAuthenticatorType.TotpAuthenticator, Optional.None, "aconfirmationcode"); + + result.Should().BeSuccess(); + _credential.MfaAuthenticators.Count.Should().Be(2); + _credential.MfaAuthenticators[0].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaAuthenticators[1].HasBeenConfirmed.Should().BeTrue(); + _credential.MfaOptions.IsAuthenticationInitiated.Should().BeTrue(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenResetMfaByOtherUser_ThenReturnsError() + { + var result = _credential.ResetMfa(Roles.Create("anotherrole").Value); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.PasswordCredentialRoot_NotOperator); + } + + [Fact] + public void WhenResetMfaByOperatorAndNoPassword_ThenDoesNothing() + { + var result = _credential.ResetMfa(Roles.Create(PlatformRoles.Operations).Value); + + result.Should().BeSuccess(); + _credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public async Task WhenResetMfaByOperatorAndExistingAuthenticators_ThenDisassociatesAuthenticatorsAndResets() + { + var mfaOptions = MfaOptions.Create(true, true).Value; + var credential = PasswordCredentialRoot.Create(_recorder.Object, _idFactory.Object, + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, "auserid".ToId(), mfaOptions).Value; + credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + credential.InitiateRegistrationVerification(); + credential.VerifyRegistration(); + credential.SetPasswordCredential("apassword"); + 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(), "anmfatoken").Value, + MfaAuthenticatorType.OobSms, "anoobcode", "anoobsecret"); + + credential.MfaAuthenticators.Count.Should().Be(2); + + var result = credential.ResetMfa(Roles.Create(PlatformRoles.Operations).Value); + + result.Should().BeSuccess(); + credential.MfaOptions.IsEnabled.Should().BeFalse(); + credential.MfaOptions.CanBeDisabled.Should().BeTrue(); + credential.MfaAuthenticators.Count.Should().Be(0); + credential.Events[12].Should().BeOfType(); + credential.Events[13].Should().BeOfType(); + credential.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenViewMfaAuthenticatorsAndNotOwner_ThenReturnsError() + { + var result = _credential.ViewMfaAuthenticators(MfaCaller.Create("anotheruserid".ToId(), "atoken").Value); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.PasswordCredentialRoot_NotOwner); + } + + [Fact] + public void WhenViewMfaAuthenticatorsAndIsNotVerified_ThenReturnsError() + { + _credential.InitiateRegistrationVerification(); + + var result = _credential.ViewMfaAuthenticators(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + [Fact] + public void WhenViewMfaAuthenticatorsAndNoPassword_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + + var result = _credential.ViewMfaAuthenticators(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value); + + result.Should().BeError(ErrorCode.PreconditionViolation, + Resources.PasswordCredentialRoot_NoPassword); + } + + [Fact] + public void WhenViewMfaAuthenticatorsAndMfaNotEnabled_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + + var result = _credential.ViewMfaAuthenticators(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + [Fact] + public void WhenViewMfaAuthenticatorsAndNotAuthenticated_ThenReturnsError() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + + var result = _credential.ViewMfaAuthenticators(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.MfaOptions_AuthenticationNotInitiated); + } + + [Fact] + public void WhenViewMfaAuthenticatorsAndAuthenticated_ThenReturns() + { + _credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, + PersonDisplayName.Create("aname").Value); + _credential.InitiateRegistrationVerification(); + _credential.VerifyRegistration(); + _credential.SetPasswordCredential("apassword"); + _credential.ChangeMfaEnabled("auserid".ToId(), true); + _credential.InitiateMfaAuthentication(); + + var result = _credential.ViewMfaAuthenticators(MfaCaller.Create("auserid".ToId(), "anmfatoken").Value); + + result.Should().BeSuccess(); } } \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs b/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs index 6c28e39a..9ed26ee6 100644 --- a/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs +++ b/src/IdentityDomain.UnitTests/PasswordKeepSpec.cs @@ -31,7 +31,7 @@ public void WhenConstructed_ThenPropertiesAssigned() password.PasswordHash.Should().BeNone(); password.ResetToken.Should().BeNone(); - password.TokenExpiresUtc.Should().BeNone(); + password.TokenExpires.Should().BeNone(); } [Fact] @@ -57,7 +57,7 @@ public void WhenConstructedWithHash_ThenPropertiesAssigned() password.PasswordHash.Should().Be("apasswordhash"); password.ResetToken.Should().BeNone(); - password.TokenExpiresUtc.Should().BeNone(); + password.TokenExpires.Should().BeNone(); } [Fact] @@ -79,7 +79,7 @@ public void WhenInitiatePasswordReset_ThenCreatesResetToken() password.PasswordHash.Should().Be("apasswordhash"); password.ResetToken.Should().Be(Token); - password.TokenExpiresUtc.Should().BeNear(DateTime.UtcNow.Add(PasswordKeep.DefaultResetExpiry)); + password.TokenExpires.Should().BeNear(DateTime.UtcNow.Add(PasswordKeep.DefaultResetExpiry)); } [Fact] @@ -94,7 +94,7 @@ public void WhenInitiatePasswordResetTwice_ThenCreatesNewResetToken() password.PasswordHash.Should().Be("apasswordhash"); password.ResetToken.Should().Be(token2); - password.TokenExpiresUtc.Should().BeNear(DateTime.UtcNow.Add(PasswordKeep.DefaultResetExpiry)); + password.TokenExpires.Should().BeNear(DateTime.UtcNow.Add(PasswordKeep.DefaultResetExpiry)); } [Fact] @@ -287,6 +287,6 @@ public void WhenCompletePasswordReset_ThenReturnsNewPassword() password.PasswordHash.Should().Be("apasswordhash"); password.ResetToken.Should().BeNone(); - password.TokenExpiresUtc.Should().BeNone(); + password.TokenExpires.Should().BeNone(); } } \ No newline at end of file diff --git a/src/IdentityDomain/DomainServices/IMfaService.cs b/src/IdentityDomain/DomainServices/IMfaService.cs new file mode 100644 index 00000000..e3297c94 --- /dev/null +++ b/src/IdentityDomain/DomainServices/IMfaService.cs @@ -0,0 +1,69 @@ +namespace IdentityDomain.DomainServices; + +/// +/// Defines a service for performing MFA calculations and translations +/// +public interface IMfaService +{ + /// + /// Generates a random OOB code + /// + /// + string GenerateOobCode(); + + /// + /// Generates a unique 6-digit code for OOB challenges + /// + string GenerateOobSecret(); + + /// + /// Generates a barcode URI for configuring an authenticator app, with the given username and secret + /// + string GenerateOtpBarcodeUri(string username, string secret); + + /// + /// Creates an uppercase alphanumeric string of 16 characters. + /// + string GenerateOtpSecret(); + + /// + /// Returns the number of time steps in TOTP verification that could be replayed later. + /// These are the number of time steps in the tolerated window that the user could have entered the code in. + /// + int GetTotpMaxTimeSteps(); + + /// + /// Verifies a TOTP code against a secret. + /// + /// + /// The caller must keep track of the previous time steps to prevent replay attacks, + /// and if this result is successful add the to the saved collection. + /// + TotpResult VerifyTotp(string secret, IReadOnlyList previousTimeSteps, string confirmationCode); +} + +public class TotpResult +{ + public static readonly TotpResult Invalid = new(null, false); + + public TotpResult(long timeStepMatched) + { + TimeStepMatched = timeStepMatched; + IsValid = true; + } + + private TotpResult(long? timeStepMatched, bool isValid) + { + TimeStepMatched = timeStepMatched; + IsValid = isValid; + } + + public bool IsValid { get; } + + public long? TimeStepMatched { get; } + + public static TotpResult Valid(int timeStepMatched) + { + return new TotpResult(timeStepMatched, true); + } +} \ No newline at end of file diff --git a/src/IdentityDomain/Events.cs b/src/IdentityDomain/Events.cs index ac81f66c..a3a21515 100644 --- a/src/IdentityDomain/Events.cs +++ b/src/IdentityDomain/Events.cs @@ -1,9 +1,11 @@ +using Common; using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.APIKeys; using Domain.Events.Shared.Identities.AuthTokens; using Domain.Events.Shared.Identities.PasswordCredentials; using Domain.Events.Shared.Identities.SSOUsers; using Domain.Shared; +using Domain.Shared.Identities; using Created = Domain.Events.Shared.Identities.AuthTokens.Created; using TokensChanged = Domain.Events.Shared.Identities.SSOUsers.TokensChanged; @@ -72,11 +74,13 @@ public static AccountUnlocked AccountUnlocked(Identifier id) } public static Domain.Events.Shared.Identities.PasswordCredentials.Created Created(Identifier id, - Identifier userId) + Identifier userId, MfaOptions mfaOptions) { return new Domain.Events.Shared.Identities.PasswordCredentials.Created(id) { - UserId = userId + UserId = userId, + IsMfaEnabled = mfaOptions.IsEnabled, + MfaCanBeDisabled = mfaOptions.CanBeDisabled }; } @@ -88,6 +92,125 @@ public static CredentialsChanged CredentialsChanged(Identifier id, string passwo }; } + public static MfaAuthenticationInitiated MfaAuthenticationInitiated(Identifier id, + Identifier userId, MfaOptions mfaOptions) + { + return new MfaAuthenticationInitiated(id) + { + UserId = userId, + AuthenticationToken = mfaOptions.AuthenticationToken.ValueOrDefault!, + AuthenticationExpiresAt = mfaOptions.AuthenticationTokenExpiresAt.ValueOrDefault + }; + } + + public static MfaAuthenticatorAdded MfaAuthenticatorAdded(Identifier id, + Identifier userId, MfaAuthenticatorType type, bool isActive) + { + return new MfaAuthenticatorAdded(id) + { + UserId = userId, + Type = type, + AuthenticatorId = null, + IsActive = isActive + }; + } + + public static MfaAuthenticatorAssociated MfaAuthenticatorAssociated(Identifier id, + MfaAuthenticator authenticator, Optional oobCode, Optional barCodeUri, + Optional secret, Optional oobChannel) + { + return new MfaAuthenticatorAssociated(id) + { + UserId = authenticator.UserId.Value, + AuthenticatorId = authenticator.Id, + Type = authenticator.Type, + OobChannelValue = oobChannel, + OobCode = oobCode, + BarCodeUri = barCodeUri, + Secret = secret + }; + } + + public static MfaAuthenticatorChallenged MfaAuthenticatorChallenged(Identifier id, + MfaAuthenticator authenticator, Optional oobCode, Optional barCodeUri, + Optional secret, Optional oobChannel) + { + return new MfaAuthenticatorChallenged(id) + { + UserId = authenticator.UserId.Value, + AuthenticatorId = authenticator.Id, + Type = authenticator.Type, + OobChannelValue = oobChannel, + OobCode = oobCode, + BarCodeUri = barCodeUri, + Secret = secret + }; + } + + public static MfaAuthenticatorConfirmed MfaAuthenticatorConfirmed(Identifier id, + MfaAuthenticator authenticator, Optional oobCode, Optional confirmationCode, + Optional verifiedState) + { + return new MfaAuthenticatorConfirmed(id) + { + UserId = authenticator.UserId.Value, + AuthenticatorId = authenticator.Id, + Type = authenticator.Type, + IsActive = true, + OobCode = oobCode, + ConfirmationCode = confirmationCode, + VerifiedState = verifiedState + }; + } + + public static MfaAuthenticatorVerified MfaAuthenticatorVerified(Identifier id, + MfaAuthenticator authenticator, Optional oobCode, Optional confirmationCode, + Optional verifiedState) + { + return new MfaAuthenticatorVerified(id) + { + UserId = authenticator.UserId.Value, + AuthenticatorId = authenticator.Id, + Type = authenticator.Type, + OobCode = oobCode, + ConfirmationCode = confirmationCode, + VerifiedState = verifiedState + }; + } + + public static MfaAuthenticatorRemoved MfaAuthenticatorRemoved(Identifier id, + Identifier userId, MfaAuthenticator authenticator) + { + return new MfaAuthenticatorRemoved(id) + { + UserId = authenticator.UserId.Value, + AuthenticatorId = authenticator.Id, + Type = authenticator.Type + }; + } + + public static MfaOptionsChanged MfaOptionsChanged(Identifier id, + Identifier userId, MfaOptions mfaOptions) + { + return new MfaOptionsChanged(id) + { + UserId = userId, + IsEnabled = mfaOptions.IsEnabled, + CanBeDisabled = mfaOptions.CanBeDisabled + }; + } + + public static MfaStateReset MfaStateReset(Identifier id, + Identifier userId, MfaOptions mfaOptions) + { + return new MfaStateReset(id) + { + UserId = userId, + IsEnabled = mfaOptions.IsEnabled, + CanBeDisabled = mfaOptions.CanBeDisabled + }; + } + public static PasswordResetCompleted PasswordResetCompleted(Identifier id, string token, string passwordHash) { return new PasswordResetCompleted(id) diff --git a/src/IdentityDomain/MfaAuthenticator.cs b/src/IdentityDomain/MfaAuthenticator.cs new file mode 100644 index 00000000..a98588ff --- /dev/null +++ b/src/IdentityDomain/MfaAuthenticator.cs @@ -0,0 +1,523 @@ +using Common; +using Common.Extensions; +using Domain.Common.Entities; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Events.Shared.Identities.PasswordCredentials; +using Domain.Interfaces.Entities; +using Domain.Services.Shared; +using Domain.Shared; +using Domain.Shared.Identities; +using IdentityDomain.DomainServices; + +namespace IdentityDomain; + +public sealed class MfaAuthenticator : EntityBase +{ + private const string RecoveryCodeDelimiter = ";"; + private const string TotpTimeStepDelimiter = ";"; + private readonly IEncryptionService _encryptionService; + private readonly IMfaService _mfaService; + + public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, + IEncryptionService encryptionService, IMfaService mfaService, + RootEventHandler rootEventHandler) + { + return new MfaAuthenticator(recorder, idFactory, mfaService, encryptionService, + rootEventHandler); + } + + private MfaAuthenticator(IRecorder recorder, IIdentifierFactory idFactory, IMfaService mfaService, + IEncryptionService encryptionService, + RootEventHandler rootEventHandler) : base(recorder, idFactory, rootEventHandler) + { + _mfaService = mfaService; + _encryptionService = encryptionService; + } + + /// + /// The barcode used to program the Authenticator App automatically + /// + public Optional BarCodeUri { get; private set; } = Optional.None; + + public bool HasBeenConfirmed => State is MfaAuthenticatorState.Confirmed or MfaAuthenticatorState.Verified + or MfaAuthenticatorState.Challenged; + + public bool IsActive { get; private set; } + + public bool IsAssociated => State is MfaAuthenticatorState.Associated; + + public bool IsChallenged => State == MfaAuthenticatorState.Challenged; + + public bool IsConfigurable => State is MfaAuthenticatorState.Created or MfaAuthenticatorState.Associated; + + /// + /// The destination of the OOB channel (e.g. the phone number or email address) + /// + public Optional OobChannelValue { get; private set; } = Optional.None; + + /// + /// A random code that is used in challenge-verification of the OOB channel. + /// + public Optional OobCode { get; private set; } = Optional.None; + + public Optional RootId { get; private set; } = Optional.None; + + /// + /// The secret that is used to compare in the challenge-verification + /// + /// + /// In the case of RecoveryCodes, this is the salted-hashed remaining codes to be used up. + /// In the case of OTP, this is the raw secret that was used to seed the TOTP code. + /// In the case of OOB, this is the salted-hashed code that was last used to compare with the value sent over the OOB + /// channel. + /// + public Optional Secret { get; private set; } + + public MfaAuthenticatorState State { get; private set; } + + public MfaAuthenticatorType Type { get; private set; } + + public Optional UserId { get; private set; } + + /// + /// Any state that needs to be persisted between subsequent challenges + /// + public Optional VerifiedState { get; private set; } = Optional.None; + + protected override Result OnStateChanged(IDomainEvent @event) + { + switch (@event) + { + case MfaAuthenticatorAdded added: + { + RootId = added.RootId.ToId(); + UserId = added.UserId.ToId(); + IsActive = false; + State = MfaAuthenticatorState.Created; + Type = added.Type; + OobChannelValue = Optional.None; + OobCode = Optional.None; + BarCodeUri = Optional.None; + Secret = Optional.None; + return Result.Ok; + } + + case MfaAuthenticatorAssociated associated: + { + State = MfaAuthenticatorState.Associated; + OobChannelValue = associated.OobChannelValue; + OobCode = associated.OobCode; + BarCodeUri = associated.BarCodeUri; + Secret = associated.Secret; + VerifiedState = Optional.None; + return Result.Ok; + } + + case MfaAuthenticatorChallenged challenged: + { + State = MfaAuthenticatorState.Challenged; + OobChannelValue = challenged.OobChannelValue; + OobCode = challenged.OobCode; + BarCodeUri = challenged.BarCodeUri; + Secret = challenged.Secret; + VerifiedState = Optional.None; + return Result.Ok; + } + + case MfaAuthenticatorConfirmed confirmed: + { + IsActive = confirmed.IsActive; + State = MfaAuthenticatorState.Confirmed; + VerifiedState = confirmed.VerifiedState; + return Result.Ok; + } + + case MfaAuthenticatorVerified verified: + { + State = MfaAuthenticatorState.Verified; + VerifiedState = verified.VerifiedState; + return Result.Ok; + } + + default: + return HandleUnKnownStateChangedEvent(@event); + } + } + + public override Result EnsureInvariants() + { + var ensureInvariants = base.EnsureInvariants(); + if (ensureInvariants.IsFailure) + { + return ensureInvariants.Error; + } + + return Result.Ok; + } + + public Result Associate(Optional oobPhoneNumber, Optional oobEmailAddress, + Optional otpUsername, + Optional recoveryCodes) + { + if (!IsConfigurable) + { + return Error.PreconditionViolation(Resources.MfaAuthenticator_ConfirmAssociation_NotAssociated); + } + + var oobChannel = Optional.None; + var oobCode = Optional.None; + var barCodeUri = Optional.None; + Optional secret; + switch (Type) + { + case MfaAuthenticatorType.RecoveryCodes: + if (!recoveryCodes.HasValue) + { + return Error.RuleViolation(Resources + .MfaAuthenticator_Associate_NoRecoveryCodes); + } + + secret = _encryptionService.Encrypt(recoveryCodes.Value); + break; + + case MfaAuthenticatorType.OobSms: + { + if (!oobPhoneNumber.HasValue) + { + return Error.RuleViolation(Resources + .MfaAuthenticator_Associate_OobSms_NoPhoneNumber); + } + + oobCode = _mfaService.GenerateOobCode(); + oobChannel = oobPhoneNumber.Value.Number; + var oobSecret = _mfaService.GenerateOobSecret(); + secret = _encryptionService.Encrypt(oobSecret); + break; + } + + case MfaAuthenticatorType.OobEmail: + { + if (!oobEmailAddress.HasValue) + { + return Error.RuleViolation(Resources + .MfaAuthenticator_Associate_OobEmail_NoEmailAddress); + } + + oobCode = _mfaService.GenerateOobCode(); + oobChannel = oobEmailAddress.Value.Address; + var oobSecret = _mfaService.GenerateOobSecret(); + secret = _encryptionService.Encrypt(oobSecret); + break; + } + + case MfaAuthenticatorType.TotpAuthenticator: + if (!otpUsername.HasValue) + { + return Error.RuleViolation(Resources + .MfaAuthenticator_Associate_OtpAuthenticator_NoUsername); + } + + var otpSecret = _mfaService.GenerateOtpSecret(); + secret = _encryptionService.Encrypt(otpSecret); + barCodeUri = _mfaService.GenerateOtpBarcodeUri(otpUsername.Value.Address, otpSecret); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(Type), Type, null); + } + + return RaiseChangeEvent(Events.PasswordCredentials.MfaAuthenticatorAssociated(Id, this, oobCode, + barCodeUri, secret, oobChannel)); + } + + public Result Challenge() + { + if (!HasBeenConfirmed) + { + return Error.PreconditionViolation(Resources.MfaAuthenticator_Challenge_NotConfirmed); + } + + var oobChannel = OobChannelValue; + Optional oobCode; + var barCodeUri = Optional.None; + Optional secret; + switch (Type) + { + case MfaAuthenticatorType.OobSms: + if (!oobChannel.HasValue) + { + return Error.RuleViolation(Resources + .MfaAuthenticator_Associate_OobSms_NoPhoneNumber); + } + + oobCode = _mfaService.GenerateOobCode(); + secret = _encryptionService.Encrypt(_mfaService.GenerateOobSecret()); + break; + + case MfaAuthenticatorType.OobEmail: + if (!oobChannel.HasValue) + { + return Error.RuleViolation(Resources + .MfaAuthenticator_Associate_OobEmail_NoEmailAddress); + } + + oobCode = _mfaService.GenerateOobCode(); + secret = _encryptionService.Encrypt(_mfaService.GenerateOobSecret()); + break; + + case MfaAuthenticatorType.RecoveryCodes: + case MfaAuthenticatorType.TotpAuthenticator: + return Result.Ok; + + case MfaAuthenticatorType.None: + return Error.RuleViolation(Resources.MfaAuthenticator_Challenge_InvalidAuthenticator); + + default: + throw new ArgumentOutOfRangeException(nameof(Type), Type, null); + } + + return RaiseChangeEvent( + Events.PasswordCredentials.MfaAuthenticatorChallenged(Id, this, oobCode, barCodeUri, secret, oobChannel)); + } + + public Result ConfirmAssociation(Optional confirmationOobCode, Optional confirmationCode) + { + if (!IsAssociated) + { + return Error.PreconditionViolation(Resources.MfaAuthenticator_ConfirmAssociation_NotAssociated); + } + + var verifiedState = VerifiedState; + switch (Type) + { + case MfaAuthenticatorType.OobSms: + case MfaAuthenticatorType.OobEmail: + { + if (!OobCode.HasValue + || confirmationOobCode != OobCode) + { + return Error.NotAuthenticated(); + } + + var secret = _encryptionService.Decrypt(Secret); + if (confirmationCode != secret) + { + return Error.NotAuthenticated(); + } + + break; + } + + case MfaAuthenticatorType.TotpAuthenticator: + { + if (!confirmationCode.HasValue) + { + return Error.NotAuthenticated(); + } + + var secret = _encryptionService.Decrypt(Secret); + var verified = _mfaService.VerifyTotp(secret, new List(), confirmationCode); + if (!verified.IsValid) + { + return Error.NotAuthenticated(); + } + + var firstTimeStep = verified.TimeStepMatched!.Value.ToString(); + verifiedState = _encryptionService.Encrypt(firstTimeStep); + break; + } + + case MfaAuthenticatorType.RecoveryCodes: + { + confirmationOobCode = Optional.None; + confirmationCode = Optional.None; + break; + } + + case MfaAuthenticatorType.None: + { + return Error.PreconditionViolation(Resources.MfaAuthenticator_ConfirmAssociation_InvalidType); + } + } + + return RaiseChangeEvent(Events.PasswordCredentials.MfaAuthenticatorConfirmed(RootId, + this, confirmationOobCode, confirmationCode, verifiedState)); + } + +#if TESTINGONLY + public void TestingOnly_Confirm() + { + State = MfaAuthenticatorState.Confirmed; + } +#endif + + public Result Verify(Optional confirmationOobCode, Optional confirmationCode) + { + if (!HasBeenConfirmed) + { + return Error.PreconditionViolation(Resources.MfaAuthenticator_Verify_NotVerifiable); + } + + var verifiedState = VerifiedState; + switch (Type) + { + case MfaAuthenticatorType.OobSms: + case MfaAuthenticatorType.OobEmail: + { + if (confirmationOobCode != OobCode) + { + return Error.NotAuthenticated(); + } + + var secret = _encryptionService.Decrypt(Secret); + if (confirmationCode != secret) + { + return Error.NotAuthenticated(); + } + + break; + } + + case MfaAuthenticatorType.TotpAuthenticator: + { + if (!confirmationCode.HasValue) + { + return Error.NotAuthenticated(); + } + + var previousVerifiedState = VerifiedState.HasValue + ? _encryptionService.Decrypt(VerifiedState).ToOptional() + : Optional.None; + var previousTimeSteps = ParsePreviousOtpTimeSteps(previousVerifiedState); + var secret = _encryptionService.Decrypt(Secret); + var verified = _mfaService.VerifyTotp(secret, previousTimeSteps, confirmationCode); + if (!verified.IsValid) + { + return Error.NotAuthenticated(); + } + + var latestTimeStep = verified.TimeStepMatched!.Value.ToString(); + var newVerifiedState = AccumulatePreviouslyUsedOtpTimeSteps(previousVerifiedState, + latestTimeStep, _mfaService); + verifiedState = _encryptionService.Encrypt(newVerifiedState); + break; + } + + case MfaAuthenticatorType.RecoveryCodes: + { + if (!confirmationCode.HasValue) + { + return Error.NotAuthenticated(); + } + + var codes = MfaAuthenticators.ParseRecoveryCodes(_encryptionService, Secret); + var previousVerifiedState = VerifiedState.HasValue + ? _encryptionService.Decrypt(VerifiedState).ToOptional() + : Optional.None; + ExcludePreviouslyUsedRecoveryCodes(codes, previousVerifiedState); + var matchedCode = Optional.None; + foreach (var code in codes) + { + if (confirmationCode == code) + { + matchedCode = code; + } + } + + if (!matchedCode.HasValue) + { + return Error.NotAuthenticated(); + } + + var newVerifiedState = AccumulatePreviouslyUsedRecoveryCodes(previousVerifiedState, matchedCode.Value); + verifiedState = _encryptionService.Encrypt(newVerifiedState); + break; + } + + case MfaAuthenticatorType.None: + { + return Error.PreconditionViolation(Resources.MfaAuthenticator_Verify_InvalidType); + } + } + + return RaiseChangeEvent(Events.PasswordCredentials.MfaAuthenticatorVerified(RootId, + this, confirmationOobCode, confirmationCode, verifiedState)); + } + + private static void ExcludePreviouslyUsedRecoveryCodes(List recoveryCodes, + Optional previouslyUsedCodes) + { + var usedCodes = MfaAuthenticators.ParseRecoveryCodes(previouslyUsedCodes); + foreach (var usedCode in usedCodes) + { + recoveryCodes.Remove(usedCode); + } + } + + /// + /// Accumulates all past used recovery codes + /// + private static string AccumulatePreviouslyUsedRecoveryCodes(string previouslyUsedCodes, + string usedCode) + { + var usedCodes = MfaAuthenticators.ParseRecoveryCodes(previouslyUsedCodes); + usedCodes.Add(usedCode); + + return usedCodes + .Where(item => item.HasValue()) + .Distinct() + .Join(RecoveryCodeDelimiter); + } + + /// + /// Accumulates up to past time steps that have already been + /// used up. This ensures a rolling window of time steps that cannot be used again. + /// There is no need to keep all past time steps since many of these could never be valid again in distant future. + /// + private static string AccumulatePreviouslyUsedOtpTimeSteps(Optional previouslyUsedTimeSteps, + string? latestTimeStep, IMfaService mfaService) + { + var maxTimeStepsToRemember = mfaService.GetTotpMaxTimeSteps(); + var steps = ParsePreviousOtpTimeSteps(previouslyUsedTimeSteps); + if (latestTimeStep.HasValue()) + { + var step = latestTimeStep.ToLongOrDefault(-1); + if (step > 0 && !steps.Contains(step)) + { + steps.Add(step); + } + } + + if (steps.Count > maxTimeStepsToRemember) + { + steps = steps + .TakeLast(maxTimeStepsToRemember) + .ToList(); + } + + return steps + .Join(TotpTimeStepDelimiter); + } + + private static List ParsePreviousOtpTimeSteps(Optional previouslyUsedTimeSteps) + { + if (!previouslyUsedTimeSteps.HasValue) + { + return []; + } + + var usedTimeSteps = previouslyUsedTimeSteps.Value + .Split(TotpTimeStepDelimiter, StringSplitOptions.RemoveEmptyEntries); + if (usedTimeSteps.Length > 0) + { + return usedTimeSteps + .Select(item => item.ToLongOrDefault(-1)) + .Where(item => item >= 0) + .Distinct() + .ToList(); + } + + return []; + } +} \ No newline at end of file diff --git a/src/IdentityDomain/MfaAuthenticatorState.cs b/src/IdentityDomain/MfaAuthenticatorState.cs new file mode 100644 index 00000000..45ad02da --- /dev/null +++ b/src/IdentityDomain/MfaAuthenticatorState.cs @@ -0,0 +1,10 @@ +namespace IdentityDomain; + +public enum MfaAuthenticatorState +{ + Created = 0, + Associated = 1, // The user is associating a new authenticator + Confirmed = 2, // The user confirmed the association + Challenged = 3, // The user has requested a challenge + Verified = 4 // The user has verified the challenge +} \ No newline at end of file diff --git a/src/IdentityDomain/MfaAuthenticators.cs b/src/IdentityDomain/MfaAuthenticators.cs new file mode 100644 index 00000000..2b420ab9 --- /dev/null +++ b/src/IdentityDomain/MfaAuthenticators.cs @@ -0,0 +1,149 @@ +using System.Collections; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Services.Shared; +using Domain.Shared.Identities; + +namespace IdentityDomain; + +public class MfaAuthenticators : IReadOnlyList +{ + private const int MaxGeneratedRecoveryCodes = 16; + private const string RecoveryCodeDelimiter = ";"; + private readonly List _authenticators = new(); + + public bool HasAnyConfirmedPlusRecoveryCodes => + _authenticators.Count > 1 + && _authenticators.Any(auth => auth.Type != MfaAuthenticatorType.RecoveryCodes && auth.HasBeenConfirmed); + + public bool HasOnlyOneUnconfirmedPlusRecoveryCodes => + _authenticators.Count == 2 + && _authenticators[0].Type == MfaAuthenticatorType.RecoveryCodes; + + public bool HasOnlyRecoveryCodes => + _authenticators.Count == 1 + && _authenticators[0].Type == MfaAuthenticatorType.RecoveryCodes; + + public Result EnsureInvariants() + { + _authenticators + .ForEach(una => una.EnsureInvariants()); + + return Result.Ok; + } + + public int Count => _authenticators.Count; + + public IEnumerator GetEnumerator() + { + return _authenticators.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public MfaAuthenticator this[int index] => _authenticators[index]; + + public void Add(MfaAuthenticator authenticator) + { + var match = FindByType(authenticator.Type); + if (match.Exists()) + { + _authenticators.Remove(match); + } + + _authenticators.Add(authenticator); + } + + public Optional FindById(Identifier authenticatorId) + { + var authenticator = _authenticators.FirstOrDefault(auth => auth.Id == authenticatorId); + return authenticator.NotExists() + ? Optional.None + : authenticator; + } + + public Optional FindByType(MfaAuthenticatorType type) + { + var authenticator = _authenticators.FirstOrDefault(auth => auth.Type == type); + return authenticator.NotExists() + ? Optional.None + : authenticator; + } + + public Optional FindRecoveryCodes() + { + var recoveryCodes = _authenticators.FirstOrDefault(auth => auth.Type == MfaAuthenticatorType.RecoveryCodes); + return recoveryCodes.NotExists() + ? Optional.None + : recoveryCodes; + } + + public static string GenerateRecoveryCodes() + { + var codes = new List(); + Repeat.Times(() => + { + var code = Guid.NewGuid().ToString("D").Substring(0, 8); + codes.Add(code); + }, MaxGeneratedRecoveryCodes); + + return codes.Join(RecoveryCodeDelimiter); + } + + public static List ParseRecoveryCodes(IEncryptionService encryptionService, string encryptedCodes) + { + var decryptedCodes = encryptionService.Decrypt(encryptedCodes); + if (decryptedCodes.HasNoValue()) + { + return []; + } + + return decryptedCodes + .Split(RecoveryCodeDelimiter, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + } + + public static List ParseRecoveryCodes(string recoveryCodes) + { + if (recoveryCodes.HasNoValue()) + { + return []; + } + + return recoveryCodes + .Split(RecoveryCodeDelimiter, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + } + + public void Remove(Identifier authenticatorId) + { + var unavailability = _authenticators.Find(auth => auth.Id == authenticatorId); + if (unavailability.Exists()) + { + _authenticators.Remove(unavailability); + } + } + + public List ToRecoveryCodes(IEncryptionService encryptionService) + { + var recoveryCodesAuthenticator = FindRecoveryCodes(); + if (recoveryCodesAuthenticator.NotExists()) + { + return new List(); + } + + var secret = recoveryCodesAuthenticator.Value.Secret.ValueOrDefault!; + return ParseRecoveryCodes(encryptionService, secret); + } + + public IReadOnlyList WithoutRecoveryCodes() + { + return _authenticators + .Where(auth => auth.Type != MfaAuthenticatorType.RecoveryCodes) + .ToList(); + } +} \ No newline at end of file diff --git a/src/IdentityDomain/MfaCaller.cs b/src/IdentityDomain/MfaCaller.cs new file mode 100644 index 00000000..3de2ebca --- /dev/null +++ b/src/IdentityDomain/MfaCaller.cs @@ -0,0 +1,46 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace IdentityDomain; + +public sealed class MfaCaller : ValueObjectBase +{ + public static Result Create(Identifier callerId, string? authenticationToken) + { + return new MfaCaller(callerId, authenticationToken.HasNoValue(), authenticationToken.HasValue() + ? authenticationToken.ToOptional() + : Optional.None); + } + + private MfaCaller(Identifier callerId, bool isAuthenticated, Optional authenticationToken) + { + CallerId = callerId; + IsAuthenticated = isAuthenticated; + AuthenticationToken = authenticationToken; + } + + public Optional AuthenticationToken { get; } + + public Identifier CallerId { get; } + + public bool IsAuthenticated { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new MfaCaller( + parts[0]!.ToId(), + parts[1]!.ToBoolOrDefault(false), + parts[2].FromValueOrNone()); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { CallerId, IsAuthenticated, AuthenticationToken }; + } +} \ No newline at end of file diff --git a/src/IdentityDomain/MfaOptions.cs b/src/IdentityDomain/MfaOptions.cs new file mode 100644 index 00000000..c3a8eb98 --- /dev/null +++ b/src/IdentityDomain/MfaOptions.cs @@ -0,0 +1,135 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared; + +namespace IdentityDomain; + +public sealed class MfaOptions : ValueObjectBase +{ + public static readonly MfaOptions Default = new(false, true, Optional.None, Optional.None); + internal static readonly TimeSpan DefaultAuthenticationTokenExpiry = TimeSpan.FromMinutes(10); + + public static Result Create(bool isEnabled, bool canBeDisabled, + string authenticationToken, DateTime authenticationExpiresAt) + { + if (authenticationToken.HasValue() && !authenticationExpiresAt.HasValue()) + { + return Error.Validation(Resources.MfaOptions_TokenWithoutExpiry); + } + + return new MfaOptions(isEnabled, canBeDisabled, authenticationToken, authenticationExpiresAt); + } + + public static Result Create(bool isEnabled, bool canBeDisabled) + { + return Create(isEnabled, canBeDisabled, Optional.None, Optional.None); + } + + private MfaOptions(bool isEnabled, bool canBeDisabled, + Optional authenticationToken, Optional tokenExpiresAtUtc) + { + IsEnabled = isEnabled; + CanBeDisabled = canBeDisabled; + AuthenticationToken = authenticationToken; + AuthenticationTokenExpiresAt = tokenExpiresAtUtc; + } + + public Optional AuthenticationToken { get; } + + public Optional AuthenticationTokenExpiresAt { get; private set; } + + public bool CanBeDisabled { get; } + + public bool IsAuthenticationExpired => + IsAuthenticationInitiated && AuthenticationTokenExpiresAt < DateTime.UtcNow; + + public bool IsAuthenticationInitiated => AuthenticationToken.HasValue; + + public bool IsEnabled { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new MfaOptions(parts[0]!.ToBoolOrDefault(false), + parts[1]!.ToBoolOrDefault(true), + parts[2].FromValueOrNone(), + parts[3].FromValueOrNone(val => val.FromIso8601())); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] + { IsEnabled, CanBeDisabled, AuthenticationToken, AuthenticationTokenExpiresAt }; + } + + [SkipImmutabilityCheck] + public Result Authenticate(MfaCaller caller) + { + if (!IsEnabled) + { + return Error.RuleViolation(Resources.MfaOptions_NotEnabled); + } + + if (caller.IsAuthenticated) + { + return Result.Ok; + } + + if (!IsAuthenticationInitiated) + { + return Error.RuleViolation(Resources.MfaOptions_AuthenticationNotInitiated); + } + + if (caller.AuthenticationToken != AuthenticationToken) + { + return Error.NotAuthenticated(Resources.MfaOptions_AuthenticationFailed); + } + + if (IsAuthenticationExpired) + { + return Error.NotAuthenticated(Resources.MfaOptions_AuthenticationTokenExpired); + } + + return Result.Ok; + } + + public Result Enable(bool isEnabled) + { + if (isEnabled == false + && CanBeDisabled == false) + { + return Error.RuleViolation(IsEnabled + ? Resources.MfaOptions_Change_CannotBeDisabled + : Resources.MfaOptions_Change_CannotBeEnabled); + } + + return Create(isEnabled, CanBeDisabled, AuthenticationToken, + AuthenticationTokenExpiresAt); + } + + public Result InitiateAuthentication(ITokensService tokensService) + { + if (!IsEnabled) + { + return Error.RuleViolation(Resources.MfaOptions_NotEnabled); + } + + var authenticationToken = tokensService.CreateMfaAuthenticationToken(); + return Create(IsEnabled, CanBeDisabled, authenticationToken, + DateTime.UtcNow.Add(DefaultAuthenticationTokenExpiry)); + } + +#if TESTINGONLY + [SkipImmutabilityCheck] + public void TestingOnly_ExpireAuthentication() + { + AuthenticationTokenExpiresAt = DateTime.UtcNow.SubtractSeconds(1); + } +#endif +} \ No newline at end of file diff --git a/src/IdentityDomain/PasswordCredentialRoot.cs b/src/IdentityDomain/PasswordCredentialRoot.cs index 991962bd..0c45b20d 100644 --- a/src/IdentityDomain/PasswordCredentialRoot.cs +++ b/src/IdentityDomain/PasswordCredentialRoot.cs @@ -6,56 +6,83 @@ using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.PasswordCredentials; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; using Domain.Services.Shared; using Domain.Shared; +using Domain.Shared.Identities; using IdentityDomain.DomainServices; namespace IdentityDomain; +public delegate Task> NotifyChallenged(MfaAuthenticator associatedAuthenticator); + public sealed class PasswordCredentialRoot : AggregateRootBase { - public const string CooldownPeriodInMinutesSettingName = "IdentityApi:PasswordCredential:CooldownPeriodInMinutes"; - public const string MaxFailedLoginsSettingName = "IdentityApi:PasswordCredential:MaxFailedLogins"; + private const string CooldownPeriodInMinutesSettingName = "IdentityApi:PasswordCredential:CooldownPeriodInMinutes"; + private const string MaxFailedLoginsSettingName = "IdentityApi:PasswordCredential:MaxFailedLogins"; + // EXTEND: Change default MFA options for all users + private static readonly MfaOptions DefaultMfaOptions = MfaOptions.Create(false, true).Value; private readonly IEmailAddressService _emailAddressService; + private readonly IEncryptionService _encryptionService; + private readonly IMfaService _mfaService; private readonly IPasswordHasherService _passwordHasherService; private readonly ITokensService _tokensService; +#pragma warning disable SAASDDD012 public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, +#pragma warning restore SAASDDD012 + IConfigurationSettings settings, IEmailAddressService emailAddressService, ITokensService tokensService, + IEncryptionService encryptionService, IPasswordHasherService passwordHasherService, IMfaService mfaService, + Identifier userId) + { + return Create(recorder, idFactory, settings, emailAddressService, tokensService, encryptionService, + passwordHasherService, mfaService, userId, DefaultMfaOptions); + } + + internal static Result Create(IRecorder recorder, IIdentifierFactory idFactory, IConfigurationSettings settings, IEmailAddressService emailAddressService, ITokensService tokensService, - IPasswordHasherService passwordHasherService, Identifier userId) + IEncryptionService encryptionService, IPasswordHasherService passwordHasherService, IMfaService mfaService, + Identifier userId, MfaOptions mfaOptions) { var root = new PasswordCredentialRoot(recorder, idFactory, settings, emailAddressService, tokensService, - passwordHasherService); - root.RaiseCreateEvent(IdentityDomain.Events.PasswordCredentials.Created(root.Id, userId)); + encryptionService, passwordHasherService, mfaService); + root.RaiseCreateEvent(IdentityDomain.Events.PasswordCredentials.Created(root.Id, userId, mfaOptions)); return root; } private PasswordCredentialRoot(IRecorder recorder, IIdentifierFactory idFactory, IConfigurationSettings settings, IEmailAddressService emailAddressService, ITokensService tokensService, - IPasswordHasherService passwordHasherService) : + IEncryptionService encryptionService, IPasswordHasherService passwordHasherService, IMfaService mfaService) : base(recorder, idFactory) { _emailAddressService = emailAddressService; _tokensService = tokensService; + _encryptionService = encryptionService; _passwordHasherService = passwordHasherService; + _mfaService = mfaService; Login = CreateLoginMonitor(settings); } private PasswordCredentialRoot(IRecorder recorder, IIdentifierFactory idFactory, IConfigurationSettings settings, IEmailAddressService emailAddressService, ITokensService tokensService, - IPasswordHasherService passwordHasherService, ISingleValueObject identifier) : base( + IEncryptionService encryptionService, IPasswordHasherService passwordHasherService, IMfaService mfaService, + ISingleValueObject identifier) : base( recorder, idFactory, identifier) { _emailAddressService = emailAddressService; _tokensService = tokensService; + _encryptionService = encryptionService; _passwordHasherService = passwordHasherService; + _mfaService = mfaService; Login = CreateLoginMonitor(settings); } public bool IsLocked => Login.IsLocked; + public bool IsMfaEnabled => MfaOptions.IsEnabled; + public bool IsPasswordResetInitiated => Password.IsResetInitiated; public bool IsPasswordResetStillValid => Password.IsResetStillValid; @@ -72,6 +99,10 @@ private PasswordCredentialRoot(IRecorder recorder, IIdentifierFactory idFactory, public LoginMonitor Login { get; private set; } + public MfaAuthenticators MfaAuthenticators { get; } = new(); + + public MfaOptions MfaOptions { get; private set; } = MfaOptions.Default; + public PasswordKeep Password { get; private set; } = PasswordKeep.Create().Value; public Optional Registration { get; private set; } @@ -86,7 +117,9 @@ public static AggregateRootFactory Rehydrate() container.GetRequiredService(), container.GetRequiredServiceForPlatform(), container.GetRequiredService(), container.GetRequiredService(), - container.GetRequiredService(), identifier); + container.GetRequiredService(), + container.GetRequiredService(), + container.GetRequiredService(), identifier); } public override Result EnsureInvariants() @@ -97,20 +130,26 @@ public override Result EnsureInvariants() return ensureInvariants.Error; } + var mfaAuthenticators = MfaAuthenticators.EnsureInvariants(); + if (mfaAuthenticators.IsFailure) + { + return mfaAuthenticators.Error; + } + if (Registration.HasValue) { var isEmailUnique = _emailAddressService.EnsureUniqueAsync(Registration.Value.EmailAddress, UserId) .GetAwaiter().GetResult(); if (!isEmailUnique) { - return Error.RuleViolation(Resources.PasswordCredentialsRoot_EmailNotUnique); + return Error.RuleViolation(Resources.PasswordCredentialRoot_EmailNotUnique); } } if (!Registration.HasValue && Password.IsResetInitiated) { - return Error.RuleViolation(Resources.PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration); + return Error.RuleViolation(Resources.PasswordCredentialRoot_PasswordInitiatedWithoutRegistration); } return Result.Ok; @@ -123,7 +162,17 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case Created created: { UserId = created.UserId.ToId(); - Recorder.TraceDebug(null, "Password credential {Id} was created for {UserId}", Id, created.UserId); + var mfaOptions = + MfaOptions.Create(created.IsMfaEnabled, created.MfaCanBeDisabled); + if (mfaOptions.IsFailure) + { + return mfaOptions.Error; + } + + MfaOptions = mfaOptions.Value; + Recorder.TraceDebug(null, "Password credential {Id} was created for {UserId}, with MFA {IsMfaEnabled}", + Id, + created.UserId, created.IsMfaEnabled); return Result.Ok; } @@ -219,11 +268,368 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } + case MfaOptionsChanged changed: + { + var options = + MfaOptions.Create(changed.IsEnabled, changed.CanBeDisabled); + if (options.IsFailure) + { + return options.Error; + } + + MfaOptions = options.Value; + Recorder.TraceDebug(null, + "Password credential {Id} changed MFA options, enabled {IsEnabled}, canBeDisabled {CanBeDisabled}", + Id, changed.IsEnabled, changed.CanBeDisabled); + return Result.Ok; + } + + case MfaStateReset reset: + { + var options = + MfaOptions.Create(reset.IsEnabled, reset.CanBeDisabled); + if (options.IsFailure) + { + return options.Error; + } + + MfaOptions = options.Value; + Recorder.TraceDebug(null, + "Password credential {Id} reset MFA state, enabled {IsEnabled}, canBeDisabled {CanBeDisabled}", + Id, reset.IsEnabled, reset.CanBeDisabled); + return Result.Ok; + } + + case MfaAuthenticationInitiated initiated: + { + var options = MfaOptions.Create(true, MfaOptions.CanBeDisabled, initiated.AuthenticationToken, + initiated.AuthenticationExpiresAt); + if (options.IsFailure) + { + return options.Error; + } + + MfaOptions = options.Value; + Recorder.TraceDebug(null, + "Password credential {Id} initiated MFA authentication", + Id); + return Result.Ok; + } + + case MfaAuthenticatorAdded added: + { + var authenticator = RaiseEventToChildEntity(isReconstituting, added, idFactory => + MfaAuthenticator.Create(Recorder, idFactory, _encryptionService, + _mfaService, + RaiseChangeEvent), + e => e.AuthenticatorId!); + if (authenticator.IsFailure) + { + return authenticator.Error; + } + + MfaAuthenticators.Add(authenticator.Value); + Recorder.TraceDebug(null, "Password credential {Id} added authenticator of type {Type}", + Id, added.Type); + return Result.Ok; + } + + case MfaAuthenticatorRemoved removed: + { + MfaAuthenticators.Remove(removed.AuthenticatorId.ToId()); + Recorder.TraceDebug(null, "CPassword credential {Id} has had authenticator {AuthenticatorId} removed", + Id, removed.AuthenticatorId); + return Result.Ok; + } + + case MfaAuthenticatorAssociated associated: + { + var authenticator = MfaAuthenticators.FindById(associated.AuthenticatorId.ToId()); + if (!authenticator.HasValue) + { + return Error.RuleViolation(Resources.PasswordCredentialRoot_NoAuthenticator); + } + + var forwarded = RaiseEventToChildEntity(associated, authenticator.Value); + if (forwarded.IsFailure) + { + return forwarded.Error; + } + + Recorder.TraceDebug(null, "Password credential {Id} is associating authenticator of type {Type}", + Id, associated.Type); + return Result.Ok; + } + + case MfaAuthenticatorChallenged challenged: + { + var authenticator = MfaAuthenticators.FindById(challenged.AuthenticatorId.ToId()); + if (!authenticator.HasValue) + { + return Error.RuleViolation(Resources.PasswordCredentialRoot_NoAuthenticator); + } + + var forwarded = RaiseEventToChildEntity(challenged, authenticator.Value); + if (forwarded.IsFailure) + { + return forwarded.Error; + } + + Recorder.TraceDebug(null, "Password credential {Id} has challenged authenticator of type {Type}", + Id, challenged.Type); + return Result.Ok; + } + + case MfaAuthenticatorConfirmed confirmed: + { + var authenticator = MfaAuthenticators.FindById(confirmed.AuthenticatorId.ToId()); + if (!authenticator.HasValue) + { + return Error.RuleViolation(Resources.PasswordCredentialRoot_NoAuthenticator); + } + + var forwarded = RaiseEventToChildEntity(confirmed, authenticator.Value); + if (forwarded.IsFailure) + { + return forwarded.Error; + } + + Recorder.TraceDebug(null, + "Password credential {Id} has associated authenticator {AuthenticatorId} of type {Type}", + Id, confirmed.AuthenticatorId, confirmed.Type); + return Result.Ok; + } + + case MfaAuthenticatorVerified verified: + { + var authenticator = MfaAuthenticators.FindById(verified.AuthenticatorId.ToId()); + if (!authenticator.HasValue) + { + return Error.RuleViolation(Resources.PasswordCredentialRoot_NoAuthenticator); + } + + var forwarded = RaiseEventToChildEntity(verified, authenticator.Value); + if (forwarded.IsFailure) + { + return forwarded.Error; + } + + Recorder.TraceDebug(null, + "Password credential {Id} has verified authenticator {AuthenticatorId} of type {Type}", + Id, verified.AuthenticatorId, verified.Type); + return Result.Ok; + } + default: return HandleUnKnownStateChangedEvent(@event); } } + public async Task> AssociateMfaAuthenticatorAsync(MfaCaller caller, + MfaAuthenticatorType type, Optional oobPhoneNumber, + Optional oobEmailAddress, Optional otpUsername, NotifyChallenged onChallenged) + { + if (!IsOwner(caller.CallerId)) + { + return Error.RoleViolation(Resources.PasswordCredentialRoot_NotOwner); + } + + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + if (!IsPasswordSet) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); + } + + if (!IsMfaEnabled) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + var authenticated = MfaOptions.Authenticate(caller); + if (authenticated.IsFailure) + { + return authenticated.Error; + } + + if (type is MfaAuthenticatorType.None or MfaAuthenticatorType.RecoveryCodes) + { + return Error.RuleViolation(Resources + .PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType); + } + + var authenticator = MfaAuthenticators.FindByType(type); + if (authenticator is { HasValue: true, Value.HasBeenConfirmed: true }) + { + return Error.PreconditionViolation(Resources + .PasswordCredentialRoot_AssociateMfaAuthenticator_AlreadyExists); + } + + if (!caller.IsAuthenticated + && MfaAuthenticators.HasAnyConfirmedPlusRecoveryCodes) + { + return Error.ForbiddenAccess(); + } + + var recoveryCodesAuthenticator = MfaAuthenticators.FindRecoveryCodes(); + if (!recoveryCodesAuthenticator.HasValue) + { + var recoveryCodesCompleted = AddRecoveryCodes(); + if (recoveryCodesCompleted.IsFailure) + { + return recoveryCodesCompleted.Error; + } + } + + if (!authenticator.HasValue) + { + var added = + RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.MfaAuthenticatorAdded(Id, UserId, type, true)); + if (added.IsFailure) + { + return added.Error; + } + + authenticator = MfaAuthenticators.FindByType(type).Value; + } + + var associated = + authenticator.Value.Associate(oobPhoneNumber, oobEmailAddress, otpUsername, Optional.None); + if (associated.IsFailure) + { + return associated.Error; + } + + // Send challenge to user + authenticator = MfaAuthenticators.FindByType(type).Value; + var handled = await onChallenged(authenticator.Value); + if (handled.IsFailure) + { + return handled.Error; + } + + return authenticator.Value; + + Result AddRecoveryCodes() + { + var recoveryAdded = + RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.MfaAuthenticatorAdded(Id, UserId, + MfaAuthenticatorType.RecoveryCodes, true)); + if (recoveryAdded.IsFailure) + { + return recoveryAdded.Error; + } + + recoveryCodesAuthenticator = MfaAuthenticators.FindRecoveryCodes(); + var recoveryCodes = MfaAuthenticators.GenerateRecoveryCodes(); + var recoveryAssociated = recoveryCodesAuthenticator.Value.Associate(Optional.None, + Optional.None, Optional.None, recoveryCodes); + if (recoveryAssociated.IsFailure) + { + return recoveryAssociated.Error; + } + + return recoveryCodesAuthenticator.Value.ConfirmAssociation(Optional.None, + Optional.None); + } + } + + public async Task> ChallengeMfaAuthenticatorAsync(MfaCaller caller, + Identifier authenticatorId, NotifyChallenged onChallenged) + { + if (!IsOwner(caller.CallerId)) + { + return Error.RoleViolation(Resources.PasswordCredentialRoot_NotOwner); + } + + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + if (!IsPasswordSet) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); + } + + if (!IsMfaEnabled) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + var authenticated = MfaOptions.Authenticate(caller); + if (authenticated.IsFailure) + { + return authenticated.Error; + } + + var authenticator = MfaAuthenticators.FindById(authenticatorId); + if (!authenticator.HasValue) + { + return Error.EntityNotFound(); + } + + var challenged = authenticator.Value.Challenge(); + if (challenged.IsFailure) + { + return challenged.Error; + } + + // Send challenge to user + var handled = await onChallenged(authenticator.Value); + if (handled.IsFailure) + { + return handled.Error; + } + + return authenticator.Value; + } + + public Result ChangeMfaEnabled(Identifier modifierId, bool isEnabled) + { + if (!IsOwner(modifierId)) + { + return Error.RoleViolation(Resources.PasswordCredentialRoot_NotOwner); + } + + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + if (!IsPasswordSet) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); + } + + if (MfaOptions.IsEnabled == isEnabled) + { + return Result.Ok; + } + + var changed = MfaOptions.Enable(isEnabled); + if (changed.IsFailure) + { + return changed.Error; + } + + if (!isEnabled) + { + var deleted = DeleteAllMfaAuthenticators(); + if (deleted.IsFailure) + { + return deleted.Error; + } + } + + return RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.MfaOptionsChanged(Id, UserId, changed.Value)); + } + public Result CompletePasswordReset(string token, string password) { if (token.IsNotValuedParameter(nameof(token), out var error1)) @@ -232,30 +638,30 @@ public Result CompletePasswordReset(string token, string password) } if (password.IsInvalidParameter(pwd => _passwordHasherService.ValidatePassword(pwd, false), - nameof(password), Resources.PasswordCredentialsRoot_InvalidPassword, out var error2)) + nameof(password), Resources.PasswordCredentialRoot_InvalidPassword, out var error2)) { return error2; } if (!IsPasswordSet) { - return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_NoPassword); + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); } if (password.IsInvalidParameter(pwd => !_passwordHasherService.VerifyPassword(pwd, Password.PasswordHash), - nameof(password), Resources.PasswordCredentialsRoot_DuplicatePassword, out var error3)) + nameof(password), Resources.PasswordCredentialRoot_DuplicatePassword, out var error3)) { return error3; } if (!IsRegistrationVerified) { - return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationUnverified); + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); } if (!IsPasswordResetStillValid) { - return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_PasswordResetTokenExpired); + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_PasswordResetTokenExpired); } var passwordHash = _passwordHasherService.HashPassword(password); @@ -274,11 +680,143 @@ public Result CompletePasswordReset(string token, string password) return Result.Ok; } + public Result ConfirmMfaAuthenticatorAssociation(MfaCaller caller, + MfaAuthenticatorType type, string? oobCode, string confirmationCode) + { + if (!IsOwner(caller.CallerId)) + { + return Error.RoleViolation(Resources.PasswordCredentialRoot_NotOwner); + } + + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + if (!IsPasswordSet) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); + } + + if (!IsMfaEnabled) + { + return Error.RuleViolation(Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + var authenticated = MfaOptions.Authenticate(caller); + if (authenticated.IsFailure) + { + return authenticated.Error; + } + + if (type is MfaAuthenticatorType.None or MfaAuthenticatorType.RecoveryCodes) + { + return Error.RuleViolation(Resources + .PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType); + } + + var authenticator = MfaAuthenticators.FindByType(type); + if (!authenticator.HasValue) + { + return Error.PreconditionViolation(Resources + .PasswordCredentialRoot_CompleteMfaAuthenticatorAssociation_NotFound); + } + + return authenticator.Value.ConfirmAssociation(oobCode, confirmationCode); + } + + public Result DisassociateMfaAuthenticator(MfaCaller caller, + Identifier authenticatorId) + { + if (!IsOwner(caller.CallerId)) + { + return Error.RoleViolation(Resources.PasswordCredentialRoot_NotOwner); + } + + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + if (!IsPasswordSet) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); + } + + if (!IsMfaEnabled) + { + return Error.RuleViolation(Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + var authenticator = MfaAuthenticators.FindById(authenticatorId); + if (!authenticator.HasValue) + { + return Error.EntityNotFound(); + } + + var recoveryCodesAuthenticator = MfaAuthenticators.FindRecoveryCodes(); + if (recoveryCodesAuthenticator.HasValue + && recoveryCodesAuthenticator.Value.Id == authenticatorId) + { + return Error.RuleViolation(Resources + .PasswordCredentialRoot_DisassociateMfaAuthenticator_RecoveryCodesCannotBeDeleted); + } + + var authenticatorDeleted = RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.MfaAuthenticatorRemoved(Id, UserId, authenticator)); + if (authenticatorDeleted.IsFailure) + { + return authenticatorDeleted.Error; + } + + if (MfaAuthenticators.HasOnlyRecoveryCodes) + { + var recoveryDeleted = RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.MfaAuthenticatorRemoved(Id, UserId, + recoveryCodesAuthenticator.Value)); + if (recoveryDeleted.IsFailure) + { + return recoveryDeleted.Error; + } + } + + return authenticator.Value; + } + + public Result InitiateMfaAuthentication() + { + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + if (!IsPasswordSet) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); + } + + var initiated = MfaOptions.InitiateAuthentication(_tokensService); + if (initiated.IsFailure) + { + return initiated.Error; + } + + var raised = + RaiseChangeEvent( + IdentityDomain.Events.PasswordCredentials.MfaAuthenticationInitiated(Id, UserId, initiated.Value)); + if (raised.IsFailure) + { + return raised.Error; + } + + return MfaOptions.AuthenticationToken.Value; + } + public Result InitiatePasswordReset() { if (!IsRegistrationVerified) { - return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationUnverified); + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); } var token = _tokensService.CreatePasswordResetToken(); @@ -289,7 +827,7 @@ public Result InitiateRegistrationVerification() { if (IsVerified) { - return Error.PreconditionViolation(Resources.PasswordCredentialsRoot_RegistrationVerified); + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationVerified); } var token = _tokensService.CreateRegistrationVerificationToken(); @@ -297,10 +835,36 @@ public Result InitiateRegistrationVerification() IdentityDomain.Events.PasswordCredentials.RegistrationVerificationCreated(Id, token)); } + public Result ResetMfa(Roles resetterRoles) + { + if (!IsOperations(resetterRoles)) + { + return Error.RoleViolation(Resources.PasswordCredentialRoot_NotOperator); + } + + if (!IsRegistrationVerified) + { + return Result.Ok; + } + + if (!IsPasswordSet) + { + return Result.Ok; + } + + var deleted = DeleteAllMfaAuthenticators(); + if (deleted.IsFailure) + { + return deleted.Error; + } + + return RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.MfaStateReset(Id, UserId, DefaultMfaOptions)); + } + public Result SetPasswordCredential(string password) { if (password.IsInvalidParameter(pwd => _passwordHasherService.ValidatePassword(pwd, true), - nameof(password), Resources.PasswordCredentialsRoot_InvalidPassword, out var error1)) + nameof(password), Resources.PasswordCredentialRoot_InvalidPassword, out var error1)) { return error1; } @@ -359,10 +923,60 @@ public void TestingOnly_Unregister() } #endif + public Result VerifyMfaAuthenticator(MfaCaller caller, + MfaAuthenticatorType type, string? oobCode, string confirmationCode) + { + if (caller.IsAuthenticated) + { + return Error.ForbiddenAccess(); + } + + if (!IsOwner(caller.CallerId)) + { + return Error.RoleViolation(Resources.PasswordCredentialRoot_NotOwner); + } + + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + if (!IsPasswordSet) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); + } + + if (!IsMfaEnabled) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + var authenticated = MfaOptions.Authenticate(caller); + if (authenticated.IsFailure) + { + return authenticated.Error; + } + + if (type is MfaAuthenticatorType.None) + { + return Error.RuleViolation(Resources + .PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType); + } + + var authenticator = MfaAuthenticators.FindByType(type); + if (!authenticator.HasValue) + { + return Error.PreconditionViolation(Resources + .PasswordCredentialRoot_CompleteMfaAuthenticatorAssociation_NotFound); + } + + return authenticator.Value.Verify(oobCode, confirmationCode); + } + public Result VerifyPassword(string password, bool auditAttempt = true) { if (password.IsInvalidParameter(pwd => _passwordHasherService.ValidatePassword(pwd, false), - nameof(password), Resources.PasswordCredentialsRoot_InvalidPassword, out var error1)) + nameof(password), Resources.PasswordCredentialRoot_InvalidPassword, out var error1)) { return error1; } @@ -421,13 +1035,75 @@ public Result VerifyRegistration() if (!IsVerificationStillVerifying) { return Error.PreconditionViolation(!IsVerificationVerifying - ? Resources.PasswordCredentialsRoot_RegistrationNotVerifying - : Resources.PasswordCredentialsRoot_RegistrationVerifyingExpired); + ? Resources.PasswordCredentialRoot_RegistrationNotVerifying + : Resources.PasswordCredentialRoot_RegistrationVerifyingExpired); } return RaiseChangeEvent(IdentityDomain.Events.PasswordCredentials.RegistrationVerificationVerified(Id)); } + public Result ViewMfaAuthenticators(MfaCaller caller) + { + if (!IsOwner(caller.CallerId)) + { + return Error.RoleViolation(Resources.PasswordCredentialRoot_NotOwner); + } + + if (!IsRegistrationVerified) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_RegistrationUnverified); + } + + if (!IsPasswordSet) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_NoPassword); + } + + if (!IsMfaEnabled) + { + return Error.PreconditionViolation(Resources.PasswordCredentialRoot_MfaNotEnabled); + } + + var authenticated = MfaOptions.Authenticate(caller); + if (authenticated.IsFailure) + { + return authenticated.Error; + } + + return Result.Ok; + } + + private Result DeleteAllMfaAuthenticators() + { + var authenticators = MfaAuthenticators.WithoutRecoveryCodes(); + foreach (var authenticator in authenticators) + { + var caller = MfaCaller.Create(UserId, null); + if (caller.IsFailure) + { + return caller.Error; + } + + var disassociated = DisassociateMfaAuthenticator(caller.Value, authenticator.Id); + if (disassociated.IsFailure) + { + return disassociated.Error; + } + } + + return Result.Ok; + } + + private bool IsOwner(Identifier userId) + { + return UserId == userId; + } + + private static bool IsOperations(Roles roles) + { + return roles.HasRole(PlatformRoles.Operations); + } + private static LoginMonitor CreateLoginMonitor(IConfigurationSettings settings) { return LoginMonitor.Create( diff --git a/src/IdentityDomain/PasswordKeep.cs b/src/IdentityDomain/PasswordKeep.cs index e0d72fa5..9fb5bc2c 100644 --- a/src/IdentityDomain/PasswordKeep.cs +++ b/src/IdentityDomain/PasswordKeep.cs @@ -29,24 +29,24 @@ public static Result Create(IPasswordHasherService password } private PasswordKeep(Optional passwordHash, Optional resetToken, - Optional tokenExpiresUtc) + Optional tokenExpires) { PasswordHash = passwordHash; ResetToken = resetToken; - TokenExpiresUtc = tokenExpiresUtc; + TokenExpires = tokenExpires; } public bool HasPassword => PasswordHash.HasValue; - public bool IsResetInitiated => ResetToken.HasValue && TokenExpiresUtc.HasValue; + public bool IsResetInitiated => ResetToken.HasValue && TokenExpires.HasValue; - public bool IsResetStillValid => IsResetInitiated && TokenExpiresUtc > DateTime.UtcNow; + public bool IsResetStillValid => IsResetInitiated && TokenExpires > DateTime.UtcNow; public Optional PasswordHash { get; } public Optional ResetToken { get; } - public Optional TokenExpiresUtc { get; } + public Optional TokenExpires { get; } public static ValueObjectFactory Rehydrate() { @@ -63,7 +63,7 @@ public static ValueObjectFactory Rehydrate() protected override IEnumerable GetAtomicValues() { return new[] - { PasswordHash.ValueOrNull, ResetToken.ValueOrNull, TokenExpiresUtc.ToValueOrNull(val => val.ToIso8601()) }; + { PasswordHash.ValueOrNull, ResetToken.ValueOrNull, TokenExpires.ToValueOrNull(val => val.ToIso8601()) }; } public Result CompletePasswordReset(IPasswordHasherService passwordHasherService, string token, diff --git a/src/IdentityDomain/Resources.Designer.cs b/src/IdentityDomain/Resources.Designer.cs index f838edeb..b06a1c7c 100644 --- a/src/IdentityDomain/Resources.Designer.cs +++ b/src/IdentityDomain/Resources.Designer.cs @@ -185,93 +185,327 @@ internal static string LoginMonitor_InvalidMaxFailedLogins { } } + /// + /// Looks up a localized string similar to Recovery codes required to associate Recovery Codes. + /// + internal static string MfaAuthenticator_Associate_NoRecoveryCodes { + get { + return ResourceManager.GetString("MfaAuthenticator_Associate_NoRecoveryCodes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An email address is required to associate OOB with Email. + /// + internal static string MfaAuthenticator_Associate_OobEmail_NoEmailAddress { + get { + return ResourceManager.GetString("MfaAuthenticator_Associate_OobEmail_NoEmailAddress", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A phone number is required to associate OOB with SMS. + /// + internal static string MfaAuthenticator_Associate_OobSms_NoPhoneNumber { + get { + return ResourceManager.GetString("MfaAuthenticator_Associate_OobSms_NoPhoneNumber", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An email address is required to associate OTP Authenticator. + /// + internal static string MfaAuthenticator_Associate_OtpAuthenticator_NoUsername { + get { + return ResourceManager.GetString("MfaAuthenticator_Associate_OtpAuthenticator_NoUsername", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This authenticator cannot be challenged. + /// + internal static string MfaAuthenticator_Challenge_InvalidAuthenticator { + get { + return ResourceManager.GetString("MfaAuthenticator_Challenge_InvalidAuthenticator", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The authenticator must be confirmed before being challenged. + /// + internal static string MfaAuthenticator_Challenge_NotConfirmed { + get { + return ResourceManager.GetString("MfaAuthenticator_Challenge_NotConfirmed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot confirm this type of authenticator. + /// + internal static string MfaAuthenticator_ConfirmAssociation_InvalidType { + get { + return ResourceManager.GetString("MfaAuthenticator_ConfirmAssociation_InvalidType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The authenticator cannot be confirmed. + /// + internal static string MfaAuthenticator_ConfirmAssociation_NotAssociated { + get { + return ResourceManager.GetString("MfaAuthenticator_ConfirmAssociation_NotAssociated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot verify this type of authenticator. + /// + internal static string MfaAuthenticator_Verify_InvalidType { + get { + return ResourceManager.GetString("MfaAuthenticator_Verify_InvalidType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The authenticator must be confirmed before being verified. + /// + internal static string MfaAuthenticator_Verify_NotVerifiable { + get { + return ResourceManager.GetString("MfaAuthenticator_Verify_NotVerifiable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MFA authentication failed. + /// + internal static string MfaOptions_AuthenticationFailed { + get { + return ResourceManager.GetString("MfaOptions_AuthenticationFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MFA authentication has not been initiated yet. + /// + internal static string MfaOptions_AuthenticationNotInitiated { + get { + return ResourceManager.GetString("MfaOptions_AuthenticationNotInitiated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MFA authentication session has expired. + /// + internal static string MfaOptions_AuthenticationTokenExpired { + get { + return ResourceManager.GetString("MfaOptions_AuthenticationTokenExpired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot change whether you can disable MFA or not. + /// + internal static string MfaOptions_CannotChangeCanBeDisabled { + get { + return ResourceManager.GetString("MfaOptions_CannotChangeCanBeDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MFA cannot be disabled. + /// + internal static string MfaOptions_Change_CannotBeDisabled { + get { + return ResourceManager.GetString("MfaOptions_Change_CannotBeDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MFA cannot be enabled. + /// + internal static string MfaOptions_Change_CannotBeEnabled { + get { + return ResourceManager.GetString("MfaOptions_Change_CannotBeEnabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MFA is not enabled for this user. + /// + internal static string MfaOptions_NotEnabled { + get { + return ResourceManager.GetString("MfaOptions_NotEnabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MFA authentication session must have expiry. + /// + internal static string MfaOptions_TokenWithoutExpiry { + get { + return ResourceManager.GetString("MfaOptions_TokenWithoutExpiry", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This authenticator has already been associated. + /// + internal static string PasswordCredentialRoot_AssociateMfaAuthenticator_AlreadyExists { + get { + return ResourceManager.GetString("PasswordCredentialRoot_AssociateMfaAuthenticator_AlreadyExists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This authenticator cannot be associated. + /// + internal static string PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType { + get { + return ResourceManager.GetString("PasswordCredentialRoot_AssociateMfaAuthenticator_InvalidType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The authenticator has not yet been associated. + /// + internal static string PasswordCredentialRoot_CompleteMfaAuthenticatorAssociation_NotFound { + get { + return ResourceManager.GetString("PasswordCredentialRoot_CompleteMfaAuthenticatorAssociation_NotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You cannot delete the recovery codes for MFA. + /// + internal static string PasswordCredentialRoot_DisassociateMfaAuthenticator_RecoveryCodesCannotBeDeleted { + get { + return ResourceManager.GetString("PasswordCredentialRoot_DisassociateMfaAuthenticator_RecoveryCodesCannotBeDeleted", resourceCulture); + } + } + /// /// Looks up a localized string similar to New password cannot be the same as old password. /// - internal static string PasswordCredentialsRoot_DuplicatePassword { + internal static string PasswordCredentialRoot_DuplicatePassword { get { - return ResourceManager.GetString("PasswordCredentialsRoot_DuplicatePassword", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_DuplicatePassword", resourceCulture); } } /// /// Looks up a localized string similar to The email is already in use by another user. /// - internal static string PasswordCredentialsRoot_EmailNotUnique { + internal static string PasswordCredentialRoot_EmailNotUnique { get { - return ResourceManager.GetString("PasswordCredentialsRoot_EmailNotUnique", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_EmailNotUnique", resourceCulture); } } /// /// Looks up a localized string similar to The password is not valid. /// - internal static string PasswordCredentialsRoot_InvalidPassword { + internal static string PasswordCredentialRoot_InvalidPassword { get { - return ResourceManager.GetString("PasswordCredentialsRoot_InvalidPassword", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_InvalidPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MFA is not enabled for this user. + /// + internal static string PasswordCredentialRoot_MfaNotEnabled { + get { + return ResourceManager.GetString("PasswordCredentialRoot_MfaNotEnabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This authenticator does not exist. + /// + internal static string PasswordCredentialRoot_NoAuthenticator { + get { + return ResourceManager.GetString("PasswordCredentialRoot_NoAuthenticator", resourceCulture); } } /// /// Looks up a localized string similar to No password has yet been set for this user. /// - internal static string PasswordCredentialsRoot_NoPassword { + internal static string PasswordCredentialRoot_NoPassword { + get { + return ResourceManager.GetString("PasswordCredentialRoot_NoPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This user is not a member of the operations team. + /// + internal static string PasswordCredentialRoot_NotOperator { + get { + return ResourceManager.GetString("PasswordCredentialRoot_NotOperator", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You must be an owner of this user account to change MFA settings. + /// + internal static string PasswordCredentialRoot_NotOwner { get { - return ResourceManager.GetString("PasswordCredentialsRoot_NoPassword", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_NotOwner", resourceCulture); } } /// /// Looks up a localized string similar to Cannot initiate password reset before the user is registered. /// - internal static string PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration { + internal static string PasswordCredentialRoot_PasswordInitiatedWithoutRegistration { get { - return ResourceManager.GetString("PasswordCredentialsRoot_PasswordInitiatedWithoutRegistration", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_PasswordInitiatedWithoutRegistration", resourceCulture); } } /// /// Looks up a localized string similar to The password reset confirmation has expired. /// - internal static string PasswordCredentialsRoot_PasswordResetTokenExpired { + internal static string PasswordCredentialRoot_PasswordResetTokenExpired { get { - return ResourceManager.GetString("PasswordCredentialsRoot_PasswordResetTokenExpired", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_PasswordResetTokenExpired", resourceCulture); } } /// /// Looks up a localized string similar to The user's registration confirmation cannot be confirmed. /// - internal static string PasswordCredentialsRoot_RegistrationNotVerifying { + internal static string PasswordCredentialRoot_RegistrationNotVerifying { get { - return ResourceManager.GetString("PasswordCredentialsRoot_RegistrationNotVerifying", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_RegistrationNotVerifying", resourceCulture); } } /// /// Looks up a localized string similar to The user's registration has not been verified yet. /// - internal static string PasswordCredentialsRoot_RegistrationUnverified { + internal static string PasswordCredentialRoot_RegistrationUnverified { get { - return ResourceManager.GetString("PasswordCredentialsRoot_RegistrationUnverified", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_RegistrationUnverified", resourceCulture); } } /// /// Looks up a localized string similar to The user's registration is already verified. /// - internal static string PasswordCredentialsRoot_RegistrationVerified { + internal static string PasswordCredentialRoot_RegistrationVerified { get { - return ResourceManager.GetString("PasswordCredentialsRoot_RegistrationVerified", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_RegistrationVerified", resourceCulture); } } /// /// Looks up a localized string similar to The user's registration confirmation window has expired. /// - internal static string PasswordCredentialsRoot_RegistrationVerifyingExpired { + internal static string PasswordCredentialRoot_RegistrationVerifyingExpired { get { - return ResourceManager.GetString("PasswordCredentialsRoot_RegistrationVerifyingExpired", resourceCulture); + return ResourceManager.GetString("PasswordCredentialRoot_RegistrationVerifyingExpired", resourceCulture); } } diff --git a/src/IdentityDomain/Resources.resx b/src/IdentityDomain/Resources.resx index 4b8c5d2c..e1388f04 100644 --- a/src/IdentityDomain/Resources.resx +++ b/src/IdentityDomain/Resources.resx @@ -48,34 +48,34 @@ The password reset token is either missing or invalid - + The email is already in use by another user - + Cannot initiate password reset before the user is registered - + The password is not valid - + The user's registration is already verified - + The user's registration confirmation cannot be confirmed - + The user's registration confirmation window has expired - + The user's registration has not been verified yet - + New password cannot be the same as old password - + The password reset confirmation has expired - + No password has yet been set for this user @@ -120,4 +120,82 @@ Only the user can update their own tokens + + MFA cannot be disabled + + + MFA cannot be enabled + + + Cannot change whether you can disable MFA or not + + + MFA is not enabled for this user + + + MFA authentication has not been initiated yet + + + MFA authentication session has expired + + + MFA authentication failed + + + MFA authentication session must have expiry + + + You must be an owner of this user account to change MFA settings + + + You cannot delete the recovery codes for MFA + + + MFA is not enabled for this user + + + This authenticator has already been associated + + + This authenticator cannot be associated + + + The authenticator has not yet been associated + + + The authenticator cannot be confirmed + + + This authenticator does not exist + + + A phone number is required to associate OOB with SMS + + + An email address is required to associate OOB with Email + + + An email address is required to associate OTP Authenticator + + + Recovery codes required to associate Recovery Codes + + + You cannot confirm this type of authenticator + + + The authenticator must be confirmed before being challenged + + + This authenticator cannot be challenged + + + The authenticator must be confirmed before being verified + + + You cannot verify this type of authenticator + + + This user is not a member of the operations team + \ No newline at end of file diff --git a/src/IdentityDomain/Validations.cs b/src/IdentityDomain/Validations.cs index 977f603e..0e686d58 100644 --- a/src/IdentityDomain/Validations.cs +++ b/src/IdentityDomain/Validations.cs @@ -40,6 +40,11 @@ public static class Login public static class Password { + public static readonly Validation ConfirmationCode = new(@"^\d{6}$"); + public static readonly Validation RecoveryConfirmationCode = new(@"^[a-fA-F0-9]{8}$"); + public static readonly Validation MfaPhoneNumber = CommonValidations.PhoneNumber; + public static readonly Validation MfaToken = CommonValidations.RandomToken(); + public static readonly Validation OobCode = CommonValidations.RandomToken(); public static readonly Validation ResetToken = CommonValidations.RandomToken(); public static readonly Validation VerificationToken = CommonValidations.RandomToken(); } diff --git a/src/IdentityInfrastructure.IntegrationTests/MfaApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/MfaApiSpec.cs new file mode 100644 index 00000000..109932d3 --- /dev/null +++ b/src/IdentityInfrastructure.IntegrationTests/MfaApiSpec.cs @@ -0,0 +1,1009 @@ +using System.Net; +using System.Text.Json; +using ApiHost1; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common.Configuration; +using Domain.Services.Shared; +using FluentAssertions; +using IdentityApplication; +using IdentityDomain.DomainServices; +using IdentityInfrastructure.ApplicationServices; +using IdentityInfrastructure.IntegrationTests.Stubs; +using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using IntegrationTesting.WebApi.Common; +using IntegrationTesting.WebApi.Common.Stubs; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.IntegrationTests; + +[UsedImplicitly] +public class MfaApiSpec +{ + [Trait("Category", "Integration.API")] + [Collection("API")] + public class GivenAnUnauthenticatedUser : WebApiSpec + { + private readonly StubMfaService _mfaService; + private readonly StubUserNotificationsService _userNotificationsService; + + public GivenAnUnauthenticatedUser(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + _userNotificationsService = + setup.GetRequiredService().As(); + _userNotificationsService.Reset(); + _mfaService = setup.GetRequiredService().As(); + _mfaService.Reset(); + } + + [Fact] + public async Task WhenListMfaAuthenticatorsWithNone_ThenReturnsEmpty() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var result = await Api.GetAsync(new ListPasswordMfaAuthenticatorsForCallerRequest + { + MfaToken = mfaToken + }); + + result.Content.Value.Authenticators!.Count.Should().Be(0); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorWithOtpAuthenticator_ThenAssociates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, + PhoneNumber = null + }); + + result.Content.Value.Authenticator!.Type.Should() + .Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator); + result.Content.Value.Authenticator.RecoveryCodes.Should().NotBeEmpty(); + result.Content.Value.Authenticator.OobCode.Should().BeNull(); + result.Content.Value.Authenticator.BarCodeUri.Should().StartWith("otpauth://totp/"); + + var authenticators = await GetAuthenticators(mfaToken); + authenticators.Count.Should().Be(2); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeFalse(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorWithOobSms_ThenAssociates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + PhoneNumber = "+6498876986" + }); + + result.Content.Value.Authenticator!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + result.Content.Value.Authenticator.RecoveryCodes.Should().NotBeEmpty(); + result.Content.Value.Authenticator.OobCode.Should().NotBeNullOrEmpty(); + result.Content.Value.Authenticator.BarCodeUri.Should().BeNull(); + _userNotificationsService.LastMfaOobSmsRecipient.Should().Be("+6498876986"); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var authenticators = await GetAuthenticators(mfaToken); + authenticators.Count.Should().Be(2); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeFalse(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorWithOobEmail_ThenAssociates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail, + PhoneNumber = null + }); + + result.Content.Value.Authenticator!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail); + result.Content.Value.Authenticator.RecoveryCodes.Should().NotBeEmpty(); + result.Content.Value.Authenticator.OobCode.Should().NotBeNullOrEmpty(); + result.Content.Value.Authenticator.BarCodeUri.Should().BeNull(); + _userNotificationsService.LastMfaOobEmailRecipient.Should().Be(login.Profile!.EmailAddress); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var authenticators = await GetAuthenticators(mfaToken); + authenticators.Count.Should().Be(2); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeFalse(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAgainForSameAuthenticator_ThenUpdatesAssociation() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + await Associate(PasswordCredentialMfaAuthenticatorType.OobSms, mfaToken, "+6498876981"); + + _userNotificationsService.LastMfaOobSmsRecipient.Should().Be("+6498876981"); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + PhoneNumber = "+6498876982" + }); + + result.Content.Value.Authenticator!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + result.Content.Value.Authenticator.RecoveryCodes.Should().NotBeEmpty(); + result.Content.Value.Authenticator.OobCode.Should().NotBeNullOrEmpty(); + result.Content.Value.Authenticator.BarCodeUri.Should().BeNull(); + _userNotificationsService.LastMfaOobSmsRecipient.Should().Be("+6498876982"); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var authenticators = await GetAuthenticators(mfaToken); + authenticators.Count.Should().Be(2); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeFalse(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAgainForAnotherAuthenticator_ThenForbids() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken); + var confirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken, null, confirmationCode); + mfaToken = await AttemptAuthenticationToGetMfaToken(login); + + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + PhoneNumber = "+6498876986" + }); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationWithOtpAuthenticator_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken); + + var confirmationCode = _mfaService.GetOtpCodeNow(); + var result = await Api.PutAsync(new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, + ConfirmationCode = confirmationCode + }); + + result.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationWithOobSms_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var (oobCode, confirmationCode, _) = + await Associate(PasswordCredentialMfaAuthenticatorType.OobSms, mfaToken); + + var result = await Api.PutAsync(new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + OobCode = oobCode, + ConfirmationCode = confirmationCode + }); + + result.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationWithOobEmail_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var (oobCode, confirmationCode, _) = + await Associate(PasswordCredentialMfaAuthenticatorType.OobEmail, mfaToken); + + var result = await Api.PutAsync(new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail, + OobCode = oobCode, + ConfirmationCode = confirmationCode + }); + + result.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorWithOtpAuthenticator_ThenChallenges() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken); + var confirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken, null, confirmationCode); + mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var authenticator = + await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken); + + var result = await Api.PutAsync(new ChallengePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorId = authenticator!.Id + }); + + result.Content.Value.Challenge!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator); + result.Content.Value.Challenge!.OobCode.Should().BeNull(); + _userNotificationsService.LastMfaOobSmsRecipient.Should().BeNull(); + _userNotificationsService.LastMfaOobEmailRecipient.Should().BeNull(); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorWithOobSms_ThenChallenges() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var (oobCode, confirmationCode, _) = + await Associate(PasswordCredentialMfaAuthenticatorType.OobSms, mfaToken); + await Confirm(PasswordCredentialMfaAuthenticatorType.OobSms, mfaToken, oobCode, confirmationCode); + mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var authenticator = await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.OobSms, mfaToken); + + var result = await Api.PutAsync(new ChallengePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorId = authenticator!.Id + }); + + result.Content.Value.Challenge!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + result.Content.Value.Challenge!.OobCode.Should().NotBeNullOrEmpty(); + _userNotificationsService.LastMfaOobSmsRecipient.Should().Be("+6498876986"); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + } + + [Fact] + public async Task WhenChallengeMfaAuthenticatorWithOobEmail_ThenChallenges() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var (oobCode, confirmationCode, _) = + await Associate(PasswordCredentialMfaAuthenticatorType.OobEmail, mfaToken); + await Confirm(PasswordCredentialMfaAuthenticatorType.OobEmail, mfaToken, oobCode, confirmationCode); + mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var authenticator = await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.OobEmail, mfaToken); + + var result = await Api.PutAsync(new ChallengePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorId = authenticator!.Id + }); + + result.Content.Value.Challenge!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail); + result.Content.Value.Challenge!.OobCode.Should().NotBeNullOrEmpty(); + _userNotificationsService.LastMfaOobEmailRecipient.Should().Be(login.Profile!.EmailAddress); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + } + + [Fact] + public async Task WhenVerifyMfaAuthenticatorWithOtpAuthenticator_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken); + var confirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken, null, confirmationCode); + mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var authenticator = + await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken); + await Challenge(mfaToken, authenticator); + confirmationCode = _mfaService.GetOtpCodeNow(MfaService.TimeStep.Next); //One time step ahead + + var result = await Api.PutAsync(new VerifyPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, + OobCode = null, + ConfirmationCode = confirmationCode + }); + + result.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); + } + + [Fact] + public async Task WhenVerifyMfaAuthenticatorWithOobSms_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var (oobCode, confirmationCode, _) = + await Associate(PasswordCredentialMfaAuthenticatorType.OobSms, mfaToken); + await Confirm(PasswordCredentialMfaAuthenticatorType.OobSms, mfaToken, oobCode, confirmationCode); + mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var authenticator = await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.OobSms, mfaToken); + (oobCode, confirmationCode) = await Challenge(mfaToken, authenticator); + + var result = await Api.PutAsync(new VerifyPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + OobCode = oobCode, + ConfirmationCode = confirmationCode + }); + + result.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); + } + + [Fact] + public async Task WhenVerifyMfaAuthenticatorWithOobEmail_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var (oobCode, confirmationCode, _) = + await Associate(PasswordCredentialMfaAuthenticatorType.OobEmail, mfaToken); + await Confirm(PasswordCredentialMfaAuthenticatorType.OobEmail, mfaToken, oobCode, confirmationCode); + mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var authenticator = await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.OobEmail, mfaToken); + (oobCode, confirmationCode) = await Challenge(mfaToken, authenticator); + + var result = await Api.PutAsync(new VerifyPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail, + OobCode = oobCode, + ConfirmationCode = confirmationCode + }); + + result.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); + } + + [Fact] + public async Task WhenVerifyMfaAuthenticatorWithARecoveryCode_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var (_, _, recoveryCodes) = + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken); + var confirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, mfaToken, null, confirmationCode); + mfaToken = await AttemptAuthenticationToGetMfaToken(login); + var authenticator = await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.RecoveryCodes, mfaToken); + await Challenge(mfaToken, authenticator); + + var result = await Api.PutAsync(new VerifyPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.RecoveryCodes, + OobCode = null, + ConfirmationCode = recoveryCodes![0] + }); + + result.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); + } + + private async Task<(string OobCode, string ConfirmationCode)> Challenge(string mfaToken, + PasswordCredentialMfaAuthenticator? authenticator) + { + var challenged = await Api.PutAsync(new ChallengePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorId = authenticator!.Id + }); + + var oobCode = challenged.Content.Value.Challenge!.OobCode!; + var confirmationCode = _mfaService.LastOobConfirmationCode!; + + return (oobCode, confirmationCode); + } + + private async Task GetAuthenticator( + PasswordCredentialMfaAuthenticatorType type, string mfaToken) + { + var authenticators = await Api.GetAsync(new ListPasswordMfaAuthenticatorsForCallerRequest + { + MfaToken = mfaToken + }); + + return authenticators.Content.Value.Authenticators! + .FirstOrDefault(auth => auth.Type == type); + } + + private async Task> GetAuthenticators(string mfaToken) + { + var authenticators = await Api.GetAsync(new ListPasswordMfaAuthenticatorsForCallerRequest + { + MfaToken = mfaToken + }); + + return authenticators.Content.Value.Authenticators!; + } + + private async Task Confirm(PasswordCredentialMfaAuthenticatorType type, string mfaToken, string? oobCode = null, + string? confirmationCode = null) + { + await Api.PutAsync(new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = type, + OobCode = oobCode, + ConfirmationCode = confirmationCode + }); + } + + private async Task<(string OobCode, string ConfirmationCode, List? recoveryCodes)> Associate( + PasswordCredentialMfaAuthenticatorType type, string mfaToken, string phoneNumber = "+6498876986") + { + var associated = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = mfaToken, + AuthenticatorType = type, + PhoneNumber = type == PasswordCredentialMfaAuthenticatorType.OobSms + ? phoneNumber + : null + }); + + var oobCode = associated.Content.Value.Authenticator!.OobCode!; + var confirmationCode = _mfaService.LastOobConfirmationCode!; + var recoveryCodes = associated.Content.Value.Authenticator!.RecoveryCodes; + + return (oobCode, confirmationCode, recoveryCodes); + } + + private async Task AttemptAuthenticationToGetMfaToken(LoginDetails login) + { + var failedAuth = await Api.PostAsync(new AuthenticatePasswordRequest + { + Username = login.Profile!.EmailAddress, + Password = PasswordForPerson + }); + + return failedAuth.Content.Error.Extensions![PasswordCredentialsApplication.MfaTokenName] + .As().GetString()!; + } + + private async Task EnableMfa(LoginDetails login) + { + await Api.PutAsync(new ChangePasswordMfaForCallerRequest + { + IsEnabled = true + }, req => req.SetJWTBearerToken(login.AccessToken)); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddSingleton(c => + new StubMfaService(c.GetRequiredServiceForPlatform(), + c.GetRequiredService())); + } + } + + [Trait("Category", "Integration.API")] + [Collection("API")] + public class GivenAnAuthenticatedUser : WebApiSpec + { + private readonly StubMfaService _mfaService; + private readonly StubUserNotificationsService _userNotificationsService; + + public GivenAnAuthenticatedUser(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + _userNotificationsService = + setup.GetRequiredService().As(); + _userNotificationsService.Reset(); + _mfaService = setup.GetRequiredService().As(); + _mfaService.Reset(); + } + + [Fact] + public async Task WhenEnableMfa_ThenEnables() + { + var login = await LoginUserAsync(); + + var result = await Api.PutAsync(new ChangePasswordMfaForCallerRequest + { + IsEnabled = true + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Credential!.Id.Should().NotBeEmpty(); + result.Content.Value.Credential.IsMfaEnabled.Should().BeTrue(); + } + + [Fact] + public async Task WhenDisableMfa_ThenDisables() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + + var result = await Api.PutAsync(new ChangePasswordMfaForCallerRequest + { + IsEnabled = false + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Credential!.Id.Should().NotBeEmpty(); + result.Content.Value.Credential.IsMfaEnabled.Should().BeFalse(); + } + + [Fact] + public async Task WhenDisableMfa_ThenDeletesAllAuthenticatorsAndDisables() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login); + var confirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login, null, confirmationCode); + + var result = await Api.PutAsync(new ChangePasswordMfaForCallerRequest + { + IsEnabled = false + }, req => req.SetJWTBearerToken(login.AccessToken)); + + await EnableMfa(login); + + result.Content.Value.Credential!.IsMfaEnabled.Should().BeFalse(); + var authenticators = await GetAuthenticators(login); + authenticators.Count.Should().Be(0); + } + + [Fact] + public async Task WhenListMfaAuthenticatorsWithNone_ThenReturnsEmpty() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var result = await Api.GetAsync(new ListPasswordMfaAuthenticatorsForCallerRequest(), + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Authenticators!.Count.Should().Be(0); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorWithOtpAuthenticator_ThenAssociates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, + PhoneNumber = null + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Authenticator!.Type.Should() + .Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator); + result.Content.Value.Authenticator.RecoveryCodes.Should().NotBeEmpty(); + result.Content.Value.Authenticator.OobCode.Should().BeNull(); + result.Content.Value.Authenticator.BarCodeUri.Should().StartWith("otpauth://totp/"); + + var authenticators = await GetAuthenticators(login); + authenticators.Count.Should().Be(2); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeFalse(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorWithOobSms_ThenAssociates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + PhoneNumber = "+6498876986" + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Authenticator!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + result.Content.Value.Authenticator.RecoveryCodes.Should().NotBeEmpty(); + result.Content.Value.Authenticator.OobCode.Should().NotBeNullOrEmpty(); + result.Content.Value.Authenticator.BarCodeUri.Should().BeNull(); + _userNotificationsService.LastMfaOobSmsRecipient.Should().Be("+6498876986"); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var authenticators = await GetAuthenticators(login); + authenticators.Count.Should().Be(2); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeFalse(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorWithOobEmail_ThenAssociates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail, + PhoneNumber = null + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Authenticator!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail); + result.Content.Value.Authenticator.RecoveryCodes.Should().NotBeEmpty(); + result.Content.Value.Authenticator.OobCode.Should().NotBeNullOrEmpty(); + result.Content.Value.Authenticator.BarCodeUri.Should().BeNull(); + _userNotificationsService.LastMfaOobEmailRecipient.Should().Be(login.Profile!.EmailAddress); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var authenticators = await GetAuthenticators(login); + authenticators.Count.Should().Be(2); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeFalse(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAgainForSameAuthenticator_ThenUpdatesAssociation() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + await Associate(PasswordCredentialMfaAuthenticatorType.OobSms, login, "+6498876981"); + + _userNotificationsService.LastMfaOobSmsRecipient.Should().Be("+6498876981"); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + PhoneNumber = "+6498876982" + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Authenticator!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + result.Content.Value.Authenticator.RecoveryCodes.Should().NotBeEmpty(); + result.Content.Value.Authenticator.OobCode.Should().NotBeNullOrEmpty(); + result.Content.Value.Authenticator.BarCodeUri.Should().BeNull(); + _userNotificationsService.LastMfaOobSmsRecipient.Should().Be("+6498876982"); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var authenticators = await GetAuthenticators(login); + authenticators.Count.Should().Be(2); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeFalse(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + } + + [Fact] + public async Task WhenAssociateMfaAuthenticatorAgainForAnotherAuthenticator_ThenAssociates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login); + var confirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login, null, confirmationCode); + + var result = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + PhoneNumber = "+6498876986" + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Authenticator!.Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + result.Content.Value.Authenticator.RecoveryCodes.Should().BeNull(); + result.Content.Value.Authenticator.OobCode.Should().NotBeNullOrEmpty(); + result.Content.Value.Authenticator.BarCodeUri.Should().BeNull(); + _userNotificationsService.LastMfaOobSmsRecipient.Should().Be("+6498876986"); + _userNotificationsService.LastMfaOobCode.Should().NotBeEmpty(); + + var authenticators = await GetAuthenticators(login); + authenticators.Count.Should().Be(3); + authenticators[0].Id.Should().NotBeEmpty(); + authenticators[0].IsActive.Should().BeTrue(); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Id.Should().NotBeEmpty(); + authenticators[1].IsActive.Should().BeTrue(); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator); + authenticators[2].Id.Should().NotBeEmpty(); + authenticators[2].IsActive.Should().BeFalse(); + authenticators[2].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + } + + [Fact] + public async Task + WhenDisassociateMfaAuthenticatorForFirstAuthenticator_ThenDeletesAuthenticatorAndRecoveryCodes() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login); + var confirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login, null, confirmationCode); + + var authenticator = await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login); + var result = await Api.DeleteAsync(new DisassociatePasswordMfaAuthenticatorForCallerRequest + { + Id = authenticator!.Id + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var authenticators = await GetAuthenticators(login); + authenticators.Count.Should().Be(0); + } + + [Fact] + public async Task WhenDisassociateMfaAuthenticatorForSecondAuthenticator_ThenDeletesAuthenticatorOnly() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login); + var otpConfirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login, null, otpConfirmationCode); + var (oobCode, ooBConfirmationCode, _) = + await Associate(PasswordCredentialMfaAuthenticatorType.OobSms, login); + await Confirm(PasswordCredentialMfaAuthenticatorType.OobSms, login, oobCode, ooBConfirmationCode); + var authenticator = await GetAuthenticator(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login); + + var result = await Api.DeleteAsync(new DisassociatePasswordMfaAuthenticatorForCallerRequest + { + Id = authenticator!.Id + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var authenticators = await GetAuthenticators(login); + authenticators.Count.Should().Be(2); + authenticators[0].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationWithOtpAuthenticator_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login); + var confirmationCode = _mfaService.GetOtpCodeNow(); + + var result = await Api.PutAsync(new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, + ConfirmationCode = confirmationCode + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Tokens.Should().BeNull(); + result.Content.Value.Authenticators!.Count.Should().Be(2); + result.Content.Value.Authenticators[0].Type.Should() + .Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + result.Content.Value.Authenticators[0].IsActive.Should().BeTrue(); + result.Content.Value.Authenticators[1].Type.Should() + .Be(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator); + result.Content.Value.Authenticators[1].IsActive.Should().BeTrue(); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationWithOobSms_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var (oobCode, confirmationCode, _) = await Associate(PasswordCredentialMfaAuthenticatorType.OobSms, login); + + var result = await Api.PutAsync(new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobSms, + OobCode = oobCode, + ConfirmationCode = confirmationCode + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Tokens.Should().BeNull(); + result.Content.Value.Authenticators!.Count.Should().Be(2); + result.Content.Value.Authenticators[0].Type.Should() + .Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + result.Content.Value.Authenticators[0].IsActive.Should().BeTrue(); + result.Content.Value.Authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobSms); + result.Content.Value.Authenticators[1].IsActive.Should().BeTrue(); + } + + [Fact] + public async Task WhenConfirmMfaAuthenticatorAssociationWithOobEmail_ThenAuthenticates() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + var (oobCode, confirmationCode, _) = + await Associate(PasswordCredentialMfaAuthenticatorType.OobEmail, login); + + var result = await Api.PutAsync(new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail, + OobCode = oobCode, + ConfirmationCode = confirmationCode + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Tokens.Should().BeNull(); + result.Content.Value.Authenticators!.Count.Should().Be(2); + result.Content.Value.Authenticators[0].Type.Should() + .Be(PasswordCredentialMfaAuthenticatorType.RecoveryCodes); + result.Content.Value.Authenticators[0].IsActive.Should().BeTrue(); + result.Content.Value.Authenticators[1].Type.Should().Be(PasswordCredentialMfaAuthenticatorType.OobEmail); + result.Content.Value.Authenticators[1].IsActive.Should().BeTrue(); + } + + [Fact] + public async Task WhenResetUserMfaByOperator_ThenResetsMfaToDefault() + { + var login = await LoginUserAsync(); + await EnableMfa(login); + await Associate(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login); + var confirmationCode = _mfaService.GetOtpCodeNow(); + await Confirm(PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, login, null, confirmationCode); + + var @operator = await LoginUserAsync(LoginUser.Operator); + var result = await Api.PutAsync(new ResetPasswordMfaRequest + { + UserId = login.User.Id + }, req => req.SetJWTBearerToken(@operator.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.Accepted); + + await EnableMfa(login); + var authenticators = await GetAuthenticators(login); + + authenticators.Count.Should().Be(0); + } + + private async Task GetAuthenticator( + PasswordCredentialMfaAuthenticatorType type, LoginDetails login) + { + var authenticators = await GetAuthenticators(login); + + return authenticators + .FirstOrDefault(auth => auth.Type == type); + } + + private async Task<(string OobCode, string ConfirmationCode, List? recoveryCodes)> Associate( + PasswordCredentialMfaAuthenticatorType type, LoginDetails login, string phoneNumber = "+6498876986") + { + var associated = await Api.PostAsync(new AssociatePasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = type, + PhoneNumber = type == PasswordCredentialMfaAuthenticatorType.OobSms + ? phoneNumber + : null + }, + req => req.SetJWTBearerToken(login.AccessToken)); + + var oobCode = associated.Content.Value.Authenticator!.OobCode!; + var confirmationCode = _mfaService.LastOobConfirmationCode!; + var recoveryCodes = associated.Content.Value.Authenticator!.RecoveryCodes; + + return (oobCode, confirmationCode, recoveryCodes); + } + + private async Task Confirm(PasswordCredentialMfaAuthenticatorType type, LoginDetails login, + string? oobCode = null, string? confirmationCode = null) + { + await Api.PutAsync(new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + AuthenticatorType = type, + OobCode = oobCode, + ConfirmationCode = confirmationCode + }, + req => req.SetJWTBearerToken(login.AccessToken)); + } + + private async Task> GetAuthenticators(LoginDetails login) + { + var authenticators = await Api.GetAsync(new ListPasswordMfaAuthenticatorsForCallerRequest(), + req => req.SetJWTBearerToken(login.AccessToken)); + + return authenticators.Content.Value.Authenticators!; + } + + private async Task EnableMfa(LoginDetails login) + { + await Api.PutAsync(new ChangePasswordMfaForCallerRequest + { + IsEnabled = true + }, req => req.SetJWTBearerToken(login.AccessToken)); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddSingleton(c => + new StubMfaService(c.GetRequiredServiceForPlatform(), + c.GetRequiredService())); + } + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs index 601413e9..5eb1856c 100644 --- a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs +++ b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs @@ -1,10 +1,12 @@ using System.Net; +using System.Text.Json; using ApiHost1; using Application.Resources.Shared; using Application.Services.Shared; using Common; using Domain.Interfaces.Authorization; using FluentAssertions; +using IdentityApplication; using IdentityDomain; using Infrastructure.Interfaces; using Infrastructure.Shared.DomainServices; @@ -127,6 +129,28 @@ await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); } + [Fact] + public async Task WhenAuthenticateAndMfaEnabled_ThenReturnsMfaContinuance() + { + var login = await LoginUserAsync(); + await Api.PutAsync(new ChangePasswordMfaForCallerRequest + { + IsEnabled = true, + }, req => req.SetJWTBearerToken(login.AccessToken)); + + var result = await Api.PostAsync(new AuthenticatePasswordRequest + { + Username = login.Profile!.EmailAddress, + Password = PasswordForPerson + }); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + result.Content.Error.Title.Should().Be(PasswordCredentialsApplication.MfaRequiredCode); + result.Content.Error.Extensions!.Count.Should().Be(1); + result.Content.Error.Extensions[PasswordCredentialsApplication.MfaTokenName].As().GetString() + .Should().NotBeEmpty(); + } + [Fact] public async Task WhenCallingSecureApiAfterAuthenticate_ThenReturnsResponse() { diff --git a/src/IdentityInfrastructure.IntegrationTests/Stubs/StubMfaService.cs b/src/IdentityInfrastructure.IntegrationTests/Stubs/StubMfaService.cs new file mode 100644 index 00000000..cdaa605e --- /dev/null +++ b/src/IdentityInfrastructure.IntegrationTests/Stubs/StubMfaService.cs @@ -0,0 +1,76 @@ +using Common.Configuration; +using Common.Extensions; +using Domain.Services.Shared; +using IdentityDomain.DomainServices; +using IdentityInfrastructure.ApplicationServices; + +namespace IdentityInfrastructure.IntegrationTests.Stubs; + +public class StubMfaService : IMfaService +{ + private readonly MfaService _mfaService; + + public StubMfaService(IConfigurationSettings settings, ITokensService tokensService) + { + _mfaService = new MfaService(settings, tokensService); + } + + public string? LastOobConfirmationCode { get; private set; } + + public string? LastOtpSecret { get; private set; } + + public string GenerateOobCode() + { + return _mfaService.GenerateOobCode(); + } + + public string GenerateOobSecret() + { + var code = _mfaService.GenerateOobSecret(); + LastOobConfirmationCode = code; + return code; + } + + public string GenerateOtpBarcodeUri(string username, string secret) + { + return _mfaService.GenerateOtpBarcodeUri(username, secret); + } + + public string GenerateOtpSecret() + { + var secret = _mfaService.GenerateOtpSecret(); + LastOtpSecret = secret; + return secret; + } + + public int GetTotpMaxTimeSteps() + { + return _mfaService.GetTotpMaxTimeSteps(); + } + + public string GetOtpCodeNow(MfaService.TimeStep timeStep = MfaService.TimeStep.Now) + { + if (LastOtpSecret.NotExists()) + { + return string.Empty; + } + +#if TESTINGONLY + var confirmationCode = MfaService.CalculateTotp(LastOtpSecret, timeStep); +#else + var confirmationCode = ""; +#endif + return confirmationCode; + } + + public TotpResult VerifyTotp(string secret, IReadOnlyList previousTimeSteps, string confirmationCode) + { + return _mfaService.VerifyTotp(secret, previousTimeSteps, confirmationCode); + } + + public void Reset() + { + LastOobConfirmationCode = null; + LastOtpSecret = null; + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MFA/AssociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/MFA/AssociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs new file mode 100644 index 00000000..95292f7c --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MFA/AssociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs @@ -0,0 +1,75 @@ +using Application.Resources.Shared; +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.MFA; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MFA; + +[Trait("Category", "Unit")] +public class AssociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec +{ + private readonly AssociatePasswordMfaAuthenticatorForCallerRequest _dto; + private readonly AssociatePasswordMfaAuthenticatorForCallerRequestValidator _validator; + + public AssociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec() + { + _validator = new AssociatePasswordMfaAuthenticatorForCallerRequestValidator(); + _dto = new AssociatePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = new TokensService().CreateMfaAuthenticationToken(), + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenMfaTokenInvalid_ThenThrows() + { + _dto.MfaToken = "aninvalidtoken"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + } + + [Fact] + public void WhenIsPhoneIsInvalid_ThenThrows() + { + _dto.PhoneNumber = "aninvalidphone"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AssociatePasswordMfaAuthenticatorForCallerRequestValidator_InvalidPhoneNumber); + } + + [Fact] + public void WhenAuthenticatorTypeIsNone_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.None; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources + .PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType); + } + + [Fact] + public void WhenAuthenticatorTypeIsRecoveryCodes_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.RecoveryCodes; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources + .PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MFA/ChallengePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/MFA/ChallengePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs new file mode 100644 index 00000000..0e092df2 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MFA/ChallengePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs @@ -0,0 +1,44 @@ +using Domain.Common.Identity; +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.MFA; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MFA; + +[Trait("Category", "Unit")] +public class ChallengePasswordMfaAuthenticatorForCallerRequestValidatorSpec +{ + private readonly ChallengePasswordMfaAuthenticatorForCallerRequest _dto; + private readonly ChallengePasswordMfaAuthenticatorForCallerRequestValidator _validator; + + public ChallengePasswordMfaAuthenticatorForCallerRequestValidatorSpec() + { + _validator = new ChallengePasswordMfaAuthenticatorForCallerRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new ChallengePasswordMfaAuthenticatorForCallerRequest + { + MfaToken = new TokensService().CreateMfaAuthenticationToken(), + AuthenticatorId = "anid" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenMfaTokenInvalid_ThenThrows() + { + _dto.MfaToken = "aninvalidtoken"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike( + Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MFA/ChangePasswordMfaForCallerRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/MFA/ChangePasswordMfaForCallerRequestValidatorSpec.cs new file mode 100644 index 00000000..1762436c --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MFA/ChangePasswordMfaForCallerRequestValidatorSpec.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using IdentityInfrastructure.Api.MFA; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MFA; + +[Trait("Category", "Unit")] +public class ChangePasswordMfaForCallerRequestValidatorSpec +{ + private readonly ChangePasswordMfaForCallerRequest _dto; + private readonly ChangePasswordMfaForCallerRequestValidator _validator; + + public ChangePasswordMfaForCallerRequestValidatorSpec() + { + _validator = new ChangePasswordMfaForCallerRequestValidator(); + _dto = new ChangePasswordMfaForCallerRequest + { + IsEnabled = true + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MFA/ConfirmPasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/MFA/ConfirmPasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs new file mode 100644 index 00000000..67610b21 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MFA/ConfirmPasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs @@ -0,0 +1,100 @@ +using Application.Resources.Shared; +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.MFA; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MFA; + +[Trait("Category", "Unit")] +public class ConfirmPasswordMfaAuthenticatorForCallerRequestValidatorSpec +{ + private readonly ConfirmPasswordMfaAuthenticatorForCallerRequest _dto; + private readonly ConfirmPasswordMfaAuthenticatorForCallerRequestValidator _validator; + + public ConfirmPasswordMfaAuthenticatorForCallerRequestValidatorSpec() + { + _validator = + new ConfirmPasswordMfaAuthenticatorForCallerRequestValidator(); + _dto = new ConfirmPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = new TokensService().CreateMfaAuthenticationToken(), + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, + ConfirmationCode = "123456" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenMfaTokenInvalid_ThenThrows() + { + _dto.MfaToken = "aninvalidtoken"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + } + + [Fact] + public void WhenOobCodeIsNullAndOobAuthenticator_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail; + _dto.OobCode = null; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidOobCode); + } + + [Fact] + public void WhenOobCodeIsInvalidAndOobAuthenticator_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail; + _dto.OobCode = "aninvalidoobcode"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidOobCode); + } + + [Fact] + public void WhenConfirmationCodeIsInvalid_ThenThrows() + { + _dto.ConfirmationCode = "aninvalidconfirmationcode"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources + .ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidConfirmationCode); + } + + [Fact] + public void WhenAuthenticatorTypeIsNone_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.None; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources + .PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType); + } + + [Fact] + public void WhenAuthenticatorTypeIsRecoveryCodes_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.RecoveryCodes; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources + .PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MFA/DisassociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/MFA/DisassociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs new file mode 100644 index 00000000..22476c37 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MFA/DisassociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec.cs @@ -0,0 +1,30 @@ +using Domain.Common.Identity; +using FluentValidation; +using IdentityInfrastructure.Api.MFA; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MFA; + +[Trait("Category", "Unit")] +public class DisassociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec +{ + private readonly DisassociatePasswordMfaAuthenticatorForCallerRequest _dto; + private readonly DisassociatePasswordMfaAuthenticatorForCallerRequestValidator _validator; + + public DisassociatePasswordMfaAuthenticatorForCallerRequestValidatorSpec() + { + _validator = + new DisassociatePasswordMfaAuthenticatorForCallerRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new DisassociatePasswordMfaAuthenticatorForCallerRequest + { + Id = "anid" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MFA/ListPasswordMfaAuthenticatorsForCallerRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/MFA/ListPasswordMfaAuthenticatorsForCallerRequestValidatorSpec.cs new file mode 100644 index 00000000..b7af6b07 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MFA/ListPasswordMfaAuthenticatorsForCallerRequestValidatorSpec.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.MFA; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MFA; + +[Trait("Category", "Unit")] +public class ListPasswordMfaAuthenticatorsForCallerRequestValidatorSpec +{ + private readonly ListPasswordMfaAuthenticatorsForCallerRequest _dto; + private readonly ListPasswordMfaAuthenticatorsForCallerRequestValidator _validator; + + public ListPasswordMfaAuthenticatorsForCallerRequestValidatorSpec() + { + _validator = new ListPasswordMfaAuthenticatorsForCallerRequestValidator(); + _dto = new ListPasswordMfaAuthenticatorsForCallerRequest + { + MfaToken = new TokensService().CreateMfaAuthenticationToken() + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenMfaTokenInvalid_ThenThrows() + { + _dto.MfaToken = "aninvalidtoken"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MFA/ResetPasswordMfaRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/MFA/ResetPasswordMfaRequestValidatorSpec.cs new file mode 100644 index 00000000..c4447b6e --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MFA/ResetPasswordMfaRequestValidatorSpec.cs @@ -0,0 +1,30 @@ +using Domain.Common.Identity; +using FluentValidation; +using IdentityInfrastructure.Api.MFA; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MFA; + +[Trait("Category", "Unit")] +public class ResetPasswordMfaRequestValidatorSpec +{ + private readonly ResetPasswordMfaRequest _dto; + private readonly ResetPasswordMfaRequestValidator _validator; + + public ResetPasswordMfaRequestValidatorSpec() + { + _validator = + new ResetPasswordMfaRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new ResetPasswordMfaRequest + { + UserId = "anid" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/MFA/VerifyPasswordMfaAuthenticatorForCallerRequestValidator.cs b/src/IdentityInfrastructure.UnitTests/Api/MFA/VerifyPasswordMfaAuthenticatorForCallerRequestValidator.cs new file mode 100644 index 00000000..00617307 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/Api/MFA/VerifyPasswordMfaAuthenticatorForCallerRequestValidator.cs @@ -0,0 +1,112 @@ +using Application.Resources.Shared; +using FluentAssertions; +using FluentValidation; +using IdentityInfrastructure.Api.MFA; +using Infrastructure.Shared.DomainServices; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.Api.MFA; + +[Trait("Category", "Unit")] +public class VerifyPasswordMfaAuthenticatorForCallerRequestValidatorSpec +{ + private readonly VerifyPasswordMfaAuthenticatorForCallerRequest _dto; + private readonly VerifyPasswordMfaAuthenticatorForCallerRequestValidator _validator; + + public VerifyPasswordMfaAuthenticatorForCallerRequestValidatorSpec() + { + _validator = + new VerifyPasswordMfaAuthenticatorForCallerRequestValidator(); + _dto = new VerifyPasswordMfaAuthenticatorForCallerRequest + { + MfaToken = new TokensService().CreateMfaAuthenticationToken(), + AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator, + ConfirmationCode = "123456" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenMfaTokenIsNull_ThenThrows() + { + _dto.MfaToken = null; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + } + + [Fact] + public void WhenMfaTokenInvalid_ThenThrows() + { + _dto.MfaToken = "aninvalidtoken"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + } + + [Fact] + public void WhenOobCodeIsNullAndOobAuthenticator_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail; + _dto.OobCode = null; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidOobCode); + } + + [Fact] + public void WhenOobCodeIsInvalidAndOobAuthenticator_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.OobEmail; + _dto.OobCode = "aninvalidoobcode"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidOobCode); + } + + [Fact] + public void WhenConfirmationCodeForOobIsInvalid_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.TotpAuthenticator; + _dto.ConfirmationCode = "aninvalidconfirmationcode"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources + .ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidConfirmationCode); + } + + [Fact] + public void WhenConfirmationCodeForRecoveryCodesIsInvalid_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.RecoveryCodes; + _dto.ConfirmationCode = "aninvalidconfirmationcode"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources + .ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidRecoveryConfirmationCode); + } + + [Fact] + public void WhenAuthenticatorTypeIsNone_ThenThrows() + { + _dto.AuthenticatorType = PasswordCredentialMfaAuthenticatorType.None; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources + .PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/ApplicationServices/MfaServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/ApplicationServices/MfaServiceSpec.cs new file mode 100644 index 00000000..e0f68ef2 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/ApplicationServices/MfaServiceSpec.cs @@ -0,0 +1,104 @@ +using Common.Configuration; +using Domain.Services.Shared; +using FluentAssertions; +using IdentityInfrastructure.ApplicationServices; +using Moq; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.ApplicationServices; + +[Trait("Category", "Unit")] +public class MfaServiceSpec +{ + private readonly MfaService _mfaService; + private readonly Mock _tokensService; + + public MfaServiceSpec() + { + var settings = new Mock(); + settings.Setup(x => x.Platform.GetString(MfaService.PlatformNameSettingName, It.IsAny())) + .Returns("anissuer"); + _tokensService = new Mock(); + _mfaService = new MfaService(settings.Object, _tokensService.Object); + } + + [Fact] + public void WhenGenerateOobSecret_ThenCreatesRandomSixDigits() + { + var result = _mfaService.GenerateOobSecret(); + + result.Length.Should().Be(6); + result.Should().MatchRegex(@"^\d{6}$"); + } + + [Fact] + public void WhenGenerateOtpSecret_ThenReturnsRandomBase32() + { + _tokensService.Setup(x => x.GenerateRandomToken()) + .Returns("=/\\_-+1234567890abcdefghijklmnopqrstuvwxyz"); + var result = _mfaService.GenerateOtpSecret(); + + result.Length.Should().Be(MfaService.OtpSecretLength); + result.Should().MatchRegex(@"^[A-Z2-7]{16}$"); + } + + [Fact] + public void WhenGenerateOtpBarcodeUri_ThenReturnsUri() + { + var result = _mfaService.GenerateOtpBarcodeUri("auserid", "asecret"); + + result.Should() + .Be( + $"otpauth://totp/anissuer:auserid?" + + $"secret=asecret" + + $"&issuer=anissuer" + + $"&algorithm={MfaService.HashMode.ToString().ToUpper()}" + + $"&digits={MfaService.SizeOfCode}" + + $"&period={MfaService.CodeRenewPeriod}"); + } + + [Fact] + public void WhenVerifyTotpAndWrongConfirmationCode_ThenReturnsFail() + { + const string confirmationCode = "111111"; + var result = _mfaService.VerifyTotp("asecret", new List(), confirmationCode); + + result.IsValid.Should().BeFalse(); + result.TimeStepMatched.Should().BeNull(); + } + + [Fact] + public void WhenVerifyTotpAndCorrectConfirmationCode_ThenReturnsSuccess() + { +#if TESTINGONLY + var confirmationCode = MfaService.CalculateTotp("asecret"); +#else + var confirmationCode = "123456"; +#endif + + var result = _mfaService.VerifyTotp("asecret", new List(), confirmationCode); + + result.IsValid.Should().BeTrue(); + result.TimeStepMatched.Should().NotBeNull(); + } + + [Fact] + public void WhenVerifyTotpAndCorrectConfirmationCodeAndReplayed_ThenReturnsFail() + { +#if TESTINGONLY + var confirmationCode = MfaService.CalculateTotp("asecret"); +#else + var confirmationCode = "123456"; +#endif + var previous = _mfaService.VerifyTotp("asecret", new List(), confirmationCode); + + previous.IsValid.Should().BeTrue(); + + var previousTimeSteps = new List { previous.TimeStepMatched!.Value }; + + var result = _mfaService.VerifyTotp("asecret", previousTimeSteps, confirmationCode); + + result.IsValid.Should().BeFalse(); + result.TimeStepMatched.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs index 584a3a1e..e8c2ef12 100644 --- a/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs @@ -17,6 +17,8 @@ namespace IdentityInfrastructure.UnitTests.DomainServices; public class EmailAddressServiceSpec { private readonly Mock _emailAddressService; + private readonly Mock _encryptionService; + private readonly Mock _mfaService; private readonly Mock _passwordHasherService; private readonly Mock _recorder; private readonly Mock _repository; @@ -32,7 +34,9 @@ public EmailAddressServiceSpec() _emailAddressService.Setup(es => es.EnsureUniqueAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); _tokensService = new Mock(); + _encryptionService = new Mock(); _passwordHasherService = new Mock(); + _mfaService = new Mock(); _settings = new Mock(); _settings.Setup(s => s.Platform.GetString(It.IsAny(), It.IsAny())) .Returns((string?)null!); @@ -81,8 +85,9 @@ public async Task WhenEnsureUniqueAsyncAndNotMatchesUserId_ThenReturnsFalse() private PasswordCredentialRoot CreateCredential(string userId) { var credential = PasswordCredentialRoot.Create(_recorder.Object, "acredentialid".ToIdentifierFactory(), - _settings.Object, _emailAddressService.Object, _tokensService.Object, _passwordHasherService.Object, - userId.ToId()).Value; + _settings.Object, _emailAddressService.Object, _tokensService.Object, _encryptionService.Object, + _passwordHasherService.Object, + _mfaService.Object, userId.ToId()).Value; credential.SetPasswordCredential("apassword"); credential.SetRegistrationDetails(EmailAddress.Create("auser@company.com").Value, PersonDisplayName.Create("aname").Value); diff --git a/src/IdentityInfrastructure/Api/MFA/AssociatePasswordMfaAuthenticatorForCallerRequestValidator.cs b/src/IdentityInfrastructure/Api/MFA/AssociatePasswordMfaAuthenticatorForCallerRequestValidator.cs new file mode 100644 index 00000000..8c38aef4 --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/AssociatePasswordMfaAuthenticatorForCallerRequestValidator.cs @@ -0,0 +1,36 @@ +using Application.Resources.Shared; +using Common.Extensions; +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class + AssociatePasswordMfaAuthenticatorForCallerRequestValidator : AbstractValidator< + AssociatePasswordMfaAuthenticatorForCallerRequest> +{ + public AssociatePasswordMfaAuthenticatorForCallerRequestValidator() + { + When(req => req.MfaToken.HasValue(), () => + { + RuleFor(req => req.MfaToken) + .Matches(Validations.Credentials.Password.MfaToken) + .WithMessage(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + }); + RuleFor(req => req.AuthenticatorType) + .IsInEnum() + .NotNull() + .Must(type => type != PasswordCredentialMfaAuthenticatorType.None + && type != PasswordCredentialMfaAuthenticatorType.RecoveryCodes) + .WithMessage(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType); + When(req => req.PhoneNumber.HasValue(), () => + { + RuleFor(req => req.PhoneNumber) + .NotEmpty() + .Matches(Validations.Credentials.Password.MfaPhoneNumber) + .WithMessage(Resources.AssociatePasswordMfaAuthenticatorForCallerRequestValidator_InvalidPhoneNumber); + }); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/ChallengePasswordMfaAuthenticatorForCallerRequestValidator.cs b/src/IdentityInfrastructure/Api/MFA/ChallengePasswordMfaAuthenticatorForCallerRequestValidator.cs new file mode 100644 index 00000000..89031f1c --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/ChallengePasswordMfaAuthenticatorForCallerRequestValidator.cs @@ -0,0 +1,22 @@ +using Domain.Common.Identity; +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class + ChallengePasswordMfaAuthenticatorForCallerRequestValidator : AbstractValidator< + ChallengePasswordMfaAuthenticatorForCallerRequest> +{ + public ChallengePasswordMfaAuthenticatorForCallerRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.MfaToken) + .Matches(Validations.Credentials.Password.MfaToken) + .WithMessage(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + RuleFor(req => req.AuthenticatorId) + .IsEntityId(identifierFactory) + .WithMessage(Resources.ChallengePasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorId); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/ChangePasswordMfaForCallerRequestValidator.cs b/src/IdentityInfrastructure/Api/MFA/ChangePasswordMfaForCallerRequestValidator.cs new file mode 100644 index 00000000..5d2f6128 --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/ChangePasswordMfaForCallerRequestValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class ChangePasswordMfaForCallerRequestValidator : AbstractValidator +{ +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/ConfirmPasswordMfaAuthenticatorForCallerRequestValidator.cs b/src/IdentityInfrastructure/Api/MFA/ConfirmPasswordMfaAuthenticatorForCallerRequestValidator.cs new file mode 100644 index 00000000..db158acf --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/ConfirmPasswordMfaAuthenticatorForCallerRequestValidator.cs @@ -0,0 +1,42 @@ +using Application.Resources.Shared; +using Common.Extensions; +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class + ConfirmPasswordMfaAuthenticatorForCallerRequestValidator : AbstractValidator< + ConfirmPasswordMfaAuthenticatorForCallerRequest> +{ + public ConfirmPasswordMfaAuthenticatorForCallerRequestValidator() + { + When(req => req.MfaToken.HasValue(), () => + { + RuleFor(req => req.MfaToken) + .Matches(Validations.Credentials.Password.MfaToken) + .WithMessage(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + }); + RuleFor(req => req.AuthenticatorType) + .IsInEnum() + .NotNull() + .Must(type => type != PasswordCredentialMfaAuthenticatorType.None + && type != PasswordCredentialMfaAuthenticatorType.RecoveryCodes) + .WithMessage(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType); + When( + req => req.AuthenticatorType is PasswordCredentialMfaAuthenticatorType.OobSms + or PasswordCredentialMfaAuthenticatorType.OobEmail, () => + { + RuleFor(req => req.OobCode) + .NotEmpty() + .Matches(Validations.Credentials.Password.OobCode) + .WithMessage(Resources.ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidOobCode); + }); + RuleFor(req => req.ConfirmationCode) + .NotEmpty() + .Matches(Validations.Credentials.Password.ConfirmationCode) + .WithMessage(Resources.ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidConfirmationCode); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/DisassociatePasswordMfaAuthenticatorForCallerRequestValidator.cs b/src/IdentityInfrastructure/Api/MFA/DisassociatePasswordMfaAuthenticatorForCallerRequestValidator.cs new file mode 100644 index 00000000..7ed203f2 --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/DisassociatePasswordMfaAuthenticatorForCallerRequestValidator.cs @@ -0,0 +1,19 @@ +using Domain.Common.Identity; +using Domain.Interfaces.Validations; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class + DisassociatePasswordMfaAuthenticatorForCallerRequestValidator : AbstractValidator< + DisassociatePasswordMfaAuthenticatorForCallerRequest> +{ + public DisassociatePasswordMfaAuthenticatorForCallerRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/ListPasswordMfaAuthenticatorsForCallerRequestValidator.cs b/src/IdentityInfrastructure/Api/MFA/ListPasswordMfaAuthenticatorsForCallerRequestValidator.cs new file mode 100644 index 00000000..8fbbc857 --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/ListPasswordMfaAuthenticatorsForCallerRequestValidator.cs @@ -0,0 +1,22 @@ +using Common.Extensions; +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class + ListPasswordMfaAuthenticatorsForCallerRequestValidator : AbstractValidator< + ListPasswordMfaAuthenticatorsForCallerRequest> +{ + public ListPasswordMfaAuthenticatorsForCallerRequestValidator() + { + When(req => req.MfaToken.HasValue(), () => + { + RuleFor(req => req.MfaToken) + .Matches(Validations.Credentials.Password.MfaToken) + .WithMessage(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + }); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/MFAApi.cs b/src/IdentityInfrastructure/Api/MFA/MFAApi.cs deleted file mode 100644 index 802da834..00000000 --- a/src/IdentityInfrastructure/Api/MFA/MFAApi.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Infrastructure.Web.Api.Interfaces; - -namespace IdentityInfrastructure.Api.MFA; - -public class MFAApi : IWebApiService; \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/MfaApi.cs b/src/IdentityInfrastructure/Api/MFA/MfaApi.cs new file mode 100644 index 00000000..e78c0b21 --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/MfaApi.cs @@ -0,0 +1,128 @@ +using Application.Resources.Shared; +using IdentityApplication; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class MfaApi : IWebApiService +{ + private readonly ICallerContextFactory _callerFactory; + private readonly IPasswordCredentialsApplication _passwordCredentialsApplication; + + public MfaApi(ICallerContextFactory callerFactory, IPasswordCredentialsApplication passwordCredentialsApplication) + { + _callerFactory = callerFactory; + _passwordCredentialsApplication = passwordCredentialsApplication; + } + + public async + Task> AssociateMfaAuthenticator( + AssociatePasswordMfaAuthenticatorForCallerRequest request, CancellationToken cancellationToken) + { + var authenticator = await _passwordCredentialsApplication.AssociateMfaAuthenticatorAsync( + _callerFactory.Create(), request.MfaToken, + request.AuthenticatorType ?? PasswordCredentialMfaAuthenticatorType.None, request.PhoneNumber, + cancellationToken); + return () => + authenticator + .HandleApplicationResult(x => + new PostResult( + new AssociatePasswordMfaAuthenticatorForCallerResponse { Authenticator = x })); + } + + public async + Task> + ChallengeMfaAuthenticator( + ChallengePasswordMfaAuthenticatorForCallerRequest request, CancellationToken cancellationToken) + { + var challenge = + await _passwordCredentialsApplication.ChallengeMfaAuthenticatorAsync(_callerFactory.Create(), + request.MfaToken!, request.AuthenticatorId!, cancellationToken); + return () => + challenge + .HandleApplicationResult(x => + new ChallengePasswordMfaAuthenticatorForCallerResponse { Challenge = x }); + } + + public async Task> ChangeMfa( + ChangePasswordMfaForCallerRequest request, CancellationToken cancellationToken) + { + var credential = + await _passwordCredentialsApplication.ChangeMfaAsync(_callerFactory.Create(), request.IsEnabled, + cancellationToken); + return () => + credential.HandleApplicationResult(x => + new ChangePasswordMfaResponse { Credential = x }); + } + + public async + Task> + ConfirmMfaAuthenticatorAssociation( + ConfirmPasswordMfaAuthenticatorForCallerRequest request, CancellationToken cancellationToken) + { + var tokensOrAuthenticators = await _passwordCredentialsApplication.ConfirmMfaAuthenticatorAssociationAsync( + _callerFactory.Create(), request.MfaToken, + request.AuthenticatorType ?? PasswordCredentialMfaAuthenticatorType.None, request.OobCode, + request.ConfirmationCode!, cancellationToken); + + return () => + tokensOrAuthenticators + .HandleApplicationResult( + x => new ConfirmPasswordMfaAuthenticatorForCallerResponse + { Tokens = x.Tokens, Authenticators = x.Authenticators }); + } + + public async Task DisassociateMfaAuthenticator( + DisassociatePasswordMfaAuthenticatorForCallerRequest request, CancellationToken cancellationToken) + { + var resource = + await _passwordCredentialsApplication.DisassociateMfaAuthenticatorAsync(_callerFactory.Create(), + request.Id!, cancellationToken); + return () => resource.HandleApplicationResult(); + } + + public async + Task, ListPasswordMfaAuthenticatorsForCallerResponse>> + ListMfaAuthenticators(ListPasswordMfaAuthenticatorsForCallerRequest request, + CancellationToken cancellationToken) + { + var authenticators = + await _passwordCredentialsApplication.ListMfaAuthenticatorsAsync(_callerFactory.Create(), request.MfaToken, + cancellationToken); + return () => + authenticators + .HandleApplicationResult, + ListPasswordMfaAuthenticatorsForCallerResponse>(x => + new ListPasswordMfaAuthenticatorsForCallerResponse { Authenticators = x }); + } + + public async Task> ResetPasswordMfa( + ResetPasswordMfaRequest request, CancellationToken cancellationToken) + { + var credential = await _passwordCredentialsApplication.ResetPasswordMfaAsync(_callerFactory.Create(), + request.UserId!, cancellationToken); + + return () => + credential.HandleApplicationResult(x => + new ChangePasswordMfaResponse { Credential = x }); + } + + public async Task> VerifyMfaAuthenticator( + VerifyPasswordMfaAuthenticatorForCallerRequest request, CancellationToken cancellationToken) + { + var tokens = + await _passwordCredentialsApplication.VerifyMfaAuthenticatorAsync(_callerFactory.Create(), + request.MfaToken!, request.AuthenticatorType ?? PasswordCredentialMfaAuthenticatorType.None, + request.OobCode, request.ConfirmationCode!, cancellationToken); + return () => + tokens.HandleApplicationResult(x => new AuthenticateResponse + { Tokens = x }); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/ResetPasswordMfaRequestValidator.cs b/src/IdentityInfrastructure/Api/MFA/ResetPasswordMfaRequestValidator.cs new file mode 100644 index 00000000..b863573f --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/ResetPasswordMfaRequestValidator.cs @@ -0,0 +1,17 @@ +using Domain.Common.Identity; +using Domain.Interfaces.Validations; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class ResetPasswordMfaRequestValidator : AbstractValidator +{ + public ResetPasswordMfaRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.UserId) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/MFA/VerifyPasswordMfaAuthenticatorForCallerRequestValidator.cs b/src/IdentityInfrastructure/Api/MFA/VerifyPasswordMfaAuthenticatorForCallerRequestValidator.cs new file mode 100644 index 00000000..17b795ab --- /dev/null +++ b/src/IdentityInfrastructure/Api/MFA/VerifyPasswordMfaAuthenticatorForCallerRequestValidator.cs @@ -0,0 +1,44 @@ +using Application.Resources.Shared; +using FluentValidation; +using IdentityDomain; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.MFA; + +public class + VerifyPasswordMfaAuthenticatorForCallerRequestValidator : AbstractValidator< + VerifyPasswordMfaAuthenticatorForCallerRequest> +{ + public VerifyPasswordMfaAuthenticatorForCallerRequestValidator() + { + RuleFor(req => req.MfaToken) + .Matches(Validations.Credentials.Password.MfaToken) + .WithMessage(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken); + RuleFor(req => req.AuthenticatorType) + .IsInEnum() + .NotNull() + .Must(type => type != PasswordCredentialMfaAuthenticatorType.None) + .WithMessage(Resources.PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType); + When( + req => req.AuthenticatorType is PasswordCredentialMfaAuthenticatorType.OobSms + or PasswordCredentialMfaAuthenticatorType.OobEmail, () => + { + RuleFor(req => req.OobCode) + .NotEmpty() + .Matches(Validations.Credentials.Password.OobCode) + .WithMessage(Resources.ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidOobCode); + }); + RuleFor(req => req.ConfirmationCode) + .NotEmpty() + .Matches(Validations.Credentials.Password.RecoveryConfirmationCode) + .When(req => req.AuthenticatorType == PasswordCredentialMfaAuthenticatorType.RecoveryCodes) + .WithMessage(Resources + .ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidRecoveryConfirmationCode); + RuleFor(req => req.ConfirmationCode) + .NotEmpty() + .Matches(Validations.Credentials.Password.ConfirmationCode) + .When(req => req.AuthenticatorType != PasswordCredentialMfaAuthenticatorType.RecoveryCodes) + .WithMessage(Resources.ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidConfirmationCode); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs index bd1e5fd0..5647a501 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs @@ -70,6 +70,7 @@ public async Task> RegisterPerson( RegisterPersonPasswordRequest request, CancellationToken cancellationToken) { diff --git a/src/IdentityInfrastructure/ApplicationServices/MfaService.cs b/src/IdentityInfrastructure/ApplicationServices/MfaService.cs new file mode 100644 index 00000000..f68dd223 --- /dev/null +++ b/src/IdentityInfrastructure/ApplicationServices/MfaService.cs @@ -0,0 +1,119 @@ +using System.Security.Cryptography; +using Common.Configuration; +using Common.Extensions; +using Domain.Services.Shared; +using IdentityDomain.DomainServices; +using OtpNet; + +namespace IdentityInfrastructure.ApplicationServices; + +/// +/// Provides a service for performing MFA calculations and translations +/// +/// +/// OTP Algorithms provided by . +/// TOTP Compliant with +/// +public class MfaService : IMfaService +{ + public enum TimeStep + { + Now, + Next + } + + internal const int CodeRenewPeriod = 30; // how often the OTP code renews + internal const OtpHashMode HashMode = OtpHashMode.Sha1; // Hash mode for the OTP code + internal const int OtpSecretLength = 16; + internal const string PlatformNameSettingName = "DomainServices:MfaService:IssuerName"; + internal const int SizeOfCode = 6; // Number of digits of the OTP code + private const int DefaultVerificationWindow = 1; // number of OTP time steps that are permitted + private readonly string _issuer; // Name that appears in the Authenticator App + private readonly ITokensService _tokensService; + + public MfaService(IConfigurationSettings settings, ITokensService tokensService) + { + _tokensService = tokensService; + _issuer = settings.Platform.GetString(PlatformNameSettingName); + } + + public string GenerateOobCode() + { + return _tokensService.GenerateRandomToken(); + } + + public string GenerateOobSecret() + { + var random = RandomNumberGenerator.GetInt32(0, 1000000); + return random.ToString().PadLeft(6, '0'); + } + + public string GenerateOtpBarcodeUri(string username, string secret) + { + var uri = new OtpUri(OtpType.Totp, secret, username, _issuer); + return uri.ToString(); + } + + /// + /// Generates a new OTP secret + /// + /// + /// This secret must be convertible to base 32, which means it must only be characters A-Z and 2-7. + /// + public string GenerateOtpSecret() + { + var token1 = _tokensService.GenerateRandomToken(); + var token2 = _tokensService.GenerateRandomToken(); + var randomToken = $"{token1}{token2}"; + + return randomToken + .ToUpper() + .ReplaceWith("[^A-Z2-7]", string.Empty) + .Substring(0, OtpSecretLength); + } + + public int GetTotpMaxTimeSteps() + { + return DefaultVerificationWindow + 2; // Add a couple more to be sure + } + + public TotpResult VerifyTotp(string secret, IReadOnlyList previousTimeSteps, string confirmationCode) + { + var totp = GetOtp(secret); + // As per RFC6238 recommendation + var verificationWindow = new VerificationWindow(DefaultVerificationWindow, DefaultVerificationWindow); + + var matched = totp.VerifyTotp(confirmationCode, out var timeStepMatched, verificationWindow); + if (matched) + { + if (previousTimeSteps.Contains(timeStepMatched)) + { + return TotpResult.Invalid; + } + } + else + { + return TotpResult.Invalid; + } + + return new TotpResult(timeStepMatched); + } + +#if TESTINGONLY + public static string CalculateTotp(string secret, TimeStep timeStep = TimeStep.Now) + { + var totp = GetOtp(secret); + var time = timeStep == TimeStep.Now + ? DateTime.UtcNow + : DateTime.UtcNow.AddSeconds(CodeRenewPeriod); + return totp.ComputeTotp(time); + } +#endif + + private static Totp GetOtp(string secret) + { + var secretBytes = Base32Encoding.ToBytes(secret); + var totp = new Totp(secretBytes, mode: HashMode, step: CodeRenewPeriod, totpSize: SizeOfCode); + return totp; + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/IdentityInfrastructure.csproj b/src/IdentityInfrastructure/IdentityInfrastructure.csproj index dc141868..9396f798 100644 --- a/src/IdentityInfrastructure/IdentityInfrastructure.csproj +++ b/src/IdentityInfrastructure/IdentityInfrastructure.csproj @@ -14,6 +14,7 @@ + diff --git a/src/IdentityInfrastructure/IdentityModule.cs b/src/IdentityInfrastructure/IdentityModule.cs index a0b79bb9..e96cc8e1 100644 --- a/src/IdentityInfrastructure/IdentityModule.cs +++ b/src/IdentityInfrastructure/IdentityModule.cs @@ -39,9 +39,10 @@ public Action> ConfigureMiddleware public Dictionary EntityPrefixes => new() { { typeof(PasswordCredentialRoot), "pwdcred" }, + { typeof(MfaAuthenticator), "mfaauth" }, { typeof(AuthTokensRoot), "authtok" }, { typeof(APIKeyRoot), "apikey" }, - { typeof(SSOUserRoot), "ssocred_" } + { typeof(SSOUserRoot), "ssocred" } }; public Assembly InfrastructureAssembly => typeof(PasswordCredentialsApi).Assembly; @@ -55,6 +56,10 @@ public Action RegisterServices services.AddSingleton(); services.AddPerHttpRequest(); services.AddSingleton(); + services.AddSingleton(c => + new MfaService( + c.GetRequiredServiceForPlatform(), + c.GetRequiredService())); services.AddSingleton(); services.AddSingleton(c => new JWTTokensService(c.GetRequiredServiceForPlatform(), @@ -75,7 +80,9 @@ public Action RegisterServices c.GetRequiredServiceForPlatform(), c.GetRequiredService(), c.GetRequiredService(), + c.GetRequiredService(), c.GetRequiredService(), + c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService())); diff --git a/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs b/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs index 9d21003c..51e7057f 100644 --- a/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs +++ b/src/IdentityInfrastructure/Persistence/PasswordCredentialsRepository.cs @@ -9,18 +9,21 @@ using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; using QueryAny; +using MfaAuthenticator = IdentityApplication.Persistence.ReadModels.MfaAuthenticator; namespace IdentityInfrastructure.Persistence; public class PasswordCredentialsRepository : IPasswordCredentialsRepository { private readonly ISnapshottingQueryStore _credentialQueries; + private readonly ISnapshottingQueryStore _mfaAuthenticatorsQueries; private readonly IEventSourcingDddCommandStore _credentials; public PasswordCredentialsRepository(IRecorder recorder, IDomainFactory domainFactory, IEventSourcingDddCommandStore credentialsStore, IDataStore store) { _credentialQueries = new SnapshottingQueryStore(recorder, domainFactory, store); + _mfaAuthenticatorsQueries = new SnapshottingQueryStore(recorder, domainFactory, store); _credentials = credentialsStore; } @@ -29,10 +32,19 @@ public async Task> DestroyAllAsync(CancellationToken cancellationT { return await Tasks.WhenAllAsync( _credentialQueries.DestroyAllAsync(cancellationToken), + _mfaAuthenticatorsQueries.DestroyAllAsync(cancellationToken), _credentials.DestroyAllAsync(cancellationToken)); } #endif + public async Task, Error>> FindCredentialsByMfaAuthenticationTokenAsync( + string token, CancellationToken cancellationToken) + { + var query = Query.From() + .Where(pc => pc.MfaAuthenticationToken, ConditionOperator.EqualTo, token); + return await FindFirstByQueryAsync(query, cancellationToken); + } + public async Task, Error>> FindCredentialsByPasswordResetTokenAsync( string token, CancellationToken cancellationToken) { @@ -68,6 +80,12 @@ public async Task, Error>> FindCredentia public async Task> SaveAsync(PasswordCredentialRoot credential, CancellationToken cancellationToken) + { + return await SaveAsync(credential, false, cancellationToken); + } + + public async Task> SaveAsync(PasswordCredentialRoot credential, bool reload, + CancellationToken cancellationToken) { var saved = await _credentials.SaveAsync(credential, cancellationToken); if (saved.IsFailure) @@ -75,6 +93,20 @@ public async Task> SaveAsync(PasswordCrede return saved.Error; } + return reload + ? await LoadAsync(credential.Id, cancellationToken) + : credential; + } + + private async Task> LoadAsync(Identifier id, + CancellationToken cancellationToken) + { + var credential = await _credentials.LoadAsync(id, cancellationToken); + if (credential.IsFailure) + { + return credential.Error; + } + return credential; } @@ -94,12 +126,12 @@ private async Task, Error>> FindFirstByQ return Optional.None; } - var tokens = await _credentials.LoadAsync(matching.Id.Value.ToId(), cancellationToken); - if (tokens.IsFailure) + var credential = await _credentials.LoadAsync(matching.Id.Value.ToId(), cancellationToken); + if (credential.IsFailure) { - return tokens.Error; + return credential.Error; } - return tokens.Value.ToOptional(); + return credential.Value.ToOptional(); } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs index a6af86b8..a0c07b80 100644 --- a/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs +++ b/src/IdentityInfrastructure/Persistence/ReadModels/PasswordCredentialProjection.cs @@ -8,16 +8,19 @@ using IdentityDomain; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; +using MfaAuthenticator = IdentityApplication.Persistence.ReadModels.MfaAuthenticator; namespace IdentityInfrastructure.Persistence.ReadModels; public class PasswordCredentialProjection : IReadModelProjection { private readonly IReadModelStore _credentials; + private readonly IReadModelStore _mfaAuthenticators; public PasswordCredentialProjection(IRecorder recorder, IDomainFactory domainFactory, IDataStore store) { _credentials = new ReadModelStore(recorder, domainFactory, store); + _mfaAuthenticators = new ReadModelStore(recorder, domainFactory, store); } public async Task> ProjectEventAsync(IDomainEvent changeEvent, @@ -31,6 +34,8 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven dto.UserId = e.UserId; dto.RegistrationVerified = false; dto.AccountLocked = false; + dto.IsMfaEnabled = e.IsMfaEnabled; + dto.MfaCanBeDisabled = e.MfaCanBeDisabled; }, cancellationToken); @@ -77,6 +82,86 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven return await _credentials.HandleUpdateAsync(e.RootId, dto => { dto.PasswordResetToken = Optional.None; }, cancellationToken); + case MfaOptionsChanged e: + return await _credentials.HandleUpdateAsync(e.RootId, dto => + { + dto.IsMfaEnabled = e.IsEnabled; + dto.MfaCanBeDisabled = e.CanBeDisabled; + }, + cancellationToken); + + case MfaStateReset e: + return await _credentials.HandleUpdateAsync(e.RootId, dto => + { + dto.IsMfaEnabled = e.IsEnabled; + dto.MfaCanBeDisabled = e.CanBeDisabled; + }, + cancellationToken); + + case MfaAuthenticationInitiated e: + return await _credentials.HandleUpdateAsync(e.RootId, dto => + { + dto.MfaAuthenticationToken = e.AuthenticationToken; + dto.MfaAuthenticationExpiresAt = e.AuthenticationExpiresAt; + }, + cancellationToken); + + case MfaAuthenticatorAdded e: + return await _mfaAuthenticators.HandleCreateAsync(e.AuthenticatorId!, dto => + { + dto.PasswordCredentialId = e.RootId; + dto.UserId = e.UserId; + dto.Type = e.Type; + dto.IsActive = e.IsActive; + dto.State = MfaAuthenticatorState.Created; + dto.VerifiedState = Optional.None; + }, + cancellationToken); + + case MfaAuthenticatorRemoved e: + return await _mfaAuthenticators.HandleDeleteAsync(e.AuthenticatorId, cancellationToken); + + case MfaAuthenticatorAssociated e: + return await _mfaAuthenticators.HandleUpdateAsync(e.AuthenticatorId, dto => + { + dto.State = MfaAuthenticatorState.Associated; + dto.OobCode = e.OobCode; + dto.OobChannelValue = e.OobChannelValue; + dto.BarCodeUri = e.BarCodeUri; + dto.Secret = e.Secret; + dto.VerifiedState = Optional.None; + }, + cancellationToken); + + case MfaAuthenticatorConfirmed e: + return await _mfaAuthenticators.HandleUpdateAsync(e.AuthenticatorId, dto => + { + dto.State = MfaAuthenticatorState.Confirmed; + dto.VerifiedState = e.VerifiedState; + dto.IsActive = e.IsActive; + }, + cancellationToken); + + case MfaAuthenticatorChallenged e: + return await _mfaAuthenticators.HandleUpdateAsync(e.AuthenticatorId, dto => + { + dto.State = MfaAuthenticatorState.Challenged; + dto.OobCode = e.OobCode; + dto.OobChannelValue = e.OobChannelValue; + dto.BarCodeUri = e.BarCodeUri; + dto.Secret = e.Secret; + dto.VerifiedState = Optional.None; + }, + cancellationToken); + + case MfaAuthenticatorVerified e: + return await _mfaAuthenticators.HandleUpdateAsync(e.AuthenticatorId, dto => + { + dto.State = MfaAuthenticatorState.Verified; + dto.VerifiedState = e.VerifiedState; + }, + cancellationToken); + default: return false; } diff --git a/src/IdentityInfrastructure/Resources.Designer.cs b/src/IdentityInfrastructure/Resources.Designer.cs index 26321c11..ae98d6c1 100644 --- a/src/IdentityInfrastructure/Resources.Designer.cs +++ b/src/IdentityInfrastructure/Resources.Designer.cs @@ -77,6 +77,15 @@ internal static string AnySSOAuthenticationProvider_MissingRefreshToken { } } + /// + /// Looks up a localized string similar to The 'PhoneNumber' is either missing or invalid. + /// + internal static string AssociatePasswordMfaAuthenticatorForCallerRequestValidator_InvalidPhoneNumber { + get { + return ResourceManager.GetString("AssociatePasswordMfaAuthenticatorForCallerRequestValidator_InvalidPhoneNumber", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'Password' is either missing or invalid. /// @@ -131,6 +140,16 @@ internal static string AuthenticateSingleSignOnRequestValidator_InvalidUsername } } + /// + /// Looks up a localized string similar to The 'AuthenticatorId' is either missing or invalid. + /// + internal static string ChallengePasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorId { + get { + return ResourceManager.GetString("ChallengePasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorId" + + "", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'Password' is either missing or invalid. /// @@ -149,6 +168,34 @@ internal static string CompletePasswordResetRequestValidator_InvalidToken { } } + /// + /// Looks up a localized string similar to The 'ConfirmationCode' is either missing or invalid. + /// + internal static string ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidConfirmationCode { + get { + return ResourceManager.GetString("ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidConfirmationCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'OobCode' is either missing or invalid. + /// + internal static string ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidOobCode { + get { + return ResourceManager.GetString("ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidOobCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'ConfirmationCode' is either missing or invalid. + /// + internal static string ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidRecoveryConfirmationCode { + get { + return ResourceManager.GetString("ConfirmPasswordMfaAuthenticatorForCallerRequestValidator_InvalidRecoveryConfirmat" + + "ionCode", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'Token' is either missing or invalid. /// @@ -185,6 +232,24 @@ internal static string InitiatePasswordResetRequestValidator_InvalidEmailAddress } } + /// + /// Looks up a localized string similar to The 'AuthenticatorType' is either missing or invalid. + /// + internal static string PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType { + get { + return ResourceManager.GetString("PasswordMfaAuthenticatorForCallerRequestValidator_InvalidAuthenticatorType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'MfaToken' is either missing or invalid. + /// + internal static string PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken { + get { + return ResourceManager.GetString("PasswordMfaAuthenticatorForCallerRequestValidator_InvalidMfaToken", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'RefreshToken' is invalid or missing. /// diff --git a/src/IdentityInfrastructure/Resources.resx b/src/IdentityInfrastructure/Resources.resx index 1b02f7e5..348c1960 100644 --- a/src/IdentityInfrastructure/Resources.resx +++ b/src/IdentityInfrastructure/Resources.resx @@ -102,4 +102,27 @@ The 'Token' is either missing or invalid + + The 'AuthenticatorType' is either missing or invalid + + + The 'MfaToken' is either missing or invalid + + + The 'PhoneNumber' is either missing or invalid + + + The 'OobCode' is either missing or invalid + + + The 'ConfirmationCode' is either missing or invalid + + + The 'ConfirmationCode' is either missing or invalid + + + The 'AuthenticatorId' is either missing or invalid + \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/MessageUserNotificationsService.cs b/src/Infrastructure.Shared/ApplicationServices/MessageUserNotificationsService.cs index 76906554..bf5323ba 100644 --- a/src/Infrastructure.Shared/ApplicationServices/MessageUserNotificationsService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/MessageUserNotificationsService.cs @@ -74,7 +74,7 @@ public async Task> NotifyPasswordMfaOobEmailAsync(ICallerContext c CancellationToken cancellationToken) { var webSiteUrl = _hostSettings.GetWebsiteHostBaseUrl(); - var webSiteRoute = _websiteUiService.ConstructPasswordMfaOobCompletionPageUrl(code); + var webSiteRoute = _websiteUiService.ConstructPasswordMfaOobConfirmationPageUrl(code); var link = webSiteUrl.WithoutTrailingSlash() + webSiteRoute; var htmlBody = $""" diff --git a/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs b/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs index 99950647..7c2025fb 100644 --- a/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/WebsiteUiService.cs @@ -8,15 +8,15 @@ namespace Infrastructure.Shared.ApplicationServices; public sealed class WebsiteUiService : IWebsiteUiService { //EXTEND: these URLs must reflect those used by the website that handles UI - private const string PasswordMfaOobCompletion = "/complete-password-mfaoob"; + private const string PasswordMfaOobConfirmation = "/confirm-password-mfaoob"; private const string PasswordRegistrationConfirmationPageRoute = "/confirm-password-registration"; private const string PasswordResetConfirmationPageRoute = "/confirm-password-reset"; private const string RegistrationPageRoute = "/register"; - public string ConstructPasswordMfaOobCompletionPageUrl(string code) + public string ConstructPasswordMfaOobConfirmationPageUrl(string code) { var escapedCode = Uri.EscapeDataString(code); - return $"{PasswordMfaOobCompletion}?code={escapedCode}"; + return $"{PasswordMfaOobConfirmation}?code={escapedCode}"; } public string ConstructPasswordRegistrationConfirmationPageUrl(string token) diff --git a/src/Infrastructure.Shared/DomainServices/TokensService.cs b/src/Infrastructure.Shared/DomainServices/TokensService.cs index f831af64..31e8119a 100644 --- a/src/Infrastructure.Shared/DomainServices/TokensService.cs +++ b/src/Infrastructure.Shared/DomainServices/TokensService.cs @@ -12,9 +12,9 @@ public sealed class TokensService : ITokensService public APIKeyToken CreateAPIKey() { - var token = GenerateRandomTokenSafeForUrl(CommonValidations.APIKeys.ApiKeyTokenSize, + var token = GenerateRandomStringSafeForUrl(CommonValidations.APIKeys.ApiKeyTokenSize, CommonValidations.APIKeys.ApiKeyPaddingReplacement); - var key = GenerateRandomTokenSafeForUrl(CommonValidations.APIKeys.ApiKeySize, + var key = GenerateRandomStringSafeForUrl(CommonValidations.APIKeys.ApiKeySize, CommonValidations.APIKeys.ApiKeyPaddingReplacement); return new APIKeyToken @@ -28,22 +28,32 @@ public APIKeyToken CreateAPIKey() public string CreateGuestInvitationToken() { - return GenerateRandomTokenSafeForUrl(); + return GenerateRandomStringSafeForUrl(); } public string CreateJWTRefreshToken() { - return GenerateRandomTokenSafeForUrl(); + return GenerateRandomStringSafeForUrl(); + } + + public string CreateMfaAuthenticationToken() + { + return GenerateRandomStringSafeForUrl(); } public string CreatePasswordResetToken() { - return GenerateRandomTokenSafeForUrl(); + return GenerateRandomStringSafeForUrl(); } public string CreateRegistrationVerificationToken() { - return GenerateRandomTokenSafeForUrl(); + return GenerateRandomStringSafeForUrl(); + } + + public string GenerateRandomToken() + { + return GenerateRandomStringSafeForUrl(); } /// @@ -71,20 +81,18 @@ public Optional ParseApiKey(string apiKey) }; } - private static string GenerateRandomTokenSafeForUrl(int keySize = DefaultTokenSizeInBytes, + private static string GenerateRandomStringSafeForUrl(int keySize = DefaultTokenSizeInBytes, string paddingReplacement = "") { - return MakeSafeForUrls(GenerateRandomToken(keySize), paddingReplacement); + return MakeSafeForUrls(GenerateRandomString(keySize), paddingReplacement); } - private static string GenerateRandomToken(int keySize = DefaultTokenSizeInBytes) + private static string GenerateRandomString(int keySize = DefaultTokenSizeInBytes) { - using (var random = RandomNumberGenerator.Create()) - { - var bytes = new byte[keySize]; - random.GetNonZeroBytes(bytes); - return Convert.ToBase64String(bytes); - } + using var random = RandomNumberGenerator.Create(); + var bytes = new byte[keySize]; + random.GetNonZeroBytes(bytes); + return Convert.ToBase64String(bytes); } private static string MakeSafeForUrls(string value, string paddingReplacement = "") diff --git a/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/AuthenticateRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/AuthenticateRequest.cs index b60d8ce0..8d81ab33 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/AuthenticateRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/AuthenticateRequest.cs @@ -6,8 +6,12 @@ namespace Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; /// /// Authenticates the user with the specified provider, using either an auth code or a username and password. /// -/// The user's username or password is invalid -/// The user has not yet verified their registration +/// The user's credentials are invalid +/// When the user has authenticated with credentials, but has not yet verified their registration +/// +/// When the user has authenticated with credentials, but has MFA enabled. The details of the error +/// contains a value of "mfa_required". +/// /// The user's account is suspended or disabled, and cannot be authenticated or used [Route("/auth", OperationMethod.Post)] public class AuthenticateRequest : UnTenantedRequest diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AssociatePasswordMfaAuthenticatorForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AssociatePasswordMfaAuthenticatorForCallerRequest.cs new file mode 100644 index 00000000..d5d60e29 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AssociatePasswordMfaAuthenticatorForCallerRequest.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +/// +/// Associates another MFA authentication factor to the user +/// +/// +/// The user is already associated to this .You must make the +/// challenge using the existing association +/// +/// +/// This API can be called Anonymously (during password authentication), as well as after being authenticated +/// +[Route("/passwords/mfa/authenticators", OperationMethod.Post)] +public class AssociatePasswordMfaAuthenticatorForCallerRequest : UnTenantedRequest< + AssociatePasswordMfaAuthenticatorForCallerRequest, AssociatePasswordMfaAuthenticatorForCallerResponse> +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + [Required] + public PasswordCredentialMfaAuthenticatorType? AuthenticatorType { get; set; } + + public string? MfaToken { get; set; } + + public string? PhoneNumber { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AssociatePasswordMfaAuthenticatorForCallerResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AssociatePasswordMfaAuthenticatorForCallerResponse.cs new file mode 100644 index 00000000..f9c1b13d --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AssociatePasswordMfaAuthenticatorForCallerResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class AssociatePasswordMfaAuthenticatorForCallerResponse : IWebResponse +{ + public PasswordCredentialMfaAssociation? Authenticator { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordRequest.cs index 9a096720..96600a5b 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticatePasswordRequest.cs @@ -8,6 +8,10 @@ namespace Infrastructure.Web.Api.Operations.Shared.Identities; /// /// The user's username or password is invalid /// The user has not yet verified their registration +/// +/// When the user has authenticated, but has MFA enabled. The details of the error contains a value of +/// "mfa_required". +/// /// The user's account is suspended or disabled, and cannot be authenticated or used [Route("/passwords/auth", OperationMethod.Post)] public class AuthenticatePasswordRequest : UnTenantedRequest diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChallengePasswordMfaAuthenticatorForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChallengePasswordMfaAuthenticatorForCallerRequest.cs new file mode 100644 index 00000000..3a1b9113 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChallengePasswordMfaAuthenticatorForCallerRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +/// +/// Challenges an MFA authenticator for the user +/// +/// /// +/// This API can only be called Anonymously (during password authentication) +/// +[Route("/passwords/mfa/authenticators/{AuthenticatorId}/challenge", OperationMethod.PutPatch)] +public class ChallengePasswordMfaAuthenticatorForCallerRequest : UnTenantedRequest< + ChallengePasswordMfaAuthenticatorForCallerRequest, ChallengePasswordMfaAuthenticatorForCallerResponse> +{ + [Required] public string? AuthenticatorId { get; set; } + + [Required] public string? MfaToken { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChallengePasswordMfaAuthenticatorForCallerResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChallengePasswordMfaAuthenticatorForCallerResponse.cs new file mode 100644 index 00000000..a0eb6ed1 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChallengePasswordMfaAuthenticatorForCallerResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class ChallengePasswordMfaAuthenticatorForCallerResponse : IWebResponse +{ + public PasswordCredentialMfaChallenge? Challenge { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChangePasswordMfaForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChangePasswordMfaForCallerRequest.cs new file mode 100644 index 00000000..d76c1478 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChangePasswordMfaForCallerRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +/// +/// Changes whether the user is MFA enabled or not +/// +[Route("/passwords/mfa", OperationMethod.PutPatch, AccessType.Token)] +[Authorize(Roles.Platform_Standard, Features.Platform_PaidTrial)] +public class + ChangePasswordMfaForCallerRequest : UnTenantedRequest +{ + [Required] public bool IsEnabled { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChangePasswordMfaResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChangePasswordMfaResponse.cs new file mode 100644 index 00000000..13be37cc --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ChangePasswordMfaResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class ChangePasswordMfaResponse : IWebResponse +{ + public PasswordCredential? Credential { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ConfirmPasswordMfaAuthenticatorForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ConfirmPasswordMfaAuthenticatorForCallerRequest.cs new file mode 100644 index 00000000..1279fb4d --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ConfirmPasswordMfaAuthenticatorForCallerRequest.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +/// +/// Confirms the association of an MFA authenticator for the user +/// +/// +/// This API can be called Anonymously (during password authentication), as well as after being authenticated +/// +[Route("/passwords/mfa/authenticators/{AuthenticatorType}/confirm", OperationMethod.PutPatch)] +public class ConfirmPasswordMfaAuthenticatorForCallerRequest : UnTenantedRequest< + ConfirmPasswordMfaAuthenticatorForCallerRequest, ConfirmPasswordMfaAuthenticatorForCallerResponse> +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + [Required] + public PasswordCredentialMfaAuthenticatorType? AuthenticatorType { get; set; } + + [Required] public string? ConfirmationCode { get; set; } + + public string? MfaToken { get; set; } + + public string? OobCode { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ConfirmPasswordMfaAuthenticatorForCallerResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ConfirmPasswordMfaAuthenticatorForCallerResponse.cs new file mode 100644 index 00000000..6b2a66c4 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ConfirmPasswordMfaAuthenticatorForCallerResponse.cs @@ -0,0 +1,11 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class ConfirmPasswordMfaAuthenticatorForCallerResponse : IWebResponse +{ + public List? Authenticators { get; set; } + + public AuthenticateTokens? Tokens { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/DisassociatePasswordMfaAuthenticatorForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/DisassociatePasswordMfaAuthenticatorForCallerRequest.cs new file mode 100644 index 00000000..6d0b0d5a --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/DisassociatePasswordMfaAuthenticatorForCallerRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +/// +/// Disassociates an associated MFA authenticator from the user +/// +[Route("/passwords/mfa/authenticators/{Id}", OperationMethod.Delete, AccessType.Token)] +[Authorize(Roles.Platform_Standard, Features.Platform_PaidTrial)] +public class DisassociatePasswordMfaAuthenticatorForCallerRequest : UnTenantedDeleteRequest< + DisassociatePasswordMfaAuthenticatorForCallerRequest> +{ + [Required] public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ListPasswordMfaAuthenticatorsForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ListPasswordMfaAuthenticatorsForCallerRequest.cs new file mode 100644 index 00000000..e49da9c4 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ListPasswordMfaAuthenticatorsForCallerRequest.cs @@ -0,0 +1,17 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +/// +/// Fetches the MFA authenticators for the user +/// +/// +/// This API can be called Anonymously (during password authentication), as well as after being authenticated +/// +[Route("/passwords/mfa/authenticators", OperationMethod.Get)] +public class ListPasswordMfaAuthenticatorsForCallerRequest : UnTenantedRequest< + ListPasswordMfaAuthenticatorsForCallerRequest, + ListPasswordMfaAuthenticatorsForCallerResponse> +{ + public string? MfaToken { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ListPasswordMfaAuthenticatorsForCallerResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ListPasswordMfaAuthenticatorsForCallerResponse.cs new file mode 100644 index 00000000..643b60da --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ListPasswordMfaAuthenticatorsForCallerResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +public class ListPasswordMfaAuthenticatorsForCallerResponse : IWebResponse +{ + public List? Authenticators { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/ResetPasswordMfaRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ResetPasswordMfaRequest.cs new file mode 100644 index 00000000..6534bc67 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/ResetPasswordMfaRequest.cs @@ -0,0 +1,13 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +/// +/// Resets the user MFA status back to the default for all users +/// +[Route("/passwords/mfa/reset", OperationMethod.PutPatch, AccessType.Token)] +[Authorize(Roles.Platform_Operations, Features.Platform_PaidTrial)] +public class ResetPasswordMfaRequest : UnTenantedRequest +{ + public string? UserId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/VerifyPasswordMfaAuthenticatorForCallerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/VerifyPasswordMfaAuthenticatorForCallerRequest.cs new file mode 100644 index 00000000..800df8ea --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/VerifyPasswordMfaAuthenticatorForCallerRequest.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Identities; + +/// +/// Verifies an MFA authenticator for the user +/// +/// +/// This API can only be called Anonymously (during password authentication) +/// +[Route("/passwords/mfa/authenticators/{AuthenticatorType}/verify", OperationMethod.PutPatch)] +public class + VerifyPasswordMfaAuthenticatorForCallerRequest : UnTenantedRequest +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + [Required] + public PasswordCredentialMfaAuthenticatorType? AuthenticatorType { get; set; } + + [Required] public string? ConfirmationCode { get; set; } + + [Required] public string? MfaToken { get; set; } + + public string? OobCode { get; set; } +} \ No newline at end of file diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 6b142336..0a0d8ffc 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -127,9 +127,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) public abstract class WebApiSpec : IClassFixture>, IDisposable where THost : class { + protected const string PasswordForPerson = "1Password!"; private const string DotNetCommandLineWithLaunchProfileArgumentsFormat = "run --no-build --configuration {0} --launch-profile {1} --project {2}"; - private const string PasswordForPerson = "1Password!"; private const string TestingServerUrl = "https://localhost"; private const int WaitStateRetries = 30; // ReSharper disable once StaticMemberInGenericType diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 4b268907..19cdc242 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -13,6 +13,7 @@ DO_NOT_SHOW DO_NOT_SHOW DO_NOT_SHOW + HINT WARNING WARNING SUGGESTION @@ -270,7 +271,10 @@ <Entry DisplayName="Construction Factories"> <Entry.Match> <And> - <Access Is="Public" /> + <Or> + <Access Is="Public" /> + <Access Is="Internal" /> + </Or> <Static/> <Kind Is="Method" /> <Name Is="Create"/> @@ -414,6 +418,7 @@ <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /><ExtraRule Prefix="Setup" Suffix="" Style="AaBb" /></Policy></Policy> <Policy><Descriptor Staticness="Static" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Static readonly fields (not private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Setup" Suffix="" Style="AaBb" /></Policy></Policy> + True True True True @@ -1473,6 +1478,7 @@ public void When$condition$_Then$outcome$() Assert.Fail();$END$ } False + True True True True @@ -1491,6 +1497,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True @@ -1557,6 +1565,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1576,6 +1585,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True @@ -1590,6 +1601,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1598,6 +1610,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1613,30 +1626,40 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True + True + True + True True True True True True True + True True True True True + True True True True True True True + True True True True True True + True + True True True True @@ -1686,6 +1709,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1814,6 +1838,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1834,6 +1859,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True diff --git a/src/UnitTesting.Common/OptionalAssertions.cs b/src/UnitTesting.Common/OptionalAssertions.cs index bbb1042d..eb6825a1 100644 --- a/src/UnitTesting.Common/OptionalAssertions.cs +++ b/src/UnitTesting.Common/OptionalAssertions.cs @@ -26,20 +26,6 @@ public AndConstraint> BeNone(string because = "", par return new AndConstraint>(this); } - public AndConstraint> BeSome(string because = "", - params object[] becauseArgs) - { - Execute.Assertion - .BecauseOf(because, becauseArgs) - .Given(() => Subject) - .ForCondition(optional => optional.HasValue) - .FailWith( - "Expected {context:optional} to have a value {0}{reason}, but it was None instead.", - optional => optional.ValueOrDefault); - - return new AndConstraint>(this); - } - public AndConstraint> BeSome(TValue some, string because = "", params object[] becauseArgs) { @@ -72,6 +58,14 @@ public AndConstraint> BeSome(Predicate some, public AndConstraint> NotBeNone(string because = "", params object[] becauseArgs) { - return BeSome(because, becauseArgs); + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject) + .ForCondition(optional => optional.HasValue) + .FailWith( + "Expected {context:optional} not to be None{reason}, but it was.", + optional => optional.ValueOrDefault); + + return new AndConstraint>(this); } } \ No newline at end of file