Stall login/pw reset to prevent email leaking (#7105)

This commit is contained in:
Rijk van Zanten
2021-07-30 21:01:07 +02:00
committed by GitHub
parent a55d1bc4a7
commit 956c590f0c
3 changed files with 59 additions and 1 deletions

View File

@@ -18,6 +18,8 @@ import { ActivityService } from '../services/activity';
import { AbstractServiceOptions, Accountability, Action, SchemaOverview, Session } from '../types';
import { SettingsService } from './settings';
import { merge } from 'lodash';
import { performance } from 'perf_hooks';
import { stall } from '../utils/stall';
type AuthenticateOptions = {
email: string;
@@ -52,6 +54,9 @@ export class AuthenticationService {
async authenticate(
options: AuthenticateOptions
): Promise<{ accessToken: any; refreshToken: any; expires: any; id?: any }> {
const STALL_TIME = 100;
const timeStart = performance.now();
const settingsService = new SettingsService({
knex: this.knex,
schema: this.schema,
@@ -97,8 +102,10 @@ export class AuthenticationService {
emitStatus('fail');
if (user?.status === 'suspended') {
await stall(STALL_TIME, timeStart);
throw new UserSuspendedException();
} else {
await stall(STALL_TIME, timeStart);
throw new InvalidCredentialsException();
}
}
@@ -125,17 +132,20 @@ export class AuthenticationService {
if (password !== undefined) {
if (!user.password) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidCredentialsException();
}
if ((await argon2.verify(user.password, password)) === false) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidCredentialsException();
}
}
if (user.tfa_secret && !otp) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidOTPException(`"otp" is required`);
}
@@ -144,6 +154,7 @@ export class AuthenticationService {
if (otpValid === false) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidOTPException(`"otp" is invalid`);
}
}
@@ -193,6 +204,8 @@ export class AuthenticationService {
await loginAttemptsLimiter.set(user.id, 0, 0);
}
await stall(STALL_TIME, timeStart);
return {
accessToken,
refreshToken,

View File

@@ -20,6 +20,7 @@ import { AuthenticationService } from './authentication';
import { ItemsService, MutationOptions } from './items';
import { MailService } from './mail';
import { SettingsService } from './settings';
import { stall } from '../utils/stall';
export class UsersService extends ItemsService {
knex: Knex;
@@ -345,8 +346,14 @@ export class UsersService extends ItemsService {
}
async requestPasswordReset(email: string, url: string | null, subject?: string | null): Promise<void> {
const STALL_TIME = 500;
const timeStart = performance.now();
const user = await this.knex.select('id').from('directus_users').where({ email }).first();
if (!user) throw new ForbiddenException();
if (!user) {
await stall(STALL_TIME, timeStart);
throw new ForbiddenException();
}
const mailService = new MailService({
schema: this.schema,
@@ -375,6 +382,8 @@ export class UsersService extends ItemsService {
},
},
});
await stall(STALL_TIME, timeStart);
}
async resetPassword(token: string, password: string): Promise<void> {

36
api/src/utils/stall.ts Normal file
View File

@@ -0,0 +1,36 @@
import { performance } from 'perf_hooks';
/**
* Wait a specific time to meet the stall ms. Useful in cases where you need to make sure that every
* path in a function takes at least X ms (for example authenticate).
*
* @param {number} ms - Stall time to wait until
* @param {number} start - Current start time of the function
*
* @example
*
* ```js
* const STALL_TIME = 100;
*
* // Function will always take (at least) 100ms
* async function doSomething() {
* const timeStart = performance.now();
*
* if (something === true) {
* await heavy();
* }
*
* stall(STALL_TIME, timeStart);
* return 'result';
* }
* ```
*/
export async function stall(ms: number, start: number) {
const now = performance.now();
const timeElapsed = now - start;
const timeRemaining = ms - timeElapsed;
if (timeRemaining <= 0) return;
return new Promise((resolve) => setTimeout(resolve, timeRemaining));
}