-
Notifications
You must be signed in to change notification settings - Fork 52
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #560 from curveball/login-challenge-refactor
Refactored password and totp login challenges into a strategy pattern.
- Loading branch information
Showing
9 changed files
with
453 additions
and
192 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,3 +22,4 @@ class NewClientController extends Controller { | |
} | ||
|
||
export default new NewClientController(); | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
); | ||
|
||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
); | ||
|
||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
|
||
} | ||
|
||
} |
Oops, something went wrong.