Skip to content

Commit

Permalink
MFA via one-time-code sent via email
Browse files Browse the repository at this point in the history
  • Loading branch information
evert committed Jan 8, 2025
1 parent 5c5a143 commit 2f21225
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 14 deletions.
7 changes: 7 additions & 0 deletions schemas/authorization-challenge-request.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
4 changes: 4 additions & 0 deletions src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
10 changes: 8 additions & 2 deletions src/login/challenge/abstract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ export abstract class AbstractLoginChallenge<TChallengeParameters> {
*/
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;
}

/**
Expand Down Expand Up @@ -60,7 +66,7 @@ export abstract class AbstractLoginChallenge<TChallengeParameters> {
* This notifies the user that some kind of response is expected as a reply
* to this challenge.
*/
abstract challenge(): never;
abstract challenge(): Promise<never>;

/**
* Validates whether the parameters object contains expected values.
Expand Down
114 changes: 114 additions & 0 deletions src/login/challenge/email-otp.ts
Original file line number Diff line number Diff line change
@@ -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<EmailOtpParameters> {

/**
* 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<boolean> {

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<boolean> {

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);

Check failure on line 69 in src/login/challenge/email-otp.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
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<never> {

console.log('challenge-now');

Check failure on line 82 in src/login/challenge/email-otp.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
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<PrincipalIdentity>;
private async findMfaIdentity(): Promise<PrincipalIdentity|null>;

Check failure on line 98 in src/login/challenge/email-otp.ts

View workflow job for this annotation

GitHub Actions / Lint

Trailing spaces not allowed
private async findMfaIdentity(must = false): Promise<PrincipalIdentity|null> {

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;

}

}
18 changes: 11 additions & 7 deletions src/login/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 7 additions & 5 deletions src/login/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
}
}
}
Expand Down Expand Up @@ -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<AbstractLoginChallenge<unknown>[]> {
async function getChallengesForPrincipal(principal: User, log: UserEventLogger, ip: string): Promise<AbstractLoginChallenge<unknown>[]> {

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 = [];
Expand Down

0 comments on commit 2f21225

Please sign in to comment.