Skip to content

Commit 25186e7

Browse files
committed
feat: advanced password validation using 'zxcvbn'
* skipFieldNames parameter * additional tests
1 parent 5e7d80b commit 25186e7

File tree

7 files changed

+216
-33
lines changed

7 files changed

+216
-33
lines changed

doc/password_validator.md

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,92 @@ Validator dependencies:
88
Validator configuration stored under `service.config.passwordValidator` key:
99

1010
### minStrength _int_
11-
Sets minimal password complexity to accept a given password
11+
Sets minimal password complexity to accept a given password.
1212

13-
### skipFieldName _string_
14-
Objects field name containing a boolean value. Allows skipping field validation if value === true Eg:
13+
### skipCheckFieldNames _string[]_
14+
Allows skipping field validation:
15+
16+
- When passed field value is `boolean`:
17+
* Validation skipped if `Object[field]` === `true`.
18+
* Validation performed if `Object[field]` === `false`.
19+
- When passed field value is `any` type except `boolean`:
20+
* Validation skipped if `Object[field]` set.
21+
* Validation performed if `Object[field]` not exists.
22+
23+
Eg:
1524
```js
25+
const validatorConfig = {
26+
minStrength: 4,
27+
enabled: true,
28+
skipCheckFieldNames: ['skipPassword'],
29+
};
30+
31+
// Skip validation
32+
const dataSkipOnBool = {
33+
myfield: 'fooBar', // schema "myfield":{"password": true} keyword
34+
skipPassword: true,
35+
};
36+
37+
// Skip validation `skipPassword` is string
38+
const dataSkipOnString = {
39+
myfield: 'fooBar',
40+
skipPassword: 'fooStringValue',
41+
};
42+
43+
// Validation performed
44+
const dataSkipOnBoolFalse = {
45+
myfield: 'fooBar',
46+
skipPassword: false,
47+
};
1648

1749
const data = {
18-
myfield: 'fooBar', // in schema "myfield":{"password": true} keyword
19-
validate: false,
50+
myfield: 'fooBar',
51+
skipPassword: true,
2052
};
2153

54+
```
55+
56+
### forceCheckFieldNames __string[]__
57+
Allows to force Validation event if the Validator disabled:
58+
- When any of passed fields value is `boolean`:
59+
* Validation performed if `Object[field]` === `true`.
60+
* Validation skipped if `Object[field]` === `false`.
61+
- When any of passed fields value is `any` type except `boolean`:
62+
* Validation performed if `Object[field]` set.
63+
* Validation skipped if `Object[field]` not exists.
64+
65+
```js
2266
const validatorConfig = {
2367
minStrength: 4,
24-
skipFieldName: 'validate', // if `data.validate` eq true 'data.myfield' is skipped during validation
68+
enabled:false, // Will validate the `data` object anyway
69+
forceCheckFieldNames: ['forceValidate'], // if `data.validate` not exists 'data.myfield' is skipped during validation
2570
}
71+
72+
// Force validation
73+
const data = {
74+
myfield: 'fooBar', // in schema "myfield":{"password": true} keyword.
75+
forceValidate: true,
76+
};
77+
78+
// Force validation, `forceValidate` is a string.
79+
const dataSkipOnString = {
80+
myfield: 'fooBar',
81+
forceValidate: 'fooStringValue',
82+
};
83+
84+
// Validation NOT performed.
85+
const dataSkipOnBoolFalse = {
86+
myfield: 'fooBar',
87+
forceValidate: false,
88+
};
89+
90+
// Validation NOT performed.
91+
const data = {
92+
myfield: 'fooBar',
93+
};
2694
```
27-
### inputFieldNames _array_
95+
96+
### inputFieldNames _string[]_
2897
Allows using additional fields from the parent object. These values used inside `zxcvbn` to avoid sensitive information inside passwords.
2998
```js
3099
const data = {
@@ -33,7 +102,7 @@ const data = {
33102
altname: 'barname',
34103
};
35104

36-
// if 'username' and 'password' or other data will match, complexity level dropped
105+
// if 'username' and 'password' or other data will match, the complexity level dropped.
37106
const validatorConfig = {
38107
minStrength: 4,
39108
inputFieldNames: [

rfcs/password_validation_limits.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ A possible solution is to use existing password-strength checking solution like
1919
Enabling password validator is Breaking change and disabled by default.
2020
In the nearest future, when everything will be ready, a validator must be enabled.
2121

22+
## OTP and Empty Password
23+
The `password` field is not defined as `required` in current `schemas`.
24+
Validation does not happen because This field does not exist in the OTP registration request.
25+
When the Service performs password generation, `skipPassword` property does not exist in the registration request, so validation not executing on generated passwords.
26+
27+
**NOTE:** Added additional test suites just to be sure.
28+
2229
## Validator Keyword
2330
Service validation algorithm based on the AJV validator and all validation requirements are inside schemas. All incoming requests checked. We can add additional custom validator `password` keyword for the `password` field, which performs required checks.
2431

@@ -56,15 +63,20 @@ When executed:
5663
- calls `zxcvbn` to obtain a complexity of passwords and returns whether the given password matches policy
5764

5865
Also, we can pass user-provided data into `zxcvbn` to check whether some sensitive data used in the password.
66+
5967
### Sample config
60-
`forceCheckFieldName` and `inputFieldNames` values point to parent object field
68+
`skipCheckFieldName` - Allows skipping password check if any field exists.
69+
`forceCheckFieldName` - Allows forcing password check if any field exists.
70+
`inputFieldNames` - values point to parent object fields that passed into `zxcvbn`.
71+
6172
`enabled` - disable/enable validator. Default value is false.
6273
```js
6374
const config = {
6475
enabled: false,
6576
minStrength: 3, // Desired strength
66-
forceCheckFieldName: ['checkPassword'], // force enable password to check if the object field value set..
67-
inputFieldNames: [ // Linked fields list, allow filter case sensitive data in the password from the parent object.
77+
skipCheckFieldNames: ['skipPassword'], // Force disable password to check if the object field value exists.
78+
forceCheckFieldNames: ['checkPassword'], // Force enable password check if the object field value exists.
79+
inputFieldNames: [ // Linked fields list, allow filter the sensitive data in the password from the parent object.
6880
'username',
6981
'otherfield'
7082
]

schemas/config.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,15 @@
295295
"minimum": 0,
296296
"maximum": 4
297297
},
298-
"forceCheckFieldName": {
298+
"forceCheckFieldNames": {
299+
"type": "array",
300+
"items": {
301+
"type": "string",
302+
"minLength": 1
303+
},
304+
"default": []
305+
},
306+
"skipCheckFieldNames": {
299307
"type": "array",
300308
"items": {
301309
"type": "string",

src/configs/core.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ exports.logger = {
7676
exports.passwordValidator = {
7777
enabled: false,
7878
minStrength: 4, // 0..4
79-
forceCheckFieldName: ['checkPassword'],
79+
forceCheckFieldNames: ['checkPassword'],
80+
skipCheckFieldNames: ['skipPassword'],
8081
inputFieldNames: [
8182
'username',
8283
],

src/utils/passwordValidator.js

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,25 +29,37 @@ function validatorError(result, dataPath) {
2929
return { keyword: regKeyword, dataPath, message };
3030
}
3131

32+
/**
33+
* Checks whether any of the passed fields exist in the passed object
34+
* @param object
35+
* @param fields[string] list of fields
36+
* @returns {boolean|*}
37+
*/
38+
function anyFieldExists(object, fields) {
39+
if (Array.isArray(fields) && fields.length > 0) {
40+
return fields.reduce((prev, fieldName) => {
41+
if (prev) return prev;
42+
if (object[fieldName]) return true;
43+
return false;
44+
}, false);
45+
}
46+
return false;
47+
}
48+
3249
/**
3350
* Returns func for AJV validator keyword
3451
* @param {validatorConfig} config
3552
*/
3653
function getValidatorFn(config) {
37-
// service may not have its config changed after start
38-
const { forceCheckFieldName, inputFieldNames, minStrength, enabled } = config;
54+
const { forceCheckFieldNames, skipCheckFieldNames, inputFieldNames, minStrength, enabled } = config;
3955

4056
return function validate(schema, data, parentSchema, currentPath, parentObject) {
41-
let forceValidate;
42-
if (Array.isArray(forceCheckFieldName) && forceCheckFieldName.length > 0) {
43-
forceValidate = forceCheckFieldName.reduce((prev, fieldName) => {
44-
if (prev) return prev;
45-
if (parentObject[fieldName]) return true;
46-
return false;
47-
}, false);
48-
}
57+
// Force validation check
58+
const forceValidate = anyFieldExists(parentObject, forceCheckFieldNames);
59+
// Force skip validation check
60+
const skipValidate = anyFieldExists(parentObject, skipCheckFieldNames);
4961

50-
const validatorEnabled = forceValidate || enabled;
62+
const validatorEnabled = (forceValidate || enabled) && !skipValidate;
5163
if (!validatorEnabled) {
5264
return true;
5365
}

test/suites/register-stubbed.js

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ const is = require('is');
44
const sinon = require('sinon').usingPromise(Promise);
55

66
describe('#register stubbed', function suite() {
7-
beforeEach(global.startService.bind(this));
8-
afterEach(global.clearRedis.bind(this));
9-
10-
it('must be able to send activation code by sms', async () => {
7+
/**
8+
* Suite performing same checks but with different configuration contexts
9+
*/
10+
const mustBeAbleToSendActivationCodeBySms = async () => {
1111
const amqpStub = sinon.stub(this.users.amqp, 'publishAndWait');
1212
const opts = {
1313
activate: false,
1414
audience: '*.localhost',
1515
challengeType: 'phone',
16-
password: 'mynicepassword',
16+
password: 'mynicepassword7521',
1717
username: '79215555555',
1818
};
1919

@@ -42,9 +42,9 @@ describe('#register stubbed', function suite() {
4242
} finally {
4343
amqpStub.restore();
4444
}
45-
});
45+
};
4646

47-
it('must be able to send password by sms', async () => {
47+
const mustBeAbleToSendPasswordBySms = async () => {
4848
const amqpStub = sinon.stub(this.users.amqp, 'publishAndWait');
4949
const opts = {
5050
activate: true,
@@ -76,9 +76,9 @@ describe('#register stubbed', function suite() {
7676
} finally {
7777
amqpStub.restore();
7878
}
79-
});
79+
};
8080

81-
it('should be able to register without password', async () => {
81+
const shouldBeAbleToRegisterWithoutPassword = async () => {
8282
const amqpStub = sinon.stub(this.users.amqp, 'publishAndWait');
8383
const opts = {
8484
activate: false,
@@ -105,5 +105,27 @@ describe('#register stubbed', function suite() {
105105

106106
const lastResponse = await this.dispatch('users.getInternalData', { username: '79215555555' });
107107
assert.equal(is.undefined(lastResponse.password), true);
108+
};
109+
110+
describe('#password validator disabled', () => {
111+
beforeEach(global.startService.bind(this));
112+
afterEach(global.clearRedis.bind(this));
113+
114+
it('must be able to send activation code by sms', mustBeAbleToSendActivationCodeBySms);
115+
it('must be able to send password by sms', mustBeAbleToSendPasswordBySms);
116+
it('should be able to register without password', shouldBeAbleToRegisterWithoutPassword);
117+
});
118+
119+
describe('#password validator enabled', () => {
120+
beforeEach(async () => {
121+
await global.startService.call(this, {
122+
passwordValidator: { enabled: true },
123+
});
124+
});
125+
afterEach(global.clearRedis.bind(this));
126+
127+
it('must be able to send activation code by sms', mustBeAbleToSendActivationCodeBySms);
128+
it('must be able to send password by sms', mustBeAbleToSendPasswordBySms);
129+
it('should be able to register without password', shouldBeAbleToRegisterWithoutPassword);
108130
});
109131
});

test/suites/register.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,65 @@ describe('#register', function registerSuite() {
257257
});
258258
});
259259

260+
describe('password validator enabled', function testSuite() {
261+
beforeEach(async function start() {
262+
await global.startService.call(this, {
263+
passwordValidator: { enabled: true },
264+
});
265+
});
266+
267+
afterEach(global.clearRedis);
268+
269+
it('must be able to create user without password validations and return user object and jwt token', function test() {
270+
const opts = {
271+
username: '[email protected]',
272+
password: 'mynicepassword',
273+
audience: 'matic.ninja',
274+
skipPassword: true,
275+
metadata: {
276+
service: 'craft',
277+
},
278+
};
279+
280+
return this.dispatch('users.register', opts)
281+
.then((registered) => {
282+
assert(registered.hasOwnProperty('jwt'));
283+
assert(registered.hasOwnProperty('user'));
284+
assert.ok(registered.user.id);
285+
assert(registered.user.hasOwnProperty('metadata'));
286+
assert(registered.user.metadata.hasOwnProperty('matic.ninja'));
287+
assert(registered.user.metadata.hasOwnProperty('*.localhost'));
288+
assert.equal(registered.user.metadata['*.localhost'].username, opts.username);
289+
assert.ifError(registered.user.password);
290+
assert.ifError(registered.user.audience);
291+
});
292+
});
293+
294+
it('must be able to create user without validations and return user object and jwt token, password is auto-generated', function test() {
295+
const opts = {
296+
username: '[email protected]',
297+
audience: 'matic.ninja',
298+
metadata: {
299+
service: 'craft',
300+
},
301+
};
302+
303+
return this.dispatch('users.register', opts)
304+
.then((registered) => {
305+
assert(registered.hasOwnProperty('jwt'));
306+
assert(registered.hasOwnProperty('user'));
307+
assert.ok(registered.user.id);
308+
assert(registered.user.hasOwnProperty('metadata'));
309+
assert(registered.user.metadata.hasOwnProperty('matic.ninja'));
310+
assert(registered.user.metadata.hasOwnProperty('*.localhost'));
311+
assert.equal(registered.user.metadata['*.localhost'].username, opts.username);
312+
assert.ifError(registered.user.password);
313+
assert.ifError(registered.user.audience);
314+
});
315+
});
316+
});
317+
318+
260319
describe('must check password strength', function suite() {
261320
beforeEach(async function start() {
262321
await global.startService.call(this, {

0 commit comments

Comments
 (0)