Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding TOTP to the authorization-challenge system (wip) #530

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion schemas/authorization-challenge-request.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,17 @@
"type": "string"
},
"password": {
"type": "string"
"type": "string",
"description": "User-supplied password"
},
"totp_code": {
"type": "string",
"description": "A 6 digit TOTP code / authenticator app code",
"minLength": 6,
"maxLength": 6,
"pattern": "/^[0-9]{6}$/"
}

}

}
4 changes: 4 additions & 0 deletions src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ export interface AuthorizationChallengeRequest {
auth_session?: string;
username?: string;
password?: string;
/**
* A 6 digit TOTP code / authenticator app code
*/
totp_code?: string;
}
/* eslint-disable */
/**
Expand Down
213 changes: 147 additions & 66 deletions src/login/service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { BadRequest, NotFound } from '@curveball/http-errors';
import { Context } from '@curveball/kernel';

Check failure on line 2 in src/login/service.ts

View workflow job for this annotation

GitHub Actions / Build

'Context' is declared but its value is never read.
import { AuthorizationChallengeRequest } from '../api-types.js';
import { InvalidGrant, OAuth2Error } from '../oauth2/errors.js';
import { OAuth2Code } from '../oauth2/types.js';
import { getSetting } from '../server-settings.js';
import * as services from '../services.js';
import { getSessionStore } from '../session-store.js';
import { AppClient, Principal, PrincipalIdentity, User } from '../types.js';
Expand All @@ -24,10 +25,7 @@
*/
principalId: number | null;

/**
* Password was checked.
*/
passwordValid: boolean;


/**
* List of OAuth2 scopes
Expand All @@ -40,6 +38,16 @@
*/
dirty: boolean;

/**
* Password was checked.
*/
passwordPassed: boolean;

/**
* TOTP code has been checked
*/
totpPassed: boolean;

};

type LoginSessionStage2 = LoginSession & {
Expand All @@ -52,7 +60,15 @@
/**
* Password was checked.
*/
passwordValid: true;
passwordPassed: true;

}
type LoginSessionStage3 = LoginSession & {

/**
* TOTP code has been checked
*/
totpPassed: true;

}

Expand All @@ -61,6 +77,17 @@
*/
const LOGIN_SESSION_EXPIRY = 60*20;

