Skip to content

Commit

Permalink
Merge pull request #6012 from beyondessential/dev
Browse files Browse the repository at this point in the history
merge: update branch with latest dev
  • Loading branch information
avaek authored Nov 27, 2024
2 parents 7283ac3 + bb45af8 commit 90f95de
Show file tree
Hide file tree
Showing 24 changed files with 785 additions and 341 deletions.
3 changes: 1 addition & 2 deletions env/servers.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@

JWT_SECRET=
SESSION_COOKIE_SECRET=


TRUSTED_PROXY_IPS=
3 changes: 2 additions & 1 deletion packages/central-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"morgan": "^1.9.0",
"multer": "^1.4.3",
"node-schedule": "^2.1.1",
"public-ip": "^2.5.0",
"public-ip": "4.0.4",
"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';

// Limit the number of wrong attempts per day per IP to 100 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
});
}
// Reset the points with getMaxAttempts for test mocking
postgresRateLimiter.points = this.getMaxAttempts();
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';

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
});
}
// Reset the points with getMaxAttempts for test mocking
postgresRateLimiter.points = this.getMaxAttempts();
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
* @returns {string}
*/
getUsernameKey(req) {
const { body } = req;
return body.emailAddress;
}

/**
* Check if the user is rate limited
* @returns {Promise<boolean>}
*/
async checkIsRateLimited(req) {
const maxConsecutiveFailsResponder = await this.postgresRateLimiter.get(
this.getUsernameKey(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.getUsernameKey(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.getUsernameKey(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.getUsernameKey(req));
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
/**
* 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 { getCountryForTimezone } from 'countries-and-timezones';
import { getAuthorizationObject, getUserAndPassFromBasicAuth } from '@tupaia/auth';
import { respond, reduceToDictionary } from '@tupaia/utils';
import { allowNoPermissions } from '../permissions';
import { createSupportTicket } from '../utilities';
import { ConsecutiveFailsRateLimiter } from './ConsecutiveFailsRateLimiter';
import { BruteForceRateLimiter } from './BruteForceRateLimiter';
import { allowNoPermissions } from '../../permissions';
import { checkUserLocationAccess } from './checkUserLocationAccess';
import { respondToRateLimitedUser } from './respondToRateLimitedUser';

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

const checkUserLocationAccess = async (req, user) => {
if (!user) return;
const { body, models } = req;
const { timezone } = body;

// The easiest way to get the country code is to use the timezone and get the most likely country using this timezone. This doesn't infringe on the user's privacy as the timezone is a very broad location. It also doesn't require the user to provide their location, which is a barrier to entry for some users.
const country = getCountryForTimezone(timezone);
if (!country) return;
// the ID is the ISO country code.
const { id, name } = country;

const existingEntry = await models.userCountryAccessAttempt.findOne({
user_id: user.id,
country_code: id,
});

// If there is already an entry for this user and country, return
if (existingEntry) return;

const userEntryCount = await models.userCountryAccessAttempt.count({
user_id: user.id,
});

const hasAnyEntries = userEntryCount > 0;

await models.userCountryAccessAttempt.create({
user_id: user.id,
country_code: id,
});

// Don't send an email if this is the first time the user has attempted to login
if (!hasAnyEntries) return;

// create a support ticket if the user has attempted to login from a new country
await createSupportTicket(
'User attempted to login from a new country',
`User ${user.first_name} ${user.last_name} (${user.id} - ${user.email}) attempted to access Tupaia from a new country: ${name}`,
);
};

/**
* 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 @@ -151,23 +112,53 @@ const checkUserLocationAccess = async (req, 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,
});
if (await consecutiveFailsRateLimiter.checkIsRateLimited(req)) {
const msBeforeNext = await consecutiveFailsRateLimiter.getRetryAfter(req);
return respondToRateLimitedUser(msBeforeNext, res);
}

await checkUserLocationAccess(req, user);
// 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,
});

await checkUserLocationAccess(req, user);

// Reset rate limiting on successful authorisation
await consecutiveFailsRateLimiter.resetFailedAttempts(req);
await bruteForceRateLimiter.resetFailedAttempts(req);

respond(res, authorizationObject, 200);
} catch (authError) {
if (authError.statusCode === 401) {
// Record failed login attempt to rate limiter
await bruteForceRateLimiter.addFailedAttempt(req);
if (grantType === GRANT_TYPES.PASSWORD || grantType === undefined) {
await consecutiveFailsRateLimiter.addFailedAttempt(req);
}
}

respond(res, authorizationObject);
throw authError;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/

import { getCountryForTimezone } from 'countries-and-timezones';
import { createSupportTicket } from '../../utilities';

export const checkUserLocationAccess = async (req, user) => {
if (!user) return;
const { body, models } = req;
const { timezone } = body;

// The easiest way to get the country code is to use the timezone and get the most likely country using this timezone. This doesn't infringe on the user's privacy as the timezone is a very broad location. It also doesn't require the user to provide their location, which is a barrier to entry for some users.
const country = getCountryForTimezone(timezone);
if (!country) return;
// the ID is the ISO country code.
const { id, name } = country;

const existingEntry = await models.userCountryAccessAttempt.findOne({
user_id: user.id,
country_code: id,
});

// If there is already an entry for this user and country, return
if (existingEntry) return;

const userEntryCount = await models.userCountryAccessAttempt.count({
user_id: user.id,
});

const hasAnyEntries = userEntryCount > 0;

await models.userCountryAccessAttempt.create({
user_id: user.id,
country_code: id,
});

// Don't send an email if this is the first time the user has attempted to login
if (!hasAnyEntries) return;

// create a support ticket if the user has attempted to login from a new country
await createSupportTicket(
'User attempted to login from a new country',
`User ${user.first_name} ${user.last_name} (${user.id} - ${user.email}) attempted to access Tupaia from a new country: ${name}`,
);
};
Loading

0 comments on commit 90f95de

Please sign in to comment.