diff --git a/api/auth.js b/api/auth.js index 69a1247..a8b3270 100644 --- a/api/auth.js +++ b/api/auth.js @@ -2,6 +2,8 @@ const router = require('express-promise-router')() const parseJSON = require('body-parser').json() const db = require('../db') +const {loginRateLimit} = require('./middleware/rateLimits') + const validate = require('./middleware/validators') const loginValidation = [ @@ -10,14 +12,14 @@ const loginValidation = [ validate.handleApiError ] -router.post('/', parseJSON, loginValidation, async (req, res) => { +router.post('/', parseJSON, loginValidation, loginRateLimit, async (req, res) => { const user = await db.user.login( req.body.email, req.body.password ) if(!user){ - return res.status(422).json({errors: [{ + return res.status(403).json({errors: [{ param: 'email', msg: 'Invalid login' },{ @@ -26,6 +28,8 @@ router.post('/', parseJSON, loginValidation, async (req, res) => { }]}) } + await loginRateLimit.reset(req.body.email); + const cart = req.sessionObj?.cart const session = await db.session.create(req, user) diff --git a/api/middleware/rateLimits.js b/api/middleware/rateLimits.js new file mode 100644 index 0000000..31b8c06 --- /dev/null +++ b/api/middleware/rateLimits.js @@ -0,0 +1,59 @@ +const {RateLimiterPostgres, RateLimiterUnion} = require('rate-limiter-flexible'); +const pg = require('../../db/pg') + +const globalOptions = { + storeClient: pg, + tableName: 'sos.rate_limits', + tableCreated: true +} + +function createRateLimit(maxTimes, duration, prefix) { + return new RateLimiterPostgres({ + ...globalOptions, + points: maxTimes, + inmemoryBlockOnConsumed: maxTimes, + keyPrefix: prefix, + duration + }) +} + +module.exports = {}; + +const loginRateLimits = [ + // Once per ten seconds + createRateLimit(1, 10, 'logon1'), + // Ten tries in five minutes + createRateLimit(10, 5 * 60, 'logon2'), + // One hundred in a day + createRateLimit(100, 24 * 60 * 60, 'logon3') +] + +const loginRateLimitPool = new RateLimiterUnion(...loginRateLimits) + +module.exports.loginRateLimit = async (req, res) => { + try { + await loginRateLimitPool.consume(req.body.email) + return 'next'; + } catch (limits) { + const wait = Math.max(...Object.values(limits).map(limit => limit?.msBeforeNext || 1)); + + res.status(429) + res.set({ + 'Retry-After': Math.ceil(wait / 1000) + }) + + res.json({errors: [{ + param: 'email', + msg: `Too many password attempts, please wait ${Math.ceil(wait / 1000)} seconds` + },{ + param: 'password', + msg: ' ' + }]}) + } +} + +module.exports.loginRateLimit.reset = email => Promise.all( + loginRateLimits.map(rateLimit => + rateLimit.delete(email) + ) +) \ No newline at end of file diff --git a/api/users.js b/api/users.js index 43cf22f..5ab873f 100644 --- a/api/users.js +++ b/api/users.js @@ -113,7 +113,7 @@ router.put('/current/password', parseJSON, changePasswordValidation, ensureUser, ) if(!user){ - return res.status(422).json({errors: [{ + return res.status(403).json({errors: [{ param: 'oldPassword', msg: 'Incorrect password' }]}) diff --git a/components/form/form.js b/components/form/form.js index 1e019bd..48e8dd2 100644 --- a/components/form/form.js +++ b/components/form/form.js @@ -133,7 +133,8 @@ export const FormController = function FormController({children, className, url, const {data: response} = await axios({ method, url, data }) return afterSubmit(response) } catch (err) { - if(!err.response || err.response.status !== 422) throw err; + const status = err?.response?.status; + if(![400, 401, 403, 422, 429].includes(status)) throw err; return errorDispatch({ type: 'set_errors', diff --git a/db/sql/1-tables.sql b/db/sql/1-tables.sql index 9bf1992..17b093d 100644 --- a/db/sql/1-tables.sql +++ b/db/sql/1-tables.sql @@ -291,4 +291,10 @@ create table sos."config" ( config_shipping_from uuid references sos."address" (address_uuid) ); +create table sos."rate_limits" ( + key varchar(255) primary key, + points integer not null default 0, + expire bigint +); + insert into sos."config" default values; diff --git a/package-lock.json b/package-lock.json index b49b738..70826ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5502,6 +5502,11 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, + "rate-limiter-flexible": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.2.1.tgz", + "integrity": "sha512-rxCP6kDDdn0cZmVqVlF06yLU+mG3TuwaHV/fUIw3OQyYhza7pzVBtdMhUmfXbBzMS+O464XP+x33pfTDGRGYVA==" + }, "raw-body": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", diff --git a/package.json b/package.json index 6e6635f..29eb3fb 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "next": "^10.0.3", "next-images": "^1.6.2", "pg": "^8.5.1", + "rate-limiter-flexible": "^2.2.1", "react": "^16.14.0", "react-dom": "^16.14.0", "react-infinite-calendar": "^2.3.1",