jezzsantos / saastack

A comprehensive codebase template for starting your real-world, fully featured SaaS web products. On the .NET platform
The Unlicense
44 stars 13 forks source link

Support MFA authentication #52

Open jezzsantos opened 1 month ago

jezzsantos commented 1 month ago

We need to support some simple 2FA scenarios like:

jezzsantos commented 3 weeks ago

Studying: https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html

jezzsantos commented 3 weeks ago

Considering:

Implementation notes:

API Implementation

Questions:

jezzsantos commented 3 weeks ago

APIS for authentication (with password): (based on Auth0 flows)

We could support:

We could support OOB pretty easily.

Step1: Auth - we call POST /passwords/auth with provider=credentials, but (if MFA is enabled) we get a response like this:

HTTP 401? "Multifactor authentication required"
{
 "mfa_token": "...mfatoken..."
}

The client now needs to call: GET /passwords/mfa/authenticators?mfaToken=, that includes the mfatoken from previous response, and gets a response like this:

HTTP 200 - Success
{
  "authenticators": [
    {
      "id" : "someid",
      "type": "RecoveryCodes",
    },
    {
      "id" : "someotherid",
      "type": "OOB",
      "channel" : "SMS"
    },
    {
      "id" : "someotherid",
      "type": "OTP",
      "channel" : "authenticator"
    },
  ]
}

SMS OOB

Given this response has an authenticator called OOB/SMS,

Challenge: Now call POST /passwords/mfa/challenge and include: type=oob_sms&authenticatorId=somotherid&mfaToken=mfatoken This should send the SMS code to the user. This should respond with:

{
  "challenge_type": "oob",
  "token": "asdae35fdt5...", //calculated one-time challenge value
}

Now we collect the SMS code (send to their phone) in the UI, and POST is back to the server to authenticate: This would then call: POST /passwords/mfa/auth with type=oob&mfaToken=mfatoken&code=smscode&oobToken=token On the server, the details are compared. and this response should return the authentication response as usual.

Authenticator OTP

Given this response has an authenticator called OTP/Authenticator,

No challenge call here.

Now we collect the Authenticator code (they can view in the authenticator app) in the UI, and POST is back to the server to authenticate: This would then call: POST /passwords/mfa/auth with type=otp&mfaToken=mfatoken&code=authenticatorcode On the server, the details are compared. and this response should return the authentication response as usual.

Recovery Codes

Given this response has an authenticator called RecoveryCodes,

No challenge call here.

Now we collect the recovery codes in the UI, and POST is back to the server to authenticate: This would then call: POST /passwords/mfa/auth with type=recoverycode&mfaToken=mfatoken&code=recoverycode On the server, the details are compared. and this response should return the authentication response as usual.

jezzsantos commented 3 weeks ago

When we authenticate via POST /password/auth normally we would return various 4XX errors to indicate a failed authentication. e.g. 401, 405, 409 etc.

In the case of 401, we should be returning a WWW-Authenticate header, to indicate how to authenticate. In the case, we succeed we would return a 201 - Created, and this kind of data:

{
  "tokens": {
    "accessToken": {
      "expiresOn": "2024-10-26T02:56:13.7981713Z",
      "type": "accessToken",
      "value": "eyJhb....."
    },
    "refreshToken": {
      "expiresOn": "2024-11-09T02:41:13.7981734Z",
      "type": "refreshToken",
      "value": "o8WXGqlNQtrZZuPeROzdyPpkk3KkWGnknm2E9Z0Np7s"
    },
    "userId": "user_qyhjHnJnURdMbZkCjAiw"
  }
}

Now, when authenticating and MFA is enabled for this user, we need to tell the client to follow an MFA flow onwards.

Strictly speaking, we have completed a partial authentication process (by providing their credentials) but we need more (i.e. the relevant 2FA codes (i.e. "Something They Have" - authenticator app, SMS, etc).

The question is do we return a 4xx error with something in response to indicate MFA flow, or do we return a 202 - Accepted with something in the response to indicate partial success?

Option 1: 202 - Accepted

    "mfa_token": "Fe26...Ha"

Option 2: 403 - Forbidden

{
  "type": "https://datatracker.ietf.org/doc/html/rfc9110#section-15.5.4",
  "title": "mfa_required",
  "status": 403,
  "detail": "Requires another factor of authentication",
  "instance": "https://localhost:5001/passwords/auth",
  "extensions": {
      "mfa_token": "Fe26...Ha"
    }
}

In Auth0, they return a 403 - Forbidden like this {error:“mfa_required”,error_description:“Multifactor authentication required”}

jezzsantos commented 2 weeks ago

Note: MFA only applies to accounts that are not SSO.

Plan of attack:

jezzsantos commented 2 weeks ago

If the first step is to enroll in MFA of one type or another, and MFA is mandatory for new users, then where do we enforce the enrollment step? Since user registration is a two-step process, where does this process fit in? (If MFA is not mandatory) we can enroll at any time later, but if mandatory, we have to force the process up front, somewhere before they login)

Options:

  1. We force the user to setup MFA authenticator after the registration API call
  2. We force the user to setup MFA authenticator after they confirm the registration (via email)
  3. We force enrollment after the authentication step, when we

Another question is, which authenticators do we force them to setup? is that determined in the code? I guess we can offer them a choice, if we have more than one hardcoded?

According to Auth0, they seem to enforce the enrollment during the authentication process: https://auth0.com/docs/secure/multi-factor-authentication/authenticate-using-ropg-flow-with-mfa (which is option 3)

The enrollment process (and the challenge process) would both end up issuing tokens. Which implies that the mfatoken is only issued as a "partial" result when trying to authenticate (with mandatory MFA, but not being enrolled already). Or you can obtain it when you are already authenticated already (when opting into MFA enrollment).

The flow would look like this: (derived from Auth0 ROPG flows)

  1. Client -> Register & Confirm account
  2. Client -> Authenticates, MFA is enabled, we get an error that includes an mfatoken
  3. Client -> With the mfa token, lists the authenticators. May get some, may get none
  4. Client -> if none authenticators, then must enroll now.
    • Client -> POST to associate endpoint (using data from list above). Responds with Bar code and recovery codes.
    • User -> scans bar code with authenticator app
    • User -> sets up on authenticator app. User copies secret code from app
    • Client -> POST secret code back to MFA API to confirm enrollment. Response comes back with access tokens
    • Client -> displays the recovery codes to save for the user.
    • User -> writes down recovery codes
  5. Client -> if some authenticators, then must challenge now.
    • Client -> gives choice of which authenticator to use. Depending on the choice made, a challenge API is called to make the backend challenge (i.e. send an SMS message).
    • User -> gets the code from the respective channel (authenticator app, SMS text etc)
    • User -> enters code into Client UI
    • Client -> POST secret code back to MFA API to confirm enrollment. Response comes back with access tokens
jezzsantos commented 2 weeks ago

Auth0 API Docs: https://auth0.com/docs/api/authentication#multi-factor-authentication