diff --git a/schemas/authorization-challenge-request.json b/schemas/authorization-challenge-request.json index 298db58b..47eb0e82 100644 --- a/schemas/authorization-challenge-request.json +++ b/schemas/authorization-challenge-request.json @@ -30,6 +30,13 @@ "maxLength": 6, "pattern": "^[0-9]{6}$" }, + "email_opt_code": { + "type": "string", + "description": "A 6 digit one-time password sent to an email address.", + "minLength": 6, + "maxLength": 6, + "pattern": "^[0-9]{6}$" + }, "remote_addr": { "description": "Ip address (ipv4 or ipv6) of the client making the request. For logging and anomaly detection.", "oneOf": [ diff --git a/src/api-types.ts b/src/api-types.ts index 624c9801..d668cf8b 100644 --- a/src/api-types.ts +++ b/src/api-types.ts @@ -177,6 +177,10 @@ export interface AuthorizationChallengeRequest { * A 6 digit TOTP code / authenticator app code */ totp_code?: string; + /** + * A 6 digit one-time password sent to an email address. + */ + email_opt_code?: string; /** * Ip address (ipv4 or ipv6) of the client making the request. For logging and anomaly detection. */ diff --git a/src/login/challenge/abstract.ts b/src/login/challenge/abstract.ts index 6a902bbc..f1681023 100644 --- a/src/login/challenge/abstract.ts +++ b/src/login/challenge/abstract.ts @@ -25,9 +25,15 @@ export abstract class AbstractLoginChallenge { */ protected log: UserEventLogger; - constructor(principal: User, logger: UserEventLogger) { + /** + * IP address of the client initiating authentication. + */ + protected ip: string; + + constructor(principal: User, logger: UserEventLogger, ip: string) { this.principal = principal; this.log = logger; + this.ip = ip; } /** @@ -60,7 +66,7 @@ export abstract class AbstractLoginChallenge { * This notifies the user that some kind of response is expected as a reply * to this challenge. */ - abstract challenge(): never; + abstract challenge(): Promise; /** * Validates whether the parameters object contains expected values. diff --git a/src/login/challenge/email-otp.ts b/src/login/challenge/email-otp.ts new file mode 100644 index 00000000..872c66b6 --- /dev/null +++ b/src/login/challenge/email-otp.ts @@ -0,0 +1,114 @@ +import { AbstractLoginChallenge } from './abstract.js'; +import { AuthorizationChallengeRequest } from '../types.js'; +import { A12nLoginChallengeError } from '../error.js'; +import * as services from '../../services.js'; +import { PrincipalIdentity } from '../../types.js'; +import { BadRequest } from '@curveball/http-errors'; + +type EmailOtpParameters = { + email_otp_code: string; +} + +/** + * Password-based authentication strategy. + */ +export class LoginChallengeEmailOtp extends AbstractLoginChallenge { + + /** + * The type of authentication factor this class provides. + */ + readonly authFactor = 'email-otp'; + + /** + * Returns true if the user has this auth factor set up. + * + * For example, if a user has a TOTP device setup this should + * return true for the totp challenge class. + */ + async userHasChallenge(): Promise { + + const identity = await this.findMfaIdentity(); + return identity !== null; + + } + + /** + * Handle the user response to a challenge. + * + * Should return true if the challenge passed. + * Should throw an Error ihe challenge failed. + */ + async checkResponse(parameters: EmailOtpParameters): Promise { + + const identity = await this.findMfaIdentity(true); + + try { + await services.principalIdentity.verifyIdentity( + identity, + parameters.email_otp_code + ); + return true; + } catch (err) { + if (err instanceof BadRequest) { + throw new A12nLoginChallengeError('Invalid or expired email_otp_code', 'email_otp_invalid'); + } else { + throw err; + } + } + + } + + /** + * Should return true if parameters contain a response to the challenge. + * + * For example, for the password challenge this checks if the paremters contained + * a 'password' key. + */ + parametersContainsResponse(parameters: AuthorizationChallengeRequest): parameters is EmailOtpParameters & AuthorizationChallengeRequest { + + console.log('contains-response', parameters); + return parameters.email_opt_code !== undefined; + + } + + /** + * Emits the initial challenge. + * + * This notifies the user that some kind of response is expected as a reply + * to this challenge. + */ + async challenge(): Promise { + + console.log('challenge-now'); + const identity = await this.findMfaIdentity(true); + await services.principalIdentity.sendVerificationRequest(identity, this.ip); + throw new A12nLoginChallengeError( + `An email has been sent to ${identity.uri.slice(7)} with a code to verify your identity.`, + 'email_otp_required' + ); + + } + + private identityCache: PrincipalIdentity | null = null; + + /** + * Finds a MFA identity that uses a mailto: address. + */ + private async findMfaIdentity(must: true): Promise; + private async findMfaIdentity(): Promise; + private async findMfaIdentity(must = false): Promise { + + if (this.identityCache) return this.identityCache; + const identities = await services.principalIdentity.findByPrincipal(this.principal); + for (const identity of identities) { + if (identity.isMfa && identity.uri.startsWith('mailto:')) { + this.identityCache = identity; + return identity; + } + } + if (must) throw new Error('Could not find an email identity usable for ma'); + return null; + + } + +} diff --git a/src/login/error.ts b/src/login/error.ts index a89b4d5c..bca59904 100644 --- a/src/login/error.ts +++ b/src/login/error.ts @@ -4,21 +4,25 @@ import { LoginSession } from './types.js'; type ChallengeErrorCode = // Account is not activated | 'account_not_active' - // The principal associated with the credentials is not a user + // The principal associated with the credentials is not a user. | 'not_a_user' - // The user doesn't have any credentials set up + // The user doesn't have any credentials set up. | 'no_credentials' - // Username or password was wrong + // Username or password was wrong. | 'username_or_password_invalid' - // Username must be provided + // Username must be provided. | 'username_required' - // Password must be provided + // Password must be provided. | 'password_required' - // User must enter a TOTP code to continue + // User must enter a TOTP code to continue. | 'totp_required' // The TOTP code that was provided is invalid. | 'totp_invalid' - // The email address used to log in was not verified + // The user must enter the code sent to them by email. + | 'email_otp_required' + // The OTP code the user entered was invalid or expired. + | 'email_otp_invalid' + // The email address used to log in was not verified. | 'email_not_verified'; export class A12nLoginChallengeError extends OAuth2Error { diff --git a/src/login/service.ts b/src/login/service.ts index e52be3bc..bbe5d4a2 100644 --- a/src/login/service.ts +++ b/src/login/service.ts @@ -9,6 +9,7 @@ import { getLogger } from '../log/service.js'; import { generateSecretToken } from '../crypto.js'; import { LoginChallengePassword } from './challenge/password.js'; import { LoginChallengeTotp } from './challenge/totp.js'; +import { LoginChallengeEmailOtp } from './challenge/email-otp.js'; import { A12nLoginChallengeError } from './error.js'; import { AbstractLoginChallenge } from './challenge/abstract.js'; import { UserEventLogger } from '../log/types.js'; @@ -94,7 +95,7 @@ export async function challenge(client: AppClient, session: LoginSession, parame if (logSessionStart) log('login-challenge-started'); - const challenges = await getChallengesForPrincipal(principal, log); + const challenges = await getChallengesForPrincipal(principal, log, parameters.remote_addr!); if (challenges.length === 0) { throw new A12nLoginChallengeError( @@ -127,7 +128,7 @@ export async function challenge(client: AppClient, session: LoginSession, parame // passes. If this is not the case we're going to emit a challenge error. for(const challenge of challenges) { if (!session.challengesCompleted.includes(challenge.authFactor)) { - challenge.challenge(); + await challenge.challenge(); } } } @@ -273,11 +274,12 @@ async function initChallengeContext(session: LoginSession, parameters: Challenge /** * Returns the full list of login challenges the user has setup up. */ -async function getChallengesForPrincipal(principal: User, log: UserEventLogger): Promise[]> { +async function getChallengesForPrincipal(principal: User, log: UserEventLogger, ip: string): Promise[]> { const challenges = [ - new LoginChallengePassword(principal, log), - new LoginChallengeTotp(principal, log) + new LoginChallengePassword(principal, log, ip), + new LoginChallengeEmailOtp(principal, log, ip), + new LoginChallengeTotp(principal, log, ip) ]; const result = [];