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

feat(security): RN-1108: Prevent too many login attempts #5861

Open
wants to merge 42 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8ccc265
add migration
tcaiger Aug 22, 2024
9fd85dd
Update authenticate.js
tcaiger Aug 22, 2024
3bf8a75
unit test
tcaiger Aug 23, 2024
34900ee
refactor
tcaiger Aug 26, 2024
bb4df07
add slowBruteForceRateLimiter
tcaiger Aug 26, 2024
034c9fa
fix tests
tcaiger Aug 26, 2024
ae19ac3
comments
tcaiger Aug 26, 2024
ca6826b
generate types
tcaiger Aug 26, 2024
af89f21
refactor tupaia rate limiter
tcaiger Sep 9, 2024
826d22e
mocking
tcaiger Sep 9, 2024
bc3253c
update tests
tcaiger Sep 9, 2024
26bb07d
clean up
tcaiger Sep 9, 2024
eb17d28
fix tests
tcaiger Sep 10, 2024
d94e363
Update OneTimeLogin.js
tcaiger Sep 10, 2024
2be9f79
Update authenticate.test.js
tcaiger Sep 10, 2024
ee26015
Merge branch 'dev' into rn-1108-prevent-too-many-login-attempts
tcaiger Sep 24, 2024
26cdbd1
Merge pull request #5951 from beyondessential/dev
avaek Oct 8, 2024
13cda35
update ip key
tcaiger Oct 10, 2024
2966b2a
Update authenticate.js
tcaiger Oct 10, 2024
137fbfb
Update LoginRoute.ts
tcaiger Oct 14, 2024
0ff22c9
update X-Forwarded-For
tcaiger Oct 14, 2024
0034882
forward ip address
tcaiger Oct 14, 2024
ba26ffc
add x-real-ip
tcaiger Oct 14, 2024
35618cf
logging
tcaiger Oct 15, 2024
6ae1b73
trust proxy
tcaiger Oct 15, 2024
363aa48
add public ip
tcaiger Oct 15, 2024
3acdc19
update orchestration server
tcaiger Oct 16, 2024
0a80d98
add proxy to config
tcaiger Oct 16, 2024
6fc4efe
use older version of public-ip
tcaiger Oct 16, 2024
d2bd526
update central server
tcaiger Oct 16, 2024
fba35ef
remove console logs
tcaiger Oct 16, 2024
7489745
update type for custom headers
tcaiger Oct 16, 2024
f64b91e
temp rollback ip changes
tcaiger Oct 16, 2024
2280165
Merge branch 'dev' into rn-1108-prevent-too-many-login-attempts
tcaiger Oct 21, 2024
cfe6f07
fix(auth): RN-1108: Securely read client ip for rate limiting (#5962)
tcaiger Oct 23, 2024
e2627bf
Merge branch 'dev' into rn-1108-prevent-too-many-login-attempts
tcaiger Oct 23, 2024
72194eb
Update authenticate.js
tcaiger Oct 23, 2024
ae94818
finish merge
tcaiger Oct 23, 2024
def422e
Update createApp.js
tcaiger Oct 23, 2024
51c2af1
Update errors.test.ts
tcaiger Oct 24, 2024
8c12512
Merge pull request #5983 from beyondessential/dev
avaek Oct 28, 2024
99847c9
Merge pull request #5988 from beyondessential/dev
avaek Nov 4, 2024
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
2 changes: 1 addition & 1 deletion packages/central-server/examples.http
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

# @name login
POST {{baseUrl}}/auth HTTP/2.0
Authorization: Basic meditrak_client:{{meditrak-app-secret}}
Authorization: Basic meditrak_client {{meditrak-app-secret}}
content-type: {{contentType}}

{
Expand Down
1 change: 1 addition & 0 deletions packages/central-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"morgan": "^1.9.0",
"multer": "^1.4.3",
"public-ip": "^2.5.0",
"rate-limiter-flexible": "^5.0.3",
"react-autobind": "^1.0.6",
"react-native-uuid": "^1.4.9",
"s3urls": "^1.5.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/

import { RateLimiterPostgres } from 'rate-limiter-flexible';
import { respond } from '@tupaia/utils';

// Limit the number of wrong attempts per day per IP to 10 for the unit tests
const MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = 100;

/**
* Singleton instances of RateLimiterPostgres
*/
let postgresRateLimiter = null;

/**
* Rate limiter which limits the number of wrong attempts per day per IP
*/
export class BruteForceRateLimiter {
constructor(database) {
if (!postgresRateLimiter) {
postgresRateLimiter = new RateLimiterPostgres({
tableCreated: true,
tableName: 'login_attempts',
storeClient: database.connection,
storeType: 'knex',
keyPrefix: 'login_fail_ip_per_day',
points: this.getMaxAttempts(),
duration: 60 * 60 * 24,
blockDuration: 60 * 60 * 24, // Block for 1 day, if 100 wrong attempts per day
});
}

this.postgresRateLimiter = postgresRateLimiter;
}

/**
* Get the maximum number of failed attempts allowed per day. Useful for testing.
* @returns {number}
*/
getMaxAttempts() {
return MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY;
}

/**
* Generate a key for the postgresRateLimiter based on the ip
* @returns {string}
*/
getIPkey(req) {
return req.ip;
}

/**
* Check if the user is rate limited
* @returns {Promise<boolean>}
*/
async checkIsRateLimited(req) {
const slowBruteForceResponder = await this.postgresRateLimiter.get(this.getIPkey(req));
return (
slowBruteForceResponder !== null &&
slowBruteForceResponder.consumedPoints >= this.getMaxAttempts()
);
}

/**
* Get the time until the user can retry.
* @returns {Promise<number>} Returns a number in milliseconds
*/
async getRetryAfter(req) {
try {
await this.postgresRateLimiter.consume(this.getIPkey(req));
} catch (rlRejected) {
return rlRejected.msBeforeNext;
}
}

/**
* Add a failed attempt to the rate limiter login_attempts table
*/
async addFailedAttempt(req) {
try {
// Add a failed attempt to the rate limiter. Gets stored in the login_attempts table
await this.postgresRateLimiter.consume(this.getIPkey(req));
} catch (rlRejected) {
// node-rate-limiter is designed to reject the promise when saving failed attempts
// We swallow the error here and let the original error bubble up
}
}

async resetFailedAttempts(req) {
await this.postgresRateLimiter.delete(this.getIPkey(req));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/

import { RateLimiterPostgres } from 'rate-limiter-flexible';
import { respond } from '@tupaia/utils';

const MAX_CONSECUTIVE_FAILS_BY_USERNAME = 10;

/**
* Singleton instances of RateLimiterPostgres
*/
let postgresRateLimiter = null;

/**
* Rate limiter which limits the number of consecutive failed attempts by username
*/
export class ConsecutiveFailsRateLimiter {
constructor(database) {
if (!postgresRateLimiter) {
postgresRateLimiter = new RateLimiterPostgres({
tableCreated: true,
tableName: 'login_attempts',
storeClient: database.connection,
storeType: 'knex',
keyPrefix: 'login_fail_consecutive_username',
points: this.getMaxAttempts(),
duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
blockDuration: 60 * 15, // Block for 15 minutes
});
}

this.postgresRateLimiter = postgresRateLimiter;
}

/**
* Get the maximum number of consecutive failed attempts allowed. Useful for testing.
* @returns {number}
*/
getMaxAttempts() {
return MAX_CONSECUTIVE_FAILS_BY_USERNAME;
}

/**
* Generate a key for the postgresRateLimiter based on the username and ip
* @returns {string}
*/
getUsernameIPkey(req) {
const { ip, body } = req;
return `${body.emailAddress}_${ip}`;
}

/**
* Check if the user is rate limited
* @returns {Promise<boolean>}
*/
async checkIsRateLimited(req) {
const maxConsecutiveFailsResponder = await this.postgresRateLimiter.get(
this.getUsernameIPkey(req),
);
return (
maxConsecutiveFailsResponder !== null &&
maxConsecutiveFailsResponder.consumedPoints >= this.getMaxAttempts()
);
}

/**
* Get the time until the user can retry.
* @returns {Promise<number>} Returns a number in milliseconds
*/
async getRetryAfter(req) {
try {
await this.postgresRateLimiter.consume(this.getUsernameIPkey(req));
} catch (rlRejected) {
return rlRejected.msBeforeNext;
}
}

async addFailedAttempt(req) {
try {
// Add a failed attempt to the rate limiter. Gets stored in the login_attempts table
await this.postgresRateLimiter.consume(this.getUsernameIPkey(req));
} catch (rlRejected) {
// node-rate-limiter is designed to reject the promise when saving failed attempts
// We swallow the error here and let the original error bubble up
}
}

async resetFailedAttempts(req) {
await this.postgresRateLimiter.delete(this.getUsernameIPkey(req));
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/**
* Tupaia MediTrak
* Copyright (c) 2017 Beyond Essential Systems Pty Ltd
/*
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/

import winston from 'winston';

import { getAuthorizationObject, getUserAndPassFromBasicAuth } from '@tupaia/auth';
import { respond, reduceToDictionary } from '@tupaia/utils';
import { allowNoPermissions } from '../permissions';
import { allowNoPermissions } from '../../permissions';
import { ConsecutiveFailsRateLimiter } from './ConsecutiveFailsRateLimiter';
import { BruteForceRateLimiter } from './BruteForceRateLimiter';

const GRANT_TYPES = {
PASSWORD: 'password',
Expand Down Expand Up @@ -98,6 +98,13 @@ const checkApiClientAuthentication = async req => {
}
};

async function respondToRateLimitedUser(msBeforeNext, res) {
const retrySecs = Math.round(msBeforeNext / 1000) || 1;
const retryMins = Math.round(retrySecs / 60) || 1;
res.set('Retry-After', retrySecs);
return respond(res, { error: `Too Many Requests. Retry in ${retryMins} min(s)` }, 429);
}

/**
* Handler for a POST to the /auth endpoint
* By default, or if URL parameters include grantType=password, will check the email address and
Expand All @@ -108,23 +115,53 @@ const checkApiClientAuthentication = async req => {
* and if valid, returns a new JWT token that can be used for accessing the API
* Override grants to do recursive authentication, for example when creating a new user.
*/

export async function authenticate(req, res) {
await req.assertPermissions(allowNoPermissions);
const { grantType } = req.query;
const consecutiveFailsRateLimiter = new ConsecutiveFailsRateLimiter(req.database);
const bruteForceRateLimiter = new BruteForceRateLimiter(req.database);

const { refreshToken, user, accessPolicy } = await checkUserAuthentication(req);
const { user: apiClientUser } = await checkApiClientAuthentication(req);
if (await bruteForceRateLimiter.checkIsRateLimited(req)) {
const msBeforeNext = await bruteForceRateLimiter.getRetryAfter(req);
return respondToRateLimitedUser(msBeforeNext, res);
}

const permissionGroupsByCountryId = await extractPermissionGroupsIfLegacy(
req.models,
accessPolicy,
);
const authorizationObject = await getAuthorizationObject({
refreshToken,
user,
accessPolicy,
apiClientUser,
permissionGroups: permissionGroupsByCountryId,
});

respond(res, authorizationObject);
if (await consecutiveFailsRateLimiter.checkIsRateLimited(req)) {
const msBeforeNext = await consecutiveFailsRateLimiter.getRetryAfter(req);
return respondToRateLimitedUser(msBeforeNext, res);
}
tcaiger marked this conversation as resolved.
Show resolved Hide resolved

// Check if the user is authorised
try {
const { refreshToken, user, accessPolicy } = await checkUserAuthentication(req);
const { user: apiClientUser } = await checkApiClientAuthentication(req);
const permissionGroupsByCountryId = await extractPermissionGroupsIfLegacy(
req.models,
accessPolicy,
);

const authorizationObject = await getAuthorizationObject({
refreshToken,
user,
accessPolicy,
apiClientUser,
permissionGroups: permissionGroupsByCountryId,
});

// Reset on successful authorisation
await consecutiveFailsRateLimiter.resetFailedAttempts(req);
await bruteForceRateLimiter.resetFailedAttempts(req);
respond(res, authorizationObject, 200);
} catch (authError) {
if (authError.statusCode === 401) {
tcaiger marked this conversation as resolved.
Show resolved Hide resolved
// Record failed login attempt to rate limiter
await bruteForceRateLimiter.addFailedAttempt(req);
if (grantType === GRANT_TYPES.PASSWORD) {
await consecutiveFailsRateLimiter.addFailedAttempt(req);
}
}

throw authError;
}
}
6 changes: 6 additions & 0 deletions packages/central-server/src/apiV2/authenticate/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/*
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/

export { authenticate } from './authenticate';
Loading
Loading