adapt-authoring-auth-local 1.3.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +1 -0
- package/lib/LocalAuthModule.js +7 -6
- package/lib/PasswordUtils.js +8 -41
- package/lib/utils/compare.js +25 -0
- package/lib/utils/getRandomHex.js +13 -0
- package/lib/utils/validate.js +29 -0
- package/lib/utils.js +3 -0
- package/package.json +2 -2
- package/tests/utils-compare.spec.js +78 -0
- package/tests/utils-getRandomHex.spec.js +36 -0
- package/tests/utils-validate.spec.js +151 -0
package/index.js
CHANGED
package/lib/LocalAuthModule.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import _ from 'lodash'
|
|
2
2
|
import { AbstractAuthModule } from 'adapt-authoring-auth'
|
|
3
3
|
import apidefs from './apidefs.js'
|
|
4
|
+
import { compare, getRandomHex, validate } from './utils.js'
|
|
4
5
|
import { formatDistanceToNowStrict as toNow } from 'date-fns'
|
|
5
6
|
import PasswordUtils from './PasswordUtils.js'
|
|
6
7
|
/**
|
|
@@ -77,7 +78,7 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
77
78
|
let lastFailedLoginAttempt
|
|
78
79
|
let error
|
|
79
80
|
try {
|
|
80
|
-
await
|
|
81
|
+
await compare(req.body.password, user.password)
|
|
81
82
|
} catch (e) {
|
|
82
83
|
if (!user.isPermLocked && !isTempLockTimeout) { // only update failed login data when account isn't locked
|
|
83
84
|
failedLoginAttempts += 1
|
|
@@ -122,8 +123,8 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
122
123
|
|
|
123
124
|
/** @override */
|
|
124
125
|
async register (data) {
|
|
125
|
-
data.password = data.password ?? await
|
|
126
|
-
await
|
|
126
|
+
data.password = data.password ?? await getRandomHex()
|
|
127
|
+
await validate(data.password)
|
|
127
128
|
return super.register({ ...data, password: await PasswordUtils.generate(data.password) })
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -173,7 +174,7 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
173
174
|
if (!Object.prototype.hasOwnProperty.call(updateData, 'password')) {
|
|
174
175
|
return this.users.update(query, updateData, { schemaName: this.userSchema, useDefaults: false, ignoreRequired: true })
|
|
175
176
|
}
|
|
176
|
-
await
|
|
177
|
+
await validate(updateData.password)
|
|
177
178
|
updateData.password = await PasswordUtils.generate(updateData.password)
|
|
178
179
|
// password updates required special process
|
|
179
180
|
const [mailer, mongodb] = await this.app.waitForModule('mailer', 'mongodb')
|
|
@@ -291,7 +292,7 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
291
292
|
email = req.body.email || req.auth.user.email
|
|
292
293
|
// validate the existing password for security
|
|
293
294
|
const [user] = await this.users.find({ email })
|
|
294
|
-
await
|
|
295
|
+
await compare(req.body.oldPassword, user.password)
|
|
295
296
|
} else { // no authenticated, so should expect body data
|
|
296
297
|
const tokenData = await PasswordUtils.validateReset(req.body.token)
|
|
297
298
|
email = tokenData.email
|
|
@@ -320,7 +321,7 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
320
321
|
*/
|
|
321
322
|
async validatePasswordHandler (req, res, next) {
|
|
322
323
|
try {
|
|
323
|
-
await
|
|
324
|
+
await validate(req.body.password)
|
|
324
325
|
res.json({ message: req.translate('app.passwordindicatorstrong') })
|
|
325
326
|
} catch (e) {
|
|
326
327
|
e.data.errors = e.data.errors.map(req.translate).join(', ')
|
package/lib/PasswordUtils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { App } from 'adapt-authoring-core'
|
|
2
2
|
import bcrypt from 'bcryptjs'
|
|
3
|
-
import
|
|
3
|
+
import { compare, getRandomHex, validate } from './utils.js'
|
|
4
4
|
import { promisify } from 'util'
|
|
5
5
|
|
|
6
6
|
/** @ignore */ const passwordResetsCollectionName = 'passwordresets'
|
|
@@ -30,48 +30,17 @@ class PasswordUtils {
|
|
|
30
30
|
* @param {String} plainPassword
|
|
31
31
|
* @param {String} hash
|
|
32
32
|
* @return {Promise}
|
|
33
|
+
* @deprecated Use compare() from 'adapt-authoring-auth-local' instead
|
|
33
34
|
*/
|
|
34
|
-
static async compare (plainPassword, hash) {
|
|
35
|
-
const error = App.instance.errors.INVALID_LOGIN_DETAILS
|
|
36
|
-
if (!plainPassword || !hash) {
|
|
37
|
-
throw error.setData({
|
|
38
|
-
error: App.instance.errors.INVALID_PARAMS.setData({ params: ['plainPassword', 'hash'] })
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
try {
|
|
42
|
-
const isValid = await promisify(bcrypt.compare)(plainPassword, hash)
|
|
43
|
-
if (!isValid) throw new Error()
|
|
44
|
-
} catch (e) {
|
|
45
|
-
throw error.setData({ error: App.instance.errors.INCORRECT_PASSWORD })
|
|
46
|
-
}
|
|
47
|
-
}
|
|
35
|
+
static async compare (plainPassword, hash) { return compare(plainPassword, hash) }
|
|
48
36
|
|
|
49
37
|
/**
|
|
50
38
|
* Validates a password against the stored config settings
|
|
51
39
|
* @param {String} password Password to validate
|
|
52
40
|
* @returns {Promise} Resolves if the password passes the validation
|
|
41
|
+
* @deprecated Use validate() from 'adapt-authoring-auth-local' instead
|
|
53
42
|
*/
|
|
54
|
-
static async validate (password) {
|
|
55
|
-
const authlocal = await App.instance.waitForModule('auth-local')
|
|
56
|
-
if (typeof password !== 'string') {
|
|
57
|
-
throw App.instance.errors.INVALID_PARAMS.setData({ params: ['password'] })
|
|
58
|
-
}
|
|
59
|
-
const blacklisted = authlocal.getConfig('blacklistedPasswordValues')
|
|
60
|
-
const match = (key, re) => !authlocal.getConfig(key) || password.search(re) > -1
|
|
61
|
-
const validationChecks = {
|
|
62
|
-
INVALID_PASSWORD_LENGTH: [password.length >= authlocal.getConfig('minPasswordLength'), { length: authlocal.getConfig('minPasswordLength') }],
|
|
63
|
-
INVALID_PASSWORD_NUMBER: [match('passwordMustHaveNumber', /[0-9]/)],
|
|
64
|
-
INVALID_PASSWORD_UPPERCASE: [match('passwordMustHaveUppercase', /[A-Z]/)],
|
|
65
|
-
INVALID_PASSWORD_LOWERCASE: [match('passwordMustHaveLowercase', /[a-z]/)],
|
|
66
|
-
INVALID_PASSWORD_SPECIAL: [match('passwordMustHaveSpecial', /[#?!@$%^&*-]/)],
|
|
67
|
-
BLACKLISTED_PASSWORD_VALUE: [blacklisted.length === 0 || blacklisted.every(p => !(password.includes(p)))]
|
|
68
|
-
}
|
|
69
|
-
const errors = Object.entries(validationChecks).reduce((m, [code, [isValid, data]]) => {
|
|
70
|
-
if (!isValid) m.push(App.instance.errors[code].setData(data))
|
|
71
|
-
return m
|
|
72
|
-
}, [])
|
|
73
|
-
if (errors.length) throw App.instance.errors.INVALID_PASSWORD.setData({ errors })
|
|
74
|
-
}
|
|
43
|
+
static async validate (password) { return validate(password) }
|
|
75
44
|
|
|
76
45
|
/**
|
|
77
46
|
* Generates a secure hash from a plain-text password
|
|
@@ -119,7 +88,7 @@ class PasswordUtils {
|
|
|
119
88
|
const { token } = await mongodb.insert(passwordResetsCollectionName, {
|
|
120
89
|
email,
|
|
121
90
|
expiresAt: new Date(Date.now() + lifespan).toISOString(),
|
|
122
|
-
token: await
|
|
91
|
+
token: await getRandomHex()
|
|
123
92
|
})
|
|
124
93
|
return token
|
|
125
94
|
}
|
|
@@ -138,11 +107,9 @@ class PasswordUtils {
|
|
|
138
107
|
* Creates a random hex string
|
|
139
108
|
* @param {Number} size Size of string
|
|
140
109
|
* @return {Promise} Resolves with the string value
|
|
110
|
+
* @deprecated Use getRandomHex() from 'adapt-authoring-auth-local' instead
|
|
141
111
|
*/
|
|
142
|
-
static async getRandomHex (size = 32) {
|
|
143
|
-
const buffer = await promisify(crypto.randomBytes)(size)
|
|
144
|
-
return buffer.toString('hex')
|
|
145
|
-
}
|
|
112
|
+
static async getRandomHex (size = 32) { return getRandomHex(size) }
|
|
146
113
|
|
|
147
114
|
/**
|
|
148
115
|
* Validates a password reset token
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { App } from 'adapt-authoring-core'
|
|
2
|
+
import bcrypt from 'bcryptjs'
|
|
3
|
+
import { promisify } from 'util'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compares a plain password to a hash
|
|
7
|
+
* @param {String} plainPassword
|
|
8
|
+
* @param {String} hash
|
|
9
|
+
* @return {Promise}
|
|
10
|
+
* @memberof localauth
|
|
11
|
+
*/
|
|
12
|
+
export async function compare (plainPassword, hash) {
|
|
13
|
+
const error = App.instance.errors.INVALID_LOGIN_DETAILS
|
|
14
|
+
if (!plainPassword || !hash) {
|
|
15
|
+
throw error.setData({
|
|
16
|
+
error: App.instance.errors.INVALID_PARAMS.setData({ params: ['plainPassword', 'hash'] })
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const isValid = await promisify(bcrypt.compare)(plainPassword, hash)
|
|
21
|
+
if (!isValid) throw new Error()
|
|
22
|
+
} catch (e) {
|
|
23
|
+
throw error.setData({ error: App.instance.errors.INCORRECT_PASSWORD })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import { promisify } from 'util'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a random hex string
|
|
6
|
+
* @param {Number} size Size of string
|
|
7
|
+
* @return {Promise<String>} Resolves with the string value
|
|
8
|
+
* @memberof localauth
|
|
9
|
+
*/
|
|
10
|
+
export async function getRandomHex (size = 32) {
|
|
11
|
+
const buffer = await promisify(crypto.randomBytes)(size)
|
|
12
|
+
return buffer.toString('hex')
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { App } from 'adapt-authoring-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates a password against the stored config settings
|
|
5
|
+
* @param {String} password Password to validate
|
|
6
|
+
* @returns {Promise} Resolves if the password passes the validation
|
|
7
|
+
* @memberof localauth
|
|
8
|
+
*/
|
|
9
|
+
export async function validate (password) {
|
|
10
|
+
const authlocal = await App.instance.waitForModule('auth-local')
|
|
11
|
+
if (typeof password !== 'string') {
|
|
12
|
+
throw App.instance.errors.INVALID_PARAMS.setData({ params: ['password'] })
|
|
13
|
+
}
|
|
14
|
+
const blacklisted = authlocal.getConfig('blacklistedPasswordValues')
|
|
15
|
+
const match = (key, re) => !authlocal.getConfig(key) || password.search(re) > -1
|
|
16
|
+
const validationChecks = {
|
|
17
|
+
INVALID_PASSWORD_LENGTH: [password.length >= authlocal.getConfig('minPasswordLength'), { length: authlocal.getConfig('minPasswordLength') }],
|
|
18
|
+
INVALID_PASSWORD_NUMBER: [match('passwordMustHaveNumber', /[0-9]/)],
|
|
19
|
+
INVALID_PASSWORD_UPPERCASE: [match('passwordMustHaveUppercase', /[A-Z]/)],
|
|
20
|
+
INVALID_PASSWORD_LOWERCASE: [match('passwordMustHaveLowercase', /[a-z]/)],
|
|
21
|
+
INVALID_PASSWORD_SPECIAL: [match('passwordMustHaveSpecial', /[#?!@$%^&*-]/)],
|
|
22
|
+
BLACKLISTED_PASSWORD_VALUE: [blacklisted.length === 0 || blacklisted.every(p => !(password.includes(p)))]
|
|
23
|
+
}
|
|
24
|
+
const errors = Object.entries(validationChecks).reduce((m, [code, [isValid, data]]) => {
|
|
25
|
+
if (!isValid) m.push(App.instance.errors[code].setData(data))
|
|
26
|
+
return m
|
|
27
|
+
}, [])
|
|
28
|
+
if (errors.length) throw App.instance.errors.INVALID_PASSWORD.setData({ errors })
|
|
29
|
+
}
|
package/lib/utils.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-auth-local",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Module which implements username/password (local) authentication",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-auth-local",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"repository": "github:adapt-security/adapt-authoring-auth-local",
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"adapt-authoring-auth": "^1.0.7",
|
|
15
|
-
"adapt-authoring-core": "^
|
|
15
|
+
"adapt-authoring-core": "^2.0.0",
|
|
16
16
|
"bcryptjs": "3.0.3",
|
|
17
17
|
"date-fns": "^4.1.0",
|
|
18
18
|
"lodash": "^4.17.21"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, before, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import bcrypt from 'bcryptjs'
|
|
4
|
+
import { promisify } from 'util'
|
|
5
|
+
|
|
6
|
+
const mockErrors = {
|
|
7
|
+
INVALID_LOGIN_DETAILS: { setData: (d) => ({ ...mockErrors.INVALID_LOGIN_DETAILS, data: d, name: 'INVALID_LOGIN_DETAILS' }), name: 'INVALID_LOGIN_DETAILS' },
|
|
8
|
+
INVALID_PARAMS: { setData: (d) => ({ ...mockErrors.INVALID_PARAMS, data: d, name: 'INVALID_PARAMS' }), name: 'INVALID_PARAMS' },
|
|
9
|
+
INCORRECT_PASSWORD: { name: 'INCORRECT_PASSWORD' }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
mock.module('adapt-authoring-core', {
|
|
13
|
+
namedExports: { App: { instance: { errors: mockErrors } } }
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const { compare } = await import('../lib/utils/compare.js')
|
|
17
|
+
|
|
18
|
+
describe('compare()', () => {
|
|
19
|
+
let hash
|
|
20
|
+
|
|
21
|
+
before(async () => {
|
|
22
|
+
const salt = await promisify(bcrypt.genSalt)(10)
|
|
23
|
+
hash = await promisify(bcrypt.hash)('correctpassword', salt)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should resolve without error when password matches hash', async () => {
|
|
27
|
+
await assert.doesNotReject(() => compare('correctpassword', hash))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should throw when password does not match hash', async () => {
|
|
31
|
+
await assert.rejects(
|
|
32
|
+
() => compare('wrongpassword', hash),
|
|
33
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should throw when plainPassword is empty', async () => {
|
|
38
|
+
await assert.rejects(
|
|
39
|
+
() => compare('', hash),
|
|
40
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should throw when hash is empty', async () => {
|
|
45
|
+
await assert.rejects(
|
|
46
|
+
() => compare('password', ''),
|
|
47
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should throw when both arguments are missing', async () => {
|
|
52
|
+
await assert.rejects(
|
|
53
|
+
() => compare(undefined, undefined),
|
|
54
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should set INCORRECT_PASSWORD as nested error on mismatch', async () => {
|
|
59
|
+
await assert.rejects(
|
|
60
|
+
() => compare('wrongpassword', hash),
|
|
61
|
+
(err) => {
|
|
62
|
+
assert.equal(err.data.error.name, 'INCORRECT_PASSWORD')
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should set INVALID_PARAMS as nested error when params missing', async () => {
|
|
69
|
+
await assert.rejects(
|
|
70
|
+
() => compare('', ''),
|
|
71
|
+
(err) => {
|
|
72
|
+
assert.equal(err.data.error.name, 'INVALID_PARAMS')
|
|
73
|
+
assert.deepEqual(err.data.error.data.params, ['plainPassword', 'hash'])
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { getRandomHex } from '../lib/utils/getRandomHex.js'
|
|
4
|
+
|
|
5
|
+
describe('getRandomHex()', () => {
|
|
6
|
+
it('should return a hex string of default length (64 chars for 32 bytes)', async () => {
|
|
7
|
+
const hex = await getRandomHex()
|
|
8
|
+
assert.equal(typeof hex, 'string')
|
|
9
|
+
assert.equal(hex.length, 64)
|
|
10
|
+
assert.ok(/^[0-9a-f]+$/.test(hex))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should return a hex string of specified length', async () => {
|
|
14
|
+
const hex = await getRandomHex(16)
|
|
15
|
+
assert.equal(hex.length, 32)
|
|
16
|
+
assert.ok(/^[0-9a-f]+$/.test(hex))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should return unique values on subsequent calls', async () => {
|
|
20
|
+
const hex1 = await getRandomHex()
|
|
21
|
+
const hex2 = await getRandomHex()
|
|
22
|
+
assert.notEqual(hex1, hex2)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should handle a size of 1', async () => {
|
|
26
|
+
const hex = await getRandomHex(1)
|
|
27
|
+
assert.equal(hex.length, 2)
|
|
28
|
+
assert.ok(/^[0-9a-f]+$/.test(hex))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should handle a large size', async () => {
|
|
32
|
+
const hex = await getRandomHex(256)
|
|
33
|
+
assert.equal(hex.length, 512)
|
|
34
|
+
assert.ok(/^[0-9a-f]+$/.test(hex))
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, beforeEach, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
const mockErrors = {
|
|
5
|
+
INVALID_PARAMS: { setData: (d) => ({ ...mockErrors.INVALID_PARAMS, data: d, name: 'INVALID_PARAMS' }), name: 'INVALID_PARAMS' },
|
|
6
|
+
INVALID_PASSWORD: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD, data: d, name: 'INVALID_PASSWORD' }), name: 'INVALID_PASSWORD' },
|
|
7
|
+
INVALID_PASSWORD_LENGTH: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_LENGTH, data: d, name: 'INVALID_PASSWORD_LENGTH' }), name: 'INVALID_PASSWORD_LENGTH' },
|
|
8
|
+
INVALID_PASSWORD_NUMBER: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_NUMBER, data: d, name: 'INVALID_PASSWORD_NUMBER' }), name: 'INVALID_PASSWORD_NUMBER' },
|
|
9
|
+
INVALID_PASSWORD_UPPERCASE: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_UPPERCASE, data: d, name: 'INVALID_PASSWORD_UPPERCASE' }), name: 'INVALID_PASSWORD_UPPERCASE' },
|
|
10
|
+
INVALID_PASSWORD_LOWERCASE: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_LOWERCASE, data: d, name: 'INVALID_PASSWORD_LOWERCASE' }), name: 'INVALID_PASSWORD_LOWERCASE' },
|
|
11
|
+
INVALID_PASSWORD_SPECIAL: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_SPECIAL, data: d, name: 'INVALID_PASSWORD_SPECIAL' }), name: 'INVALID_PASSWORD_SPECIAL' },
|
|
12
|
+
BLACKLISTED_PASSWORD_VALUE: { setData: (d) => ({ ...mockErrors.BLACKLISTED_PASSWORD_VALUE, data: d, name: 'BLACKLISTED_PASSWORD_VALUE' }), name: 'BLACKLISTED_PASSWORD_VALUE' }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let authlocalConfig = {
|
|
16
|
+
minPasswordLength: 8,
|
|
17
|
+
passwordMustHaveNumber: false,
|
|
18
|
+
passwordMustHaveUppercase: false,
|
|
19
|
+
passwordMustHaveLowercase: false,
|
|
20
|
+
passwordMustHaveSpecial: false,
|
|
21
|
+
blacklistedPasswordValues: []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const mockAuthlocal = {
|
|
25
|
+
getConfig: (key) => authlocalConfig[key]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
mock.module('adapt-authoring-core', {
|
|
29
|
+
namedExports: {
|
|
30
|
+
App: {
|
|
31
|
+
instance: {
|
|
32
|
+
errors: mockErrors,
|
|
33
|
+
waitForModule: async () => mockAuthlocal
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const { validate } = await import('../lib/utils/validate.js')
|
|
40
|
+
|
|
41
|
+
describe('validate()', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
authlocalConfig = {
|
|
44
|
+
minPasswordLength: 8,
|
|
45
|
+
passwordMustHaveNumber: false,
|
|
46
|
+
passwordMustHaveUppercase: false,
|
|
47
|
+
passwordMustHaveLowercase: false,
|
|
48
|
+
passwordMustHaveSpecial: false,
|
|
49
|
+
blacklistedPasswordValues: []
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should resolve for a valid password meeting minimum length', async () => {
|
|
54
|
+
await assert.doesNotReject(() => validate('abcdefgh'))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should throw when password is not a string', async () => {
|
|
58
|
+
await assert.rejects(
|
|
59
|
+
() => validate(12345678),
|
|
60
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should throw when password is null', async () => {
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
() => validate(null),
|
|
67
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should throw when password is too short', async () => {
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
() => validate('short'),
|
|
74
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should include minimum length in error data', async () => {
|
|
79
|
+
await assert.rejects(
|
|
80
|
+
() => validate('short'),
|
|
81
|
+
(err) => {
|
|
82
|
+
assert.equal(err.name, 'INVALID_PASSWORD')
|
|
83
|
+
const lengthErr = err.data.errors.find(e => e.name === 'INVALID_PASSWORD_LENGTH')
|
|
84
|
+
assert.ok(lengthErr)
|
|
85
|
+
assert.equal(lengthErr.data.length, 8)
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should throw when number is required but missing', async () => {
|
|
92
|
+
authlocalConfig.passwordMustHaveNumber = true
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
() => validate('abcdefgh'),
|
|
95
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should accept password with number when required', async () => {
|
|
100
|
+
authlocalConfig.passwordMustHaveNumber = true
|
|
101
|
+
await assert.doesNotReject(() => validate('abcdefg1'))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should throw when uppercase is required but missing', async () => {
|
|
105
|
+
authlocalConfig.passwordMustHaveUppercase = true
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
() => validate('abcdefgh'),
|
|
108
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should accept password with uppercase when required', async () => {
|
|
113
|
+
authlocalConfig.passwordMustHaveUppercase = true
|
|
114
|
+
await assert.doesNotReject(() => validate('Abcdefgh'))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should throw when special character is required but missing', async () => {
|
|
118
|
+
authlocalConfig.passwordMustHaveSpecial = true
|
|
119
|
+
await assert.rejects(
|
|
120
|
+
() => validate('abcdefgh'),
|
|
121
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should collect multiple validation errors', async () => {
|
|
126
|
+
authlocalConfig.passwordMustHaveNumber = true
|
|
127
|
+
authlocalConfig.passwordMustHaveUppercase = true
|
|
128
|
+
authlocalConfig.passwordMustHaveSpecial = true
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
() => validate('abcdefgh'),
|
|
131
|
+
(err) => {
|
|
132
|
+
assert.equal(err.name, 'INVALID_PASSWORD')
|
|
133
|
+
assert.ok(err.data.errors.length >= 3)
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should throw when password contains a blacklisted value', async () => {
|
|
140
|
+
authlocalConfig.blacklistedPasswordValues = ['password']
|
|
141
|
+
await assert.rejects(
|
|
142
|
+
() => validate('password123'),
|
|
143
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should accept password not containing blacklisted values', async () => {
|
|
148
|
+
authlocalConfig.blacklistedPasswordValues = ['password']
|
|
149
|
+
await assert.doesNotReject(() => validate('securevalue'))
|
|
150
|
+
})
|
|
151
|
+
})
|