From 2c6e3c9f956281c08e8d5c81eab0a08d4ff8619a Mon Sep 17 00:00:00 2001 From: butlerx Date: Thu, 14 Jul 2016 12:19:17 +0100 Subject: [PATCH] Add brute force protection for login Account gets locked after number of failed logins thats specified in the config, default 3. The lock is reset after the user changes the password or by calling the set_user_lock and changing the value. --- README.md | 14 ++++ default-options.json | 1 + test/lock-unlock.test.js | 148 +++++++++++++++++++++++++++++++++++++++ user.js | 59 ++++++++++++++-- 4 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 test/lock-unlock.test.js diff --git a/README.md b/README.md index ab0cd9e..f29df2e 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ contains an `ok` field that is either true or false, indicating the success or f * `mustrepeat`: you must provide a `repeat` argument (a repeat of the password) when setting a password * `resetperiod`: duration in millis that a password reset token is valid, default: 24 hours * `pepper`: used in addition to password salts, a pepper is very similar but is stored in code instead of a database. + * `lockTry`: number of failed attempts before the account is locked, default 3, set to null to disable this feature To set options, do so when you load the plugin: @@ -430,6 +431,19 @@ Object with properties: * `ok`: true if operation is OK +### role:user, cmd:set_user_lock + +Sets the lock on users account + +#### Argumants: + + * `id`: the id of the user + * `failTry`: boolean to say if the login failed or not + * `why`: why the accouunts lock status is being changed + +#### Provides: + + * `ok`: true if the account was unloocked or false if the lock was incremented ## Logging diff --git a/default-options.json b/default-options.json index b38d403..8558fae 100644 --- a/default-options.json +++ b/default-options.json @@ -7,6 +7,7 @@ "confirm": false, "oldsha": true, "pepper": "", + "lockTry": null, "user": { "fields": [ { diff --git a/test/lock-unlock.test.js b/test/lock-unlock.test.js new file mode 100644 index 0000000..e6fa47b --- /dev/null +++ b/test/lock-unlock.test.js @@ -0,0 +1,148 @@ +'use strict' + +var Seneca = require('seneca') + +var _ = require('lodash') + +var Lab = require('lab') +var Code = require('code') +var lab = exports.lab = Lab.script() +var suite = lab.suite +var test = lab.test +var before = lab.before +var expect = Code.expect +var wrongPassword = 'fail1fail' +var resetPassword = 'reset1reset' + +var si = Seneca() +if (si.version >= '2.0.0'){ + si + .use(require('seneca-entity')) +} +si.use('../user', {lockTry: 3}) + +var user1Data = { + nick: 'nick1', + email: 'nick1@example.com', + password: 'test1test', + active: true, +} + +var user2Data = { + nick: 'nick2', + email: 'nick2@example.com', + password: 'test2test', + active: true, +} + +suite('seneca-user update suite tests ', function () { + before({}, function (done) { + si.ready(function (err) { + if (err) return process.exit(!console.error(err)) + done() + }) + }) + + test('user/register test', function (done) { + si.act(_.extend({role: 'user', cmd: 'register'}, user1Data), function (err, data) { + expect(err).to.not.exist() + expect(data.user.nick).to.equal(user1Data.nick) + done(err) + }) + }) + + test('user/register test', function (done) { + si.act(_.extend({role: 'user', cmd: 'register'}, user2Data), function (err, data) { + expect(err).to.not.exist() + expect(data.user.nick).to.equal(user2Data.nick) + done(err) + }) + }) + + var user2Id + test('user/login user test', function (done) { + si.act({role: 'user', cmd: 'login', nick: user2Data.nick, password: user2Data.password}, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.true() + user2Id = data.user.id + done(err) + }) + }) + + for(var i=0; i<3; i++) { + test('user/login user test', function (done) { + si.act({role: 'user', cmd: 'login', nick: user1Data.nick, password: wrongPassword}, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.false() + done(err) + }) + }) + + test('user/user lock test', function (done) { + si.act({role: 'user', cmd: 'set_user_lock', id: user2Id, failTry: true}, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.false() + done(err) + }) + }) + } + + test('user/login user test', function (done) { + si.act({role: 'user', cmd: 'login', nick: user1Data.nick, password: user1Data.password}, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.false() + done(err) + }) + }) + + test('user/login user test', function (done) { + si.act({role: 'user', cmd: 'login', nick: user2Data.nick, password: user2Data.password}, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.false() + done(err) + }) + }) + + test('user/user unlock test', function (done) { + si.act({role: 'user', cmd: 'set_user_lock', id: user2Id}, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.true() + done(err) + }) + }) + + var resetId + test('user/create_reset unknown user test', function (done) { + si.act({ role: 'user', cmd: 'create_reset', nick: user1Data.nick }, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.true() + expect(data.reset.id).to.exist() + resetId = data.reset.token + done(err) + }) + }) + + test('user/create_reset unknown user test', function (done) { + si.act({ role: 'user', cmd: 'execute_reset', token: resetId, password: resetPassword, repeat: resetPassword }, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.true() + done(err) + }) + }) + + test('user/login user test', function (done) { + si.act({role: 'user', cmd: 'login', nick: user1Data.nick, password: resetPassword}, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.true() + done(err) + }) + }) + + test('user/login user test', function (done) { + si.act({role: 'user', cmd: 'login', nick: user2Data.nick, password: user2Data.password}, function (err, data) { + expect(err).to.not.exist() + expect(data.ok).to.be.true() + done(err) + }) + }) +}) diff --git a/user.js b/user.js index b2a1948..c9466e0 100644 --- a/user.js +++ b/user.js @@ -219,6 +219,12 @@ module.exports = function user (options) { cmd_update) + // ### Set user lock + // Pattern: _**role**:user, **cmd**:set_user_lock_ + seneca.add({role: role, cmd: 'set_user_lock'}, + cmd_set_user_lock) + + // ### Enable User - DEPRECATED // Replaced with **activate** command seneca.add({role: role, cmd: 'enable'},// keep this for backward compatibility @@ -485,6 +491,7 @@ module.exports = function user (options) { return done(null, out) } + user.lockTry = 0 user.salt = out.salt user.pass = out.pass user.save$(function (err, user) { @@ -526,6 +533,7 @@ module.exports = function user (options) { user.name = args.name || '' user.active = void 0 === args.active ? true : args.active user.when = new Date().toISOString() + user.lockTry = 0; if (options.confirm) { user.confirmed = args.confirmed || false @@ -612,7 +620,7 @@ module.exports = function user (options) { // - password: password text, alias: pass // Provides: // - success: {ok:true,user:,login:} - // - failure: {ok:false,why:,nick:} + // - failure: {ok:false,why:,user:} function cmd_login (args, done) { var seneca = this var user = args.user @@ -625,6 +633,10 @@ module.exports = function user (options) { return done(null, {ok: false, why: why, user: user}) } + if (user.lockTry >= options.lockTry && !_.isNull(options.lockTry)) { + seneca.log.debug('login/fail', why = 'locked-out', user) + return done(null, {ok: false, why: why, user: user}) + } if (args.auto) { return make_login(user, 'auto') } @@ -634,7 +646,10 @@ module.exports = function user (options) { if (err) return done(err) if (!out.ok) { seneca.log.debug('login/fail', why = 'invalid-password', user) - return done(null, {ok: false, why: why}) + seneca.act({role: role, cmd: 'set_user_lock', id: user.id, failTry: true, why: why},function (err, out) { + if (err) return done(err) + done(null, {ok: false, why: why, user:user}) + }) } else return make_login(user, 'password') }) @@ -661,13 +676,47 @@ module.exports = function user (options) { login.save$(function (err, login) { if (err) return done(err) - - seneca.log.debug('login/ok', why, user, login) - done(null, {ok: true, user: user, login: login, why: why}) + seneca.act({role: role, cmd: 'set_user_lock', id: user.id}, function (err, out) { + if (err) return done(err) + seneca.log.debug('login/ok', why, user, login) + done(null, {ok: true, user: user, login: login, why: why}) + }) }) } } + // Unlocks accounts that a user has been locked out of due to failled password attempts + // Or let the lock if the user failed to login to their account + // - id, user id to unlock + // - failTry, boolean to say login failed + // - why, why account lock was changed + // Provides: + // - success: {ok:true, why:} + // - failure: {ok:false, why:} + function cmd_set_user_lock (args, done) { + var seneca = this + var why + var userent = seneca.make(user_canon) + var ok + + userent.load$({id: args.id}, function (err, user) { + if (err) return done(err, {ok: false, why: err}) + if(!_.isUndefined(args.failTry) && args.failTry) { + user.lockTry = user.lockTry + 1 + ok = false + why = args.why + } else { + user.lockTry = 0 + ok = true + why = 'account-unlocked' + } + user.save$(function (err, user) { + if (err) return done(err, {ok: false, why: err}) + seneca.log.debug('user/lock', why, user) + done(null, {ok: ok, why: why}) + }) + }) + } // Confirm an existing user - using confirm code sent to user // - nick, email: to resolve user