From c19ccd5df40f2356870b8b1b746a82091bc3a1d9 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 25 Sep 2020 14:22:13 -0400 Subject: [PATCH] implement account locking on too many attempts and unlocking after time window --- demo/payload.config.js | 6 +++-- payload.d.ts | 2 ++ src/auth/operations/login.js | 8 ++++++- src/collections/init.js | 41 ++++++++++++++++++++++++++++++++- src/utilities/sanitizeConfig.js | 3 +++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/demo/payload.config.js b/demo/payload.config.js index b0ea60fa63..9737d8dc4b 100644 --- a/demo/payload.config.js +++ b/demo/payload.config.js @@ -29,6 +29,8 @@ module.exports = { admin: { user: 'admins', // indexHTML: 'custom-index.html', + maxLoginAttempts: 3, + lockTime: 600 * 1000, // lock time in ms meta: { titleSuffix: '- Payload Demo', // ogImage: '/static/find-image-here.jpg', @@ -115,8 +117,8 @@ module.exports = { }, productionGraphQLPlayground: false, hooks: { - afterError: () => { - console.error('global error config handler'); + afterError: (err) => { + console.error('global error config handler', err); }, }, webpack: (config) => config, diff --git a/payload.d.ts b/payload.d.ts index 1473616b14..ac9920137b 100644 --- a/payload.d.ts +++ b/payload.d.ts @@ -169,6 +169,8 @@ declare module "@payloadcms/payload/types" { export interface PayloadConfig { admin?: { user?: string; + maxLoginAttempts?: number; + lockTime?: number; meta?: { titleSuffix?: string; }, diff --git a/src/auth/operations/login.js b/src/auth/operations/login.js index e12f433e63..5183e1a2bf 100644 --- a/src/auth/operations/login.js +++ b/src/auth/operations/login.js @@ -35,9 +35,15 @@ async function login(args) { if (!userDoc || (args.collection.config.auth.emailVerification && !userDoc._verified)) { throw new AuthenticationError(); } + if (userDoc && userDoc.isLocked) { + throw new AuthenticationError(); + } const authResult = await userDoc.authenticate(password); - if (!authResult.user) { + if (authResult.user) { + await authResult.user.resetLoginAttempts(); + } else { + await userDoc.incLoginAttempts(); throw new AuthenticationError(); } diff --git a/src/collections/init.js b/src/collections/init.js index 31aca0f458..38072d339c 100644 --- a/src/collections/init.js +++ b/src/collections/init.js @@ -20,7 +20,46 @@ function registerCollections() { const schema = buildSchema(formattedCollection, this.config); if (collection.auth) { - schema.plugin(passportLocalMongoose, { usernameField: 'email' }); + schema.plugin(passportLocalMongoose, { + usernameField: 'email', + }); + + // Check if collection is the admin user set in config + if (collection.slug === this.config.admin.user) { + schema.add({ loginAttempts: { type: Number, hide: true, default: 0 } }); + schema.add({ lockUntil: { type: Date, hide: true } }); + + schema.virtual('isLocked').get(() => !!(this.lockUntil && this.lockUntil > Date.now())); + + const { maxLoginAttempts, lockTime } = this.config.admin; + + // eslint-disable-next-line func-names + schema.methods.incLoginAttempts = function (cb) { + // Expired lock, restart count at 1 + if (this.lockUntil && this.lockUntil < Date.now()) { + return this.updateOne({ + $set: { loginAttempts: 1 }, + $unset: { lockUntil: 1 }, + }, cb); + } + + const updates = { $inc: { loginAttempts: 1 } }; + // Lock the account if at max attempts and not already locked + if (this.loginAttempts + 1 >= maxLoginAttempts && !this.isLocked) { + updates.$set = { lockUntil: Date.now() + lockTime }; + } + return this.updateOne(updates, cb); + }; + + // eslint-disable-next-line func-names + schema.methods.resetLoginAttempts = function (cb) { + return this.updateOne({ + $set: { loginAttempts: 0 }, + $unset: { lockUntil: 1 }, + }, cb); + }; + } + schema.path('hash').options.hide = true; schema.path('salt').options.hide = true; if (collection.auth.emailVerification) { diff --git a/src/utilities/sanitizeConfig.js b/src/utilities/sanitizeConfig.js index ad17eda3b5..72955a936f 100644 --- a/src/utilities/sanitizeConfig.js +++ b/src/utilities/sanitizeConfig.js @@ -29,6 +29,9 @@ const sanitizeConfig = (config) => { sanitizedConfig.collections.push(defaultUser); } + sanitizedConfig.maxLoginAttempts = sanitizedConfig.maxLoginAttempts || 3; + sanitizedConfig.lockTime = sanitizedConfig.lockTime || 600000; // 10 minutes + sanitizedConfig.email = config.email || {}; sanitizedConfig.email.fromName = sanitizedConfig.email.fromName || 'Payload'; sanitizedConfig.email.fromAddress = sanitizedConfig.email.fromName || 'hello@payloadcms.com';