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

moved login throttling to be rule based #256

Merged
merged 1 commit into from
Jan 13, 2025
Merged
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
180 changes: 101 additions & 79 deletions services/api/src/routes/auth/__tests__/password.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,85 +74,6 @@ describe('/1/auth', () => {
});
});

it('should throttle a few seconds after 5 bad attempts', async () => {
mockTime('2020-01-01');

const password = '123password!';
const user = await createUser({
password,
loginAttempts: 5,
lastLoginAttemptAt: new Date(),
});
let response;

response = await request('POST', '/1/auth/password/login', {
email: user.email,
password: 'bad password',
});
expect(response.status).toBe(401);

response = await request('POST', '/1/auth/password/login', {
email: user.email,
password,
});
expect(response.status).toBe(401);

advanceTime(60 * 1000);
response = await request('POST', '/1/auth/password/login', {
email: user.email,
password,
});
expect(response.status).toBe(200);

unmockTime();
});

it('should throttle 1 hour after 10 bad attempts', async () => {
mockTime('2020-01-01');

const password = '123password!';
const user = await createUser({
password,
loginAttempts: 9,
lastLoginAttemptAt: new Date(),
});
let response;

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(401);

advanceTime(60 * 60 * 1000);

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(200);

unmockTime();
});

it('should not throttle after successful login attempt', async () => {
mockTime('2020-01-01');

const password = '123password!';
const user = await createUser({
password,
loginAttempts: 10,
lastLoginAttemptAt: new Date(),
});
let response;

advanceTime(60 * 60 * 1000);

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(200);

advanceTime(1000);

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(200);

unmockTime();
});

it('should store the new token payload on the user', async () => {
mockTime('2020-01-01T00:00:00.000Z');
const password = '123password!';
Expand Down Expand Up @@ -219,6 +140,107 @@ describe('/1/auth', () => {

unmockTime();
});

describe('login throttling', () => {
it('should not throttle up to 5 attempts', async () => {
mockTime('2020-01-01');

let response;

const password = '123password!';
const user = await createUser({
password,
loginAttempts: 4,
lastLoginAttemptAt: new Date(),
});

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(200);

unmockTime();
});

it('should throttle 1 minute up to 10 attempts', async () => {
mockTime('2020-01-01');

let response;

const password = '123password!';
const user = await createUser({
password,
loginAttempts: 6,
lastLoginAttemptAt: new Date(),
});

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(401);

advanceTime(59 * 1000);
user.loginAttempts = 9;
await user.save();

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(401);

advanceTime(60 * 1000);
response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(200);

unmockTime();
});

it('should throttle 1 hour after 10 attempts', async () => {
mockTime('2020-01-01');

let response;

const password = '123password!';
const user = await createUser({
password,
loginAttempts: 10,
lastLoginAttemptAt: new Date(),
});

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(401);

advanceTime(59 * 60 * 1000);
await user.save();

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(401);

advanceTime(60 * 60 * 1000);
response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(200);

unmockTime();
});

it('should not throttle after successful login attempt', async () => {
mockTime('2020-01-01');

const password = '123password!';
const user = await createUser({
password,
loginAttempts: 10,
lastLoginAttemptAt: new Date(),
});
let response;

advanceTime(60 * 60 * 1000);

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(200);

advanceTime(1000);

response = await request('POST', '/1/auth/password/login', { email: user.email, password });
expect(response.status).toBe(200);

unmockTime();
});
});
});

describe('POST /register', () => {
Expand Down
28 changes: 0 additions & 28 deletions services/api/src/utils/__tests__/math.js

This file was deleted.

59 changes: 37 additions & 22 deletions services/api/src/utils/auth/login.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
const { AuditEntry } = require('../../models');
const { mapExponential } = require('../math');
const { createAuthToken, removeExpiredTokens } = require('./tokens');

const LOGIN_THROTTLE = {
// Apply lockout after 5 tries
triesMin: 5,
// Scale to max at 10 tries
triesMax: 12,
// 1 hour lockout maximum
timeMax: 60 * 60 * 1000,
};
const LOGIN_TIMEOUT_RULES = [
{
timeout: 0,
maxAttempts: 5,
},
{
minAttempts: 6,
maxAttempts: 10,
timeout: 60 * 1000,
},
{
minAttempts: 11,
timeout: 60 * 60 * 1000,
},
];

async function login(user, ctx) {
const token = createAuthToken(user, ctx);
Expand All @@ -26,24 +32,33 @@ async function login(user, ctx) {
}

async function verifyLoginAttempts(user, ctx) {
const { triesMin, triesMax, timeMax } = LOGIN_THROTTLE;

// Fixed random failing test where the Date.now() was a fraction later than new Date, resulting in negative number
// const dt = new Date() - (user.lastLoginAttemptAt || Date.now());
const now = new Date();
const dt = now - (user.lastLoginAttemptAt || now);
const threshold = mapExponential(user.loginAttempts || 0, triesMin, triesMax, 0, timeMax);

if (dt >= threshold) {
user.lastLoginAttemptAt = new Date();
user.loginAttempts += 1;
} else {
let { loginAttempts = 0, lastLoginAttemptAt } = user;

await user.updateOne({
lastLoginAttemptAt: new Date(),
$inc: {
loginAttempts: 1,
},
});

if (!lastLoginAttemptAt) {
return;
}

const rule = LOGIN_TIMEOUT_RULES.find((r) => {
const { minAttempts = 0, maxAttempts = Infinity } = r;
return loginAttempts >= minAttempts && loginAttempts <= maxAttempts;
});

const dt = new Date() - lastLoginAttemptAt;

if (dt < rule?.timeout) {
await AuditEntry.append('Reached max authentication attempts', {
ctx,
actor: user,
category: 'security',
});
throw Error(`Too many attempts. Try again in ${Math.max(1, Math.floor(threshold / (60 * 1000)))} minute(s)`);
throw Error('Too many login attempts. Please wait a bit and try again.');
}
}

Expand Down
21 changes: 0 additions & 21 deletions services/api/src/utils/math.js

This file was deleted.

Loading