/**
* Get a login session by providing login information.
*
* Logins may consist of multiple steps. We need to keep track of what information
* was supplied across each step.
*
* This function starts such a session, which may later be continued by using
* a login session id. (authSession).
*
* This function can be used to either kick off a new session, or continue the session.
*/
export async function getSession(client: AppClient, parameters: ChallengeRequest): Promise<LoginSession> {

if (parameters.auth_session) {
Expand All @@ -74,8 +101,7 @@

}


export async function startLoginSession(client: AppClient, scope?: string[]): Promise<LoginSession> {
async function startLoginSession(client: AppClient, scope?: string[]): Promise<LoginSession> {

const store = getSessionStore();
const id: string = await store.newSessionId();
Expand All @@ -85,45 +111,14 @@
appClientId: client.id,
expiresAt: Math.floor(Date.now() / 1000) + LOGIN_SESSION_EXPIRY,
principalId: null,
passwordValid: false,
passwordPassed: false,
totpPassed: false,
scope,
dirty: true,
};

}

export async function continueLoginSession(client: AppClient, authSession: string): Promise<LoginSession> {

const store = getSessionStore();
const session: LoginSession|null = await store.get(authSession) as LoginSession|null;

if (session === null) {
throw new InvalidGrant('Invalid auth_session');
}

if (session.appClientId != client.id) {
throw new InvalidGrant('The client you authenticated with did not start this login session');
}

return session;

}

export async function storeSession(session: LoginSession) {

const store = getSessionStore();
await store.set(session.authSession, session, session.expiresAt);

}
async function deleteSession(session: LoginSession) {

const store = getSessionStore();
await store.delete(session.authSession);

}



/**
* Validate a login challenge request.
*
Expand All @@ -133,28 +128,24 @@
* If more credentials are needed or if any information is incorrect, an error
* will be thrown.
*/
export async function challenge(client: AppClient, session: LoginSession, parameters: ChallengeRequest, ctx: Context): Promise<OAuth2Code> {
export async function challenge(client: AppClient, session: LoginSession, parameters: ChallengeRequest): Promise<OAuth2Code> {

let user;

try {
if (!session.principalId) {
if (parameters.username === undefined || parameters.username === undefined) {
throw new A12nLoginChallengeError(
session,
'A username and password are required',
'username-password',
false,
);

}
await challengeUsernamePassword(
session,
parameters.username!,
parameters.password!,
ctx,
);
await challengeUsernamePassword(session, parameters);
}
assertSessionStage2(session);

const principalService = new services.principal.PrincipalService('insecure');
user = await principalService.findById(session.principalId, 'user');

if (!session.totpPassed) {
await challengeTotp(session, parameters, user);
}

assertSessionStage3(session);

} finally {

Expand All @@ -165,10 +156,8 @@

}

assertSessionStage2(session);

const principalService = new services.principal.PrincipalService('insecure');
const user = await principalService.findById(session.principalId, 'user');


await deleteSession(session);

Expand All @@ -186,13 +175,52 @@

}

async function challengeUsernamePassword(session: LoginSession, username: string, password: string, ctx: Context): Promise<User> {
async function continueLoginSession(client: AppClient, authSession: string): Promise<LoginSession> {

const store = getSessionStore();
const session: LoginSession|null = await store.get(authSession) as LoginSession|null;

if (session === null) {
throw new InvalidGrant('Invalid auth_session');
}

if (session.appClientId != client.id) {
throw new InvalidGrant('The client you authenticated with did not start this login session');
}

return session;

}

async function storeSession(session: LoginSession) {

const store = getSessionStore();
await store.set(session.authSession, session, session.expiresAt);

}
async function deleteSession(session: LoginSession) {

const store = getSessionStore();
await store.delete(session.authSession);

}

async function challengeUsernamePassword(session: LoginSession, parameters: ChallengeRequest): Promise<User> {

if (parameters.username === undefined || parameters.password === undefined) {
throw new A12nLoginChallengeError(
session,
'A username and password are required',
'username-password',
false,
);

}
const principalService = new services.principal.PrincipalService('insecure');
let user: Principal;
let identity: PrincipalIdentity;
try {
identity = await services.principalIdentity.findByUri('mailto:' + username);
identity = await services.principalIdentity.findByUri('mailto:' + parameters.username);
} catch (err) {
if (err instanceof NotFound) {
throw new A12nLoginChallengeError(
Expand Down Expand Up @@ -231,8 +259,8 @@
);
}

const log = getLoggerFromContext(ctx, user);

Check failure on line 262 in src/login/service.ts

View workflow job for this annotation

GitHub Actions / Build

Cannot find name 'ctx'.
const { success, errorMessage } = await services.user.validateUserCredentials(user, password, log);

Check failure on line 263 in src/login/service.ts

View workflow job for this annotation

GitHub Actions / Build

Cannot find name 'password'.
if (!success && errorMessage) {
throw new A12nLoginChallengeError(
session,
Expand All @@ -243,7 +271,7 @@
}

session.principalId = user.id;
session.passwordValid = true;
session.passwordPassed = true;
session.dirty = true;

if (!user.active) {
Expand All @@ -266,11 +294,59 @@
return user;
}

/**
* This function is responsible for ensuring that if TOTP is set up for a user,
* it gets checked.
*/
async function challengeTotp(session: LoginSession, parameters: ChallengeRequest, user: User): Promise<void> {

const serverTotpMode = getSetting('totp');
if (serverTotpMode === 'disabled') {
// Server-wide TOTP disabled.
session.totpPassed = true;
session.dirty = true;
return;
}
const hasTotp = await services.mfaTotp.hasTotp(user);
if (!hasTotp) {
// Does this server require TOTP
if (serverTotpMode === 'required') {
throw new InvalidGrant('This server is configured to require TOTP, and this user does not have TOTP set up. Logging in is not possible for this user in its current state. Contact an administrator');
}
// User didn't have TOTP so we just pass them
session.totpPassed = true;
session.dirty = true;
}
if (!parameters.totp_code) {
// No TOTP code was provided
throw new A12nLoginChallengeError(
session,
'Please provide a TOTP code from the user\'s authenticator app.',
'totp',
false,
);
}
if (!await services.mfaTotp.validateTotp(user, parameters.totp_code)) {
// TOTP code was incorrect
throw new A12nLoginChallengeError(
session,
'Incorrect TOTP code. Make sure your system clock is set to the correct time and try again',
'totp',
true
);
}

// TOTP check successful!
session.totpPassed = true;
session.dirty = true;

}

type ChallengeType =
| 'username-password' // We want a username and password
| 'activate' // Account is inactive. There's nothing the user can do.
| 'verify-email' // We recognized the email address, but it was never verified
| 'totp' // Please supply a TOTP code.

class A12nLoginChallengeError extends OAuth2Error {

Expand Down Expand Up @@ -303,17 +379,22 @@

}



}

function assertSessionStage2(session: LoginSession): asserts session is LoginSessionStage2 {

if (!session.principalId) {
throw new Error('Invalid state: missing principalId');
}
if (!session.passwordValid) {
throw new Error('Invalid state: passwordValid was false');
if (!session.passwordPassed) {
throw new Error('Invalid state: passwordPassed was false');
}

}
function assertSessionStage3(session: LoginSession): asserts session is LoginSessionStage3 {

if (!session.totpPassed) {
throw new Error('Invalid state: totpChecked should have been true');
}

}
Expand Down
Loading