Skip to content

Commit

Permalink
resolves #370 - includes an update to reset timeout via user operatio…
Browse files Browse the repository at this point in the history
…n or password reset
  • Loading branch information
Bo Motlagh committed Oct 25, 2023
1 parent 4c299f7 commit 8dafbaf
Show file tree
Hide file tree
Showing 12 changed files with 211 additions and 8 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
23 changes: 23 additions & 0 deletions src/api/accounts/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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);
}
};

Expand Down
32 changes: 29 additions & 3 deletions src/api/accounts/accountOidcInterface.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,47 @@ 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) {
if (account.verified === false) {
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;
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/api/accounts/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
29 changes: 29 additions & 0 deletions src/api/accounts/dal.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
},
Expand Down
24 changes: 24 additions & 0 deletions src/api/accounts/models/loginAttempts.js
Original file line number Diff line number Diff line change
@@ -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);
43 changes: 43 additions & 0 deletions src/api/accounts/models/loginTimeout.js
Original file line number Diff line number Diff line change
@@ -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);
14 changes: 14 additions & 0 deletions src/api/authGroup/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/api/oidc/interactions/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ const api = {
{
...params,
login_hint: req.body.email
}, 'Invalid email or password.'));
}, account?.error || 'Invalid email or password.'));
}

const result = {
Expand Down
22 changes: 21 additions & 1 deletion swagger.clean.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8254,6 +8254,7 @@ components:
- verify_account
- password_reset
- generate_password
- clear_timeouts

initialAccessTokenRequest:
properties:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 21 additions & 1 deletion swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8888,6 +8888,7 @@ components:
- verify_account
- password_reset
- generate_password
- clear_timeouts

initialAccessTokenRequest:
type: object
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion views/login/primary.pug
Original file line number Diff line number Diff line change
@@ -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)
Expand Down

0 comments on commit 8dafbaf

Please sign in to comment.