diff --git a/package.json b/package.json index 7cc685d..5af636c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ue-auth", "altName": "UE-Auth", - "version": "1.35.4", + "version": "1.36.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 034385d..26f7cec 100644 --- a/src/api/accounts/account.js +++ b/src/api/accounts/account.js @@ -16,7 +16,7 @@ const cryptoRandomString = require('crypto-random-string'); const config = require('../../config'); export default { - async importAccounts(authGroup, global, array, creator, customDomain) { + async importAccounts(authGroup, global, array, creator) { let failed = []; let success = []; let ok = 0; @@ -46,9 +46,53 @@ export default { // todo - event based bulk notification system return { warning: 'Auto verify does not work with bulk imports. You will need to send password reset notifications or direct your users to the self-service password reset page.', attempted, ok, failed, success }; }, - async writeAccount(data, creator = undefined) { + async passwordPolicy(ag, policy, password) { + const p = policy; + // example of a custom regex if you want to test it out + // custom = '(?=.{10,})(?=.*?[^\\w\\s])(?=.*?[0-9])(?=.*?[A-Z]).*?[a-z].*' + if(p.enabled) { + let policy; + let custom = false; + const standard = (pP) => { + const pVal = `(?=.{${pP.characters},})${(pP.special) ? '(?=.*?[^\\w\\s])' : ''}${(pP.number ? '(?=.*?[0-9])' : '')}${(pP.caps ? '(?=.*?[A-Z])' : '' )}.*?[a-z].*`; + return new RegExp(pVal); + }; + + try { + if(p.pattern.custom) { + try { + policy = new RegExp(p.pattern.custom); + console.info('this worked'); + custom = true; + } catch(e) { + const message = `Custom Password Policy did not compile - ${p.pattern.custom}. Defaulted to standard.`; + if(ag) ueEvents.emit(ag, 'ue.account.error', message); + else console.error(); + } + } + if(!policy) { + policy = standard(p.pattern); + } + } catch (error) { + const message = `Unexpected error with password policy validation - ${error.message}`; + if(ag) ueEvents.emit(ag, 'ue.account.error', message); + else console.error(message); + throw Boom.expectationFailed('Password validation is enabled but there was an unexpected error. Contact the admin and try again later.'); + } + + if(!policy.test(password)) { + const message = (custom) ? 'Password must follow the policy. Contact your administrator' : + `Password must follow the policy: At least ${p.pattern.characters} characters${(p.pattern.caps) ? ', at least one capital' : ''}${(p.pattern.number) ? ', at least one number' : ''}${(p.pattern.special) ? ', at least one special character' : ''}.`; + throw Boom.badRequest(message); + } + } + }, + async writeAccount(data, policyPattern, creator = undefined) { data.email = data.email.toLowerCase(); if(!data.username) data.username = data.email; + if(data.password) { + await this.passwordPolicy(data.authGroup, policyPattern, data.password); + } const output = await dal.writeAccount(data); ueEvents.emit(data.authGroup, 'ue.account.create', output); if(data.profile) { @@ -119,6 +163,7 @@ export default { const patched = jsonPatch.apply_patch(account.toObject(), update); if(patched.password !== account.password) { password = true; + await this.passwordPolicy(authGroup.id, authGroup.config.passwordPolicy, patched.password); } if(patched.active === false) { if (authGroup.owner === id) throw Boom.badRequest('You can not deactivate the owner of the auth group'); @@ -134,13 +179,14 @@ export default { return (account?.mfa?.enabled === true); }, - async updatePassword(authGroupId, id, password, modifiedBy) { + async updatePassword(authGroup, id, password, modifiedBy) { const update = { modifiedBy, password }; - const output = await dal.updatePassword(authGroupId, id, update); - ueEvents.emit(authGroupId, 'ue.account.edit', output); + await this.passwordPolicy(authGroup.id, authGroup.config.passwordPolicy, password); + const output = await dal.updatePassword(authGroup.id, id, update); + ueEvents.emit(authGroup.id, 'ue.account.edit', output); return output; }, @@ -227,14 +273,14 @@ export default { apiMethod: 'PATCH', apiBody: [ { - "op": "replace", - "path": "/password", - "value": 'NEW-PASSWORD-HERE' + 'op': 'replace', + 'path': '/password', + 'value': 'NEW-PASSWORD-HERE' }, { - "op": "replace", - "path": "/verified", - "value": true + 'op': 'replace', + 'path': '/verified', + 'value': true } ] } diff --git a/src/api/accounts/accountOidcInterface.js b/src/api/accounts/accountOidcInterface.js index 66d774d..ae5f8e5 100644 --- a/src/api/accounts/accountOidcInterface.js +++ b/src/api/accounts/accountOidcInterface.js @@ -142,7 +142,7 @@ class Account { } - account = await acct.writeAccount(accData); + account = await acct.writeAccount(accData, {}); } else { let ident = []; ident = account.identities.filter((identity) => { diff --git a/src/api/accounts/api.js b/src/api/accounts/api.js index 059a076..ff787f7 100644 --- a/src/api/accounts/api.js +++ b/src/api/accounts/api.js @@ -37,8 +37,10 @@ const api = { }, async writeAccount(req, res, next) { try { + req.generatePassword = false; if (req.body.generatePassword === true) { req.body.password = cryptoRandomString({length: 16, type: 'url-safe'}); + req.generatePassword = true; delete req.body.generatePassword; } if (req.groupActivationEvent === true) return api.activateGroupWithAccount(req, res, next); @@ -53,7 +55,7 @@ const api = { } // force recovery code creation as a second step if (req.body.recoverCodes) delete req.body.recoverCodes; - const result = await acct.writeAccount(req.body, user); + const result = await acct.writeAccount(req.body, (req.generatePassword) ? {} : req.authGroup.config.passwordPolicy, user); try { if (req.globalSettings.notifications.enabled === true && req.authGroup.pluginOptions.notification.enabled === true && @@ -154,7 +156,7 @@ const api = { const user = await acct.getAccountAccessByEmailOrUsername(req.authGroup.id, req.body.email); let result; let newUser; - let password = cryptoRandomString({length: 32, type: 'url-safe'}); + const password = cryptoRandomString({length: 32, type: 'url-safe'}); if(user) { const checkForOrg = user.access.filter((ac) => { return (ac.organization.id === req.organization.id); @@ -191,7 +193,7 @@ const api = { picture: req.body.profile.picture }; } - newUser = await acct.writeAccount(newData); + newUser = await acct.writeAccount(newData, {}); if(!newUser) throw new Error('Could not create user'); result = await access.defineAccess(req.authGroup, req.organization, newUser._id, {}, req.globalSettings, req.user.sub, 'created', true, req.customDomainUI, req.customDomain); try { @@ -245,7 +247,7 @@ const api = { let client; try { req.body.verified = true; - account = await acct.writeAccount(req.body); + account = await acct.writeAccount(req.body, (req.generatePassword) ? {} : req.authGroup.config.passwordPolicy); if(!account) throw Boom.expectationFailed('Account not created due to unknown error. Try again later'); try { client = await cl.generateClient(req.authGroup); @@ -993,7 +995,7 @@ async function userOperation(req, user, password) { throw error; } case 'generate_password': - result = await acct.updatePassword(req.authGroup.id, req.params.id, password, (req.user) ? req.user.sub : undefined, req.customDomain); + result = await acct.updatePassword(req.authGroup, req.params.id, password, (req.user) ? req.user.sub : undefined, req.customDomain); return say.ok(result, RESOURCE); default: throw Boom.badRequest('Unknown operation'); diff --git a/src/api/authGroup/api.js b/src/api/authGroup/api.js index e942721..b12b5c1 100644 --- a/src/api/authGroup/api.js +++ b/src/api/authGroup/api.js @@ -30,6 +30,16 @@ const api = { if(req.body.setupCode !== config.ONE_TIME_PERSONAL_ROOT_CREATION_KEY) return next(Boom.unauthorized()); if(!config.ROOT_EMAIL) return next(Boom.badData('Root Email Not Configured')); if(!req.body.password) return next(Boom.badData('Need to provide a password for initial account')); + const policy = { + enabled: config.PASSWORD_POLICY.enabled, + pattern: { + characters: config.PASSWORD_POLICY.characters, + special: config.PASSWORD_POLICY.special, + number: config.PASSWORD_POLICY.number, + caps: config.PASSWORD_POLICY.caps + } + }; + await acct.passwordPolicy(undefined, policy, req.body.password); const check = await group.getOneByEither('root'); if(check) return next(Boom.forbidden('root is established, this action is forbidden')); // finished security and data checks, proceeding @@ -50,7 +60,7 @@ const api = { }; g = await group.write(gData); aData.authGroup = g.id; - account = await acct.writeAccount(aData); + account = await acct.writeAccount(aData, {}); client = await cl.generateClient(g); final = JSON.parse(JSON.stringify(await group.activateNewAuthGroup(g, account, client.client_id))); if(final.config) { diff --git a/src/api/authGroup/group.js b/src/api/authGroup/group.js index 77051d6..2f6ee15 100644 --- a/src/api/authGroup/group.js +++ b/src/api/authGroup/group.js @@ -462,6 +462,18 @@ async function standardPatchValidation(original, patched) { throw Boom.badRequest('aliasDnsOIDC cannot be edited form this API'); } + if(patched.config?.passwordPolicy?.enabled) { + if(patched.config?.passwordPolicy?.pattern?.custom) { + if(original.config?.passwordPolicy?.pattern?.custom !== patched.config?.passwordPolicy?.pattern?.custom) { + try { + new RegExp(patched.config.passwordPolicy.pattern.custom); + } catch (error) { + throw Boom.badRequest('A custom regular expression password policy was added but did not compile'); + } + } + } + } + const groupSchema = Joi.object().keys(definition); const main = await groupSchema.validateAsync(patched, { allowUnknown: true diff --git a/src/api/authGroup/model.js b/src/api/authGroup/model.js index a94978c..6b5ee06 100644 --- a/src/api/authGroup/model.js +++ b/src/api/authGroup/model.js @@ -199,6 +199,31 @@ const authGroup = new mongoose.Schema({ config: { keys: Array, cookieKeys: Array, + passwordPolicy: { + enabled: { + type: Boolean, + default: config.PASSWORD_POLICY.enabled + }, + pattern: { + characters: { + type: Number, + default: config.PASSWORD_POLICY.characters + }, + special: { + type: Boolean, + default: config.PASSWORD_POLICY.special + }, + number: { + type: Boolean, + default: config.PASSWORD_POLICY.number + }, + caps: { + type: Boolean, + default: config.PASSWORD_POLICY.caps + }, + custom: String + } + }, requireVerified: { type: Boolean, default: false diff --git a/src/config.js b/src/config.js index b161913..46101b7 100644 --- a/src/config.js +++ b/src/config.js @@ -154,6 +154,13 @@ const config = { process.env.SECURITY_FRAME_ANCESTORS.split(',') : // eslint-disable-next-line quotes (envVars.SECURITY_FRAME_ANCESTORS) ? envVars.SECURITY_FRAME_ANCESTORS.split(',') : [`'self'`] + }, + PASSWORD_POLICY: { + enabled: true, + characters: 6, + special: true, + number: true, + caps: true } }; diff --git a/swagger.clean.yaml b/swagger.clean.yaml index 29bd668..0f49f98 100644 --- a/swagger.clean.yaml +++ b/swagger.clean.yaml @@ -9063,6 +9063,27 @@ components: type: array items: $ref: '#/components/schemas/federatedOAuth2' + passwordPolicy: + type: object + description: this will be enforced for all password creates and updates in your authgroup + properties: + enabled: + type: boolean + pattern: + type: object + properties: + characters: + type: number + example: 10 + special: + type: boolean + number: + type: boolean + caps: + type: boolean + custom: + type: string + description: a regular expression. make sure it will compile ui: type: object properties: diff --git a/swagger.yaml b/swagger.yaml index b39b1f7..de7e649 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -9698,6 +9698,27 @@ components: type: array items: $ref: '#/components/schemas/federatedOAuth2' + passwordPolicy: + type: object + description: this will be enforced for all password creates and updates in your authgroup + properties: + enabled: + type: boolean + pattern: + type: object + properties: + characters: + type: number + example: 10 + special: + type: boolean + number: + type: boolean + caps: + type: boolean + custom: + type: string + description: a regular expression. make sure it will compile ui: type: object properties: diff --git a/test/accounts.test.js b/test/accounts.test.js index 2748e22..844349f 100644 --- a/test/accounts.test.js +++ b/test/accounts.test.js @@ -40,7 +40,7 @@ describe('Accounts', () => { authGroup: AccountMocks.account.authGroup }; const spy = jest.spyOn(dal, 'writeAccount'); - const result = await account.writeAccount(data); + const result = await account.writeAccount(data, {}); expect(spy).toHaveBeenCalledWith(data); expect(Model.prototype.save).toHaveBeenCalled(); expect(result.email).toBe(AccountMocks.account.email); @@ -69,7 +69,7 @@ describe('Accounts', () => { authGroup: AccountMocks.account.authGroup }; const spy = jest.spyOn(dal, 'writeAccount'); - const result = await account.writeAccount(data); + const result = await account.writeAccount(data, {}); expect(spy).toHaveBeenCalledWith({ ...data, username: AccountMocks.account.email }); expect(Model.prototype.save).toHaveBeenCalled(); expect(result.email).toBe(AccountMocks.account.email); @@ -82,6 +82,80 @@ describe('Accounts', () => { } }); + it('Create an account that fails password policy', async () => { + try { + const expected = JSON.parse(JSON.stringify(AccountMocks.account)); + expected.id = expected._id; + delete expected.blocked; + delete expected._id; + delete expected.password; + delete expected.__v; + mockingoose(Model).toReturn(AccountMocks.account, 'save'); + mockingoose(Group).toReturn(GroupMocks.group, 'findOne'); + const data = { + email: AccountMocks.account.email.toUpperCase(), //making sure it comes back lowercase + password: 'testpass', + authGroup: AccountMocks.account.authGroup + }; + const spy = jest.spyOn(dal, 'writeAccount'); + const policy = { + enabled: true, + pattern: { + characters: 10, + special: true, + number: true, + caps: true + } + }; + const result = await account.writeAccount(data, policy); + t.fail('should not get here'); + //expect(spy).toHaveBeenCalledWith({ ...data, username: AccountMocks.account.email }); + //expect(Model.prototype.save).toHaveBeenCalled(); + //expect(result.email).toBe(AccountMocks.account.email); + //expect(result.username).toBe(AccountMocks.account.username); + //expect(result.password).toBe(AccountMocks.account.password); + //const res = JSON.parse(JSON.stringify(result)); + //expect(res).toMatchObject(expected); + } catch (error) { + expect(error.message).toBe('Password must follow the policy: At least 10 characters, at least one capital, at least one number, at least one special character.'); + } + }); + + it('Create an account that passes the password policy', async () => { + try { + const expected = JSON.parse(JSON.stringify(AccountMocks.account)); + expected.id = expected._id; + delete expected.blocked; + delete expected._id; + delete expected.password; + delete expected.__v; + mockingoose(Model).toReturn(AccountMocks.account, 'save'); + mockingoose(Group).toReturn(GroupMocks.group, 'findOne'); + const data = { + email: AccountMocks.account.email.toUpperCase(), //making sure it comes back lowercase + password: 'testpassA1!', + authGroup: AccountMocks.account.authGroup + }; + const spy = jest.spyOn(dal, 'writeAccount'); + const policy = { + enabled: true, + pattern: { + characters: 10, + special: true, + number: true, + caps: true + } + }; + const result = await account.writeAccount(data, policy); + expect(spy).toHaveBeenCalledWith({ ...data, username: AccountMocks.account.email }); + expect(Model.prototype.save).toHaveBeenCalled(); + expect(result.email).toBe(AccountMocks.account.email); + expect(result.username).toBe(AccountMocks.account.username); + } catch (error) { + t.fail(error); + } + }); + it('get one account', async () => { try { const expected = JSON.parse(JSON.stringify(AccountMocks.account)); @@ -273,9 +347,14 @@ describe('Accounts', () => { // random hash - value not important updated.password = '$2a$10$BIFS5/ldwZjtXa3RrW9kK.rRKjeb/jf/7haIwlWXaRp5.J/xAOt7a'; mockingoose(Model).toReturn(updated, 'findOneAndUpdate'); - mockingoose(Group).toReturn(GroupMocks.group, 'findOne'); + mockingoose(Group).toReturn(GroupMocks.group, 'findOne'); + const group = GroupMocks.group; + group.id = group._id; + group.config.passwordPolicy = { + enabled: false + }; const result = await account.updatePassword( - AccountMocks.account.authGroup, + group, AccountMocks.account._id, newPass, 'TEST');