Skip to content

Commit

Permalink
Merge pull request #560 from curveball/login-challenge-refactor
Browse files Browse the repository at this point in the history
Refactored password and totp login challenges into a strategy pattern.
  • Loading branch information
evert authored Jan 8, 2025
2 parents 4b170f1 + 9b625b7 commit 38914b2
Show file tree
Hide file tree
Showing 9 changed files with 453 additions and 192 deletions.
1 change: 1 addition & 0 deletions src/app-client/controller/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ class NewClientController extends Controller {
}

export default new NewClientController();

77 changes: 77 additions & 0 deletions src/login/challenge/abstract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { AuthorizationChallengeRequest, LoginSession } from '../types.js';
import { User } from '../../types.js';
import { AuthFactorType } from '../../user-auth-factor/types.js';
import { UserEventLogger } from '../../log/types.js';

/**
* This abstract class is implemented by various authentication challenge strategies.
*/
export abstract class AbstractLoginChallenge<TChallengeParameters> {

/**
* The type of authentication factor this class provides.
*/
abstract readonly authFactor: AuthFactorType;

/**
* The principal associated with the process.
*
* This class will only be used for a single user, so this lets you cache responses if needed.
*/
protected principal: User;

/**
* Logger function
*/
protected log: UserEventLogger;

constructor(principal: User, logger: UserEventLogger) {
this.principal = principal;
this.log = logger;
}

/**
* 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.
*/
abstract userHasChallenge(): Promise<boolean>;

/**
* Handle the user response to a challenge.
*
* Should return true if the challenge passed.
* Should throw an Error ihe challenge failed.
*/
abstract checkResponse(session: LoginSession, parameters: TChallengeParameters): Promise<boolean>;

/**
* 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.
*/
abstract parametersContainsResponse(parameters: AuthorizationChallengeRequest): parameters is TChallengeParameters & AuthorizationChallengeRequest;

/**
* Emits the initial challenge.
*
* This notifies the user that some kind of response is expected as a reply
* to this challenge.
*/
abstract challenge(session: LoginSession): never;

/**
* Validates whether the parameters object contains expected values.
*
* This for instance will make sure that a 'password' key was provided for
* the Password challenge.
*/
validateParameters(parameters: AuthorizationChallengeRequest): asserts parameters is TChallengeParameters & AuthorizationChallengeRequest {
if (!this.parametersContainsResponse(parameters)) {
throw new Error('Invalid state. This should normally not happen unless there\'s a logic bug');
}
}

}
81 changes: 81 additions & 0 deletions src/login/challenge/password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { AbstractLoginChallenge } from './abstract.js';
import { AuthorizationChallengeRequest, LoginSession } from '../types.js';
import { A12nLoginChallengeError } from '../error.js';
import * as services from '../../services.js';

type PasswordParameters = {
password: string;
}

/**
* Password-based authentication strategy.
*/
export class LoginChallengePassword extends AbstractLoginChallenge<PasswordParameters> {

/**
* The type of authentication factor this class provides.
*/
readonly authFactor = 'password';

/**
* 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.
*/
userHasChallenge(): Promise<boolean> {

return services.user.hasPassword(this.principal);

}

/**
* Handle the user response to a challenge.
*
* Should return true if the challenge passed.
* Should throw an Error ihe challenge failed.
*/
async checkResponse(session: LoginSession, parameters: PasswordParameters): Promise<boolean> {

const { success, errorMessage } = await services.user.validateUserCredentials(this.principal, parameters.password, this.log);
if (!success && errorMessage) {
throw new A12nLoginChallengeError(
session,
errorMessage,
'username_or_password_invalid',
);
}

return true;

}

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

return parameters.password !== undefined;

}

/**
* Emits the initial challenge.
*
* This notifies the user that some kind of response is expected as a reply
* to this challenge.
*/
challenge(session: LoginSession): never {

throw new A12nLoginChallengeError(
session,
'A username and password are required',
'password_required',
);

}

}
106 changes: 106 additions & 0 deletions src/login/challenge/totp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { AbstractLoginChallenge } from './abstract.js';
import { AuthorizationChallengeRequest, LoginSession } from '../types.js';
import { A12nLoginChallengeError } from '../error.js';
import * as services from '../../services.js';
import { InvalidGrant } from '../../oauth2/errors.js';
import { getSetting } from '../../server-settings.js';

type TotpParameters = {
totp_code: string;
}

/**
* Time-based-one-time-passwords.
*
* This strategy handles authenticator apps.
*/
export class LoginChallengeTotp extends AbstractLoginChallenge<TotpParameters> {

/**
* The type of authentication factor this class provides.
*/
readonly authFactor = 'totp';

/**
* 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 serverTotpMode = getSetting('totp');
if (serverTotpMode === 'disabled') return false;
return services.mfaTotp.hasTotp(this.principal);

}

async checkResponse(session: LoginSession, parameters: TotpParameters): Promise<boolean> {

const serverTotpMode = getSetting('totp');
if (serverTotpMode === 'disabled') {
// Server-wide TOTP disabled.
return true;
}
const hasTotp = await services.mfaTotp.hasTotp(this.principal);
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
return 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_required',
);
}
if (!await services.mfaTotp.validateTotp(this.principal, parameters.totp_code)) {
this.log('totp-failed');
// 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_invalid',
);
} else {
this.log('totp-success');
};

// TOTP check successful!
return true;

}
/**
* 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 TotpParameters {

return parameters.totp_code !== undefined;

}

/**
* Emits the initial challenge.
*
* This notifies the user that some kind of response is expected as a reply
* to this challenge.
*/
challenge(session: LoginSession): never {

throw new A12nLoginChallengeError(
session,
'Please provide a TOTP code from the user\'s authenticator app.',
'totp_required',
);

}

}
3 changes: 3 additions & 0 deletions src/login/controller/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { PrincipalIdentity, User } from '../../types.js';
import { loginForm } from '../formats/html.js';
import { isValidRedirect } from '../utilities.js';

/**
* The Login controller renders the basic login form
*/
class LoginController extends Controller {

async get(ctx: Context) {
Expand Down
5 changes: 5 additions & 0 deletions src/login/controller/mfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { MFALoginSession } from '../../mfa/types.js';
import { mfaForm } from '../formats/html.js';
import { getLoggerFromContext } from '../../log/service.js';

/**
* Multi-factor-auth controller
*
* Handles both TOTP and Webauthn
*/
class MFAController extends Controller {

async get(ctx: Context) {
Expand Down
49 changes: 49 additions & 0 deletions src/login/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { OAuth2Error } from '../oauth2/errors.js';
import { LoginSession } from './types.js';

type ChallengeErrorCode =
// Account is not activated
| 'account_not_active'
// The principal associated with the credentials is not a user
| 'not_a_user'
// The user doesn't have any credentials set up
| 'no_credentials'
// Username or password was wrong
| 'username_or_password_invalid'
// Username must be provided
| 'username_required'
// Password must be provided
| 'password_required'
// 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
| 'email_not_verified';

export class A12nLoginChallengeError extends OAuth2Error {

httpStatus = 400;
errorCode: ChallengeErrorCode;
session: LoginSession;

constructor(session: LoginSession, message: string, errorCode: ChallengeErrorCode) {

super(message);
this.errorCode = errorCode;
this.session = session;

}

serializeErrorBody() {

return {
error: this.errorCode,
error_description: this.message,
auth_session: this.session.authSession,
expires_at: this.session.expiresAt,
};

}

}
Loading

0 comments on commit 38914b2

Please sign in to comment.