From 8dafbaf31ff15530ab2044600e7915db39737004 Mon Sep 17 00:00:00 2001 From: Bo Motlagh Date: Wed, 25 Oct 2023 17:59:17 -0400 Subject: [PATCH] resolves #370 - includes an update to reset timeout via user operation or password reset --- package.json | 2 +- src/api/accounts/account.js | 23 +++++++++++++ src/api/accounts/accountOidcInterface.js | 32 ++++++++++++++++-- src/api/accounts/api.js | 4 +++ src/api/accounts/dal.js | 29 ++++++++++++++++ src/api/accounts/models/loginAttempts.js | 24 +++++++++++++ src/api/accounts/models/loginTimeout.js | 43 ++++++++++++++++++++++++ src/api/authGroup/model.js | 14 ++++++++ src/api/oidc/interactions/api.js | 2 +- swagger.clean.yaml | 22 +++++++++++- swagger.yaml | 22 +++++++++++- views/login/primary.pug | 2 +- 12 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 src/api/accounts/models/loginAttempts.js create mode 100644 src/api/accounts/models/loginTimeout.js diff --git a/package.json b/package.json index 9befbaa..9209d59 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ue-auth", "altName": "UE-Auth", - "version": "1.39.3", + "version": "1.40.0", "description": "UE Auth is a multi-tenant OIDC Provider, User Management, B2B Product Access, and Roles/Permissions Management system intended to create a single hybrid solution to serve as Identity and Access for both self-registered B2C Apps and Enterprise B2B Solutions", "private": false, "license": "SEE LICENSE IN ./LICENSE.md", diff --git a/src/api/accounts/account.js b/src/api/accounts/account.js index 7dad598..7d77cd1 100644 --- a/src/api/accounts/account.js +++ b/src/api/accounts/account.js @@ -11,6 +11,7 @@ import ueEvents from '../../events/ueEvents'; import Joi from 'joi'; import plugins from '../plugins/plugins'; import crypto from 'crypto'; + const cryptoRandomString = require('crypto-random-string'); const config = require('../../config'); @@ -217,6 +218,8 @@ export default { ueEvents.emit(authGroup.id, 'ue.account.error', msg); } } + // if there were timeouts, clear them. If there are errors with this, it should get exposed to the user. + await this.cleanupTimout(authGroup.id, id); ueEvents.emit(authGroup.id, 'ue.account.edit', output); return output; }, @@ -474,6 +477,26 @@ export default { message: 'Someone has requested a list of Company portals to which you are the owner. If you did not make this request, you may ignore the message. Click the button below to see a list of all Company logins you own. The button will be active for 2 hours.' }; return n.notify(globalSettings, data, authGroup); + }, + async getTimeout(authGroupId, accountId) { + return dal.getTimeout(authGroupId, accountId); + }, + async createTimeout(authGroup, accountId) { + const duration = authGroup.config?.failedLoginThresholds?.duration || 2; //backup duration of 2 hours + if(duration > 23) throw 'Expires duration must be less than 23'; + const today = new Date(); + today.setHours(today.getHours() + duration); + return dal.createTimeout(authGroup.id, accountId, today) + }, + async recordAttempt(authGroupId, accountId) { + return dal.recordAttempt(authGroupId, accountId); + }, + async getAttempts(authGroupId, accountId) { + return dal.getAttempts(authGroupId, accountId); + }, + async cleanupTimout(authGroupId, accountId) { + await dal.clearTimeout(authGroupId, accountId); + return dal.clearAttempts(authGroupId, accountId); } }; diff --git a/src/api/accounts/accountOidcInterface.js b/src/api/accounts/accountOidcInterface.js index ae5f8e5..fade81a 100644 --- a/src/api/accounts/accountOidcInterface.js +++ b/src/api/accounts/accountOidcInterface.js @@ -69,6 +69,7 @@ class Account { // This can be anything you need to authenticate a user - in OP docs this is called findByLogin static async authenticate(authGroup, email, password) { try { + const thresholds = authGroup.config?.failedLoginThresholds?.enabled; const account = await acct.getAccountByEmailOrUsername(authGroup.id, email); if(account.active !== true || account.blocked === true || account.userLocked === true) throw undefined; if(authGroup && authGroup.config && authGroup.config.requireVerified === true) { @@ -76,14 +77,39 @@ class Account { throw undefined; } } - + if(thresholds === true) { + // Account has a timeout because they attempted to login too many times with the wrong password + const checkTimeout = await acct.getTimeout(authGroup.id, account.id); + if(checkTimeout) throw undefined; + } if(await account.verifyPassword(password)) { return { accountId: account.id, mfaEnabled: account.mfa.enabled }; + } else { + if(thresholds === true) { + try { + await acct.recordAttempt(authGroup.id, account.id); + } catch (error) { + console.error('PROBLEM RECORDING LOGIN TIMEOUT', error); + } + const failedAttempts = await acct.getAttempts(authGroup.id, account.id); + if(failedAttempts >= authGroup.config?.failedLoginThresholds?.threshold) { + try { + await acct.createTimeout(authGroup, account.id); + } catch (error) { + console.error('PROBLEM CREATING A TIMEOUT', error); + } + const t = authGroup.config?.failedLoginThresholds?.threshold || 5; + const d = authGroup.config?.failedLoginThresholds?.duration || 2; + const e = authGroup.primaryEmail; + const message = `Your account is being temporarily locked because you entered the wrong password ${t} times within 1 hour. It will remain locked for ${d} hours.\nYou can reset your password to resolve this immediately by clicking through with your email and selecting 'Forgot your password'.\nIf you need help, contact your admin at ${e}.` + throw message; + } + } } throw undefined; - } catch (err) { - return undefined; + } catch (error) { + return (typeof error === 'string') ? { error } : undefined; } } diff --git a/src/api/accounts/api.js b/src/api/accounts/api.js index ff787f7..c0cc863 100644 --- a/src/api/accounts/api.js +++ b/src/api/accounts/api.js @@ -997,6 +997,10 @@ async function userOperation(req, user, password) { case 'generate_password': result = await acct.updatePassword(req.authGroup, req.params.id, password, (req.user) ? req.user.sub : undefined, req.customDomain); return say.ok(result, RESOURCE); + case 'clear_timeouts': + if(req.permissions.enforceOwn === true) throw Boom.forbidden(); + await acct.cleanupTimout(req.authGroup.id, req.params.id); + return say.noContent(RESOURCE); default: throw Boom.badRequest('Unknown operation'); } diff --git a/src/api/accounts/dal.js b/src/api/accounts/dal.js index fb4beb3..6bf2cf9 100644 --- a/src/api/accounts/dal.js +++ b/src/api/accounts/dal.js @@ -1,5 +1,7 @@ import Account from './models/accounts'; import History from './models/pHistory'; +import Timeout from './models/loginTimeout'; +import Attempt from './models/loginAttempts'; import bcrypt from 'bcryptjs'; import Organization from '../orgs/model'; import Domain from '../domains/model'; @@ -8,6 +10,33 @@ import Group from '../authGroup/model'; const config = require('../../config'); export default { + async recordAttempt(authGroup, accountId) { + const attempt = new Attempt({ + authGroup, + accountId + }) + return attempt.save(); + }, + async getAttempts(authGroup, accountId) { + return Attempt.find({ authGroup, accountId }).countDocuments(); + }, + async clearAttempts(authGroup, accountId) { + return Attempt.deleteMany({ authGroup, accountId }); + }, + async getTimeout(authGroup, accountId) { + return Timeout.findOne({ authGroup, accountId }); + }, + async createTimeout(authGroup, accountId, expiresAt) { + const timeout = new Timeout({ + authGroup, + accountId, + expiresAt + }); + return timeout.save(); + }, + async clearTimeout(authGroup, accountId) { + return Timeout.findOneAndRemove({ authGroup, accountId }); + }, async getActiveAccountCount(authGroup) { return Account.find({ authGroup, active: true, blocked: { $ne: true } }).countDocuments(); }, diff --git a/src/api/accounts/models/loginAttempts.js b/src/api/accounts/models/loginAttempts.js new file mode 100644 index 0000000..5a144c8 --- /dev/null +++ b/src/api/accounts/models/loginAttempts.js @@ -0,0 +1,24 @@ +import mongoose from 'mongoose'; +import { v4 as uuid } from 'uuid'; + +const loginAttempt = new mongoose.Schema({ + createdAt: { + type: Date, + default: Date.now, + expires: '1h' + }, + authGroup: { + type: String, + required: true + }, + accountId: { + type: String, + required: true + }, + _id: { + type: String, + default: uuid + } +},{ _id: false }); + +export default mongoose.model('login-attempt', loginAttempt); \ No newline at end of file diff --git a/src/api/accounts/models/loginTimeout.js b/src/api/accounts/models/loginTimeout.js new file mode 100644 index 0000000..60ec2b2 --- /dev/null +++ b/src/api/accounts/models/loginTimeout.js @@ -0,0 +1,43 @@ +import mongoose from 'mongoose'; +import { v4 as uuid } from 'uuid'; + +const loginTimeout = new mongoose.Schema({ + createdAt: { + type: Date, + default: Date.now + }, + expiresAt: { + type: Date, + required: true, + expires: 0 + }, + authGroup: { + type: String, + required: true + }, + accountId: { + type: String, + required: true + }, + _id: { + type: String, + default: uuid + } +},{ _id: false }); + +loginTimeout.index({ accountId: 1, authGroup: 1}, { unique: true }); + +loginTimeout.virtual('id').get(function(){ + return this._id.toString(); +}); + +loginTimeout.set('toJSON', { + virtuals: true +}); + +loginTimeout.options.toJSON.transform = function (doc, ret, options) { + ret.id = ret._id; + delete ret._id; +}; + +export default mongoose.model('login-timout', loginTimeout); \ No newline at end of file diff --git a/src/api/authGroup/model.js b/src/api/authGroup/model.js index 26c4c2f..36ff92f 100644 --- a/src/api/authGroup/model.js +++ b/src/api/authGroup/model.js @@ -203,6 +203,20 @@ const authGroup = new mongoose.Schema({ type: Boolean, default: false }, + failedLoginThresholds: { + enabled: { + type: Boolean, + default: false + }, + threshold: { + type: Number, + default: 5 + }, + duration: { + type: Number, + default: 2 + } + }, passwordPolicy: { enabled: { type: Boolean, diff --git a/src/api/oidc/interactions/api.js b/src/api/oidc/interactions/api.js index 40ba9d1..1796f15 100644 --- a/src/api/oidc/interactions/api.js +++ b/src/api/oidc/interactions/api.js @@ -399,7 +399,7 @@ const api = { { ...params, login_hint: req.body.email - }, 'Invalid email or password.')); + }, account?.error || 'Invalid email or password.')); } const result = { diff --git a/swagger.clean.yaml b/swagger.clean.yaml index 76f7386..d889f26 100644 --- a/swagger.clean.yaml +++ b/swagger.clean.yaml @@ -8254,6 +8254,7 @@ components: - verify_account - password_reset - generate_password + - clear_timeouts initialAccessTokenRequest: properties: @@ -9067,6 +9068,22 @@ components: type: array items: $ref: '#/components/schemas/federatedOAuth2' + restrictPasswordRepeats: + type: boolean + default: false + failedLoginThresholds: + type: object + properties: + enabled: + type: boolean + default: false + threshold: + type: number + default: 5 + duration: + type: number + description: how many hours should the account be temporarily locked? + default: 2 passwordPolicy: type: object description: this will be enforced for all password creates and updates in your authgroup @@ -9078,13 +9095,16 @@ components: properties: characters: type: number - example: 10 + example: 6 special: type: boolean + example: true number: type: boolean + example: false caps: type: boolean + example: false custom: type: string description: a regular expression. make sure it will compile diff --git a/swagger.yaml b/swagger.yaml index 9d16121..002c175 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -8888,6 +8888,7 @@ components: - verify_account - password_reset - generate_password + - clear_timeouts initialAccessTokenRequest: type: object @@ -9702,6 +9703,22 @@ components: type: array items: $ref: '#/components/schemas/federatedOAuth2' + restrictPasswordRepeats: + type: boolean + default: false + failedLoginThresholds: + type: object + properties: + enabled: + type: boolean + default: false + threshold: + type: number + default: 5 + duration: + type: number + description: how many hours should the account be temporarily locked? + default: 2 passwordPolicy: type: object description: this will be enforced for all password creates and updates in your authgroup @@ -9713,13 +9730,16 @@ components: properties: characters: type: number - example: 10 + example: 6 special: type: boolean + example: true number: type: boolean + example: false caps: type: boolean + example: false custom: type: string description: a regular expression. make sure it will compile diff --git a/views/login/primary.pug b/views/login/primary.pug index a7a88df..4bd450c 100644 --- a/views/login/primary.pug +++ b/views/login/primary.pug @@ -1,7 +1,7 @@ block primary if (flash) .flash-container.m-b-20 - p#flash=flash + pre#flash=flash if (client && client.logoUri) img.logo(src=client.logoUri alt='Product Logo') else if (authGroupLogo)