adapt-authoring-auth-local 1.3.1 → 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 +8 -7
- package/lib/PasswordUtils.js +9 -42
- 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/LocalAuthModule.spec.js +2 -7
- package/tests/PasswordUtils.spec.js +2 -9
- 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
|
|
@@ -105,7 +106,7 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
105
106
|
* @param {Object} user The current user
|
|
106
107
|
*/
|
|
107
108
|
async handleLockStatus (user) {
|
|
108
|
-
const tempLockEndTime = new Date(user.lastFailedLoginAttempt).getTime() + this.getConfig('temporaryLockDuration')
|
|
109
|
+
const tempLockEndTime = new Date(user.lastFailedLoginAttempt).getTime() + this.getConfig('temporaryLockDuration')
|
|
109
110
|
const tempLockRemainingSecs = Math.round((tempLockEndTime - Date.now()) / 1000)
|
|
110
111
|
|
|
111
112
|
if (user.isPermLocked) {
|
|
@@ -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.some(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
|
|
@@ -164,7 +131,7 @@ class PasswordUtils {
|
|
|
164
131
|
const [user] = await users.find({ email: tokenData.email })
|
|
165
132
|
if (!user) {
|
|
166
133
|
throw App.instance.errors.NOT_FOUND
|
|
167
|
-
.setData({ type: 'user', id:
|
|
134
|
+
.setData({ type: 'user', id: tokenData.email })
|
|
168
135
|
}
|
|
169
136
|
return tokenData
|
|
170
137
|
}
|
|
@@ -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"
|
|
@@ -505,10 +505,7 @@ describe('LocalAuthModule', () => {
|
|
|
505
505
|
const user = {
|
|
506
506
|
isPermLocked: false,
|
|
507
507
|
isTempLocked: true,
|
|
508
|
-
// NOTE:
|
|
509
|
-
// appears to be a bug if the config value is already in ms (isTimeMs: true).
|
|
510
|
-
// We set lastFailedLoginAttempt to now so the lock is still active with
|
|
511
|
-
// the doubled value.
|
|
508
|
+
// NOTE: lastFailedLoginAttempt is set to now so the lock is still active.
|
|
512
509
|
lastFailedLoginAttempt: new Date().toISOString()
|
|
513
510
|
}
|
|
514
511
|
await assert.rejects(
|
|
@@ -649,9 +646,7 @@ describe('LocalAuthModule', () => {
|
|
|
649
646
|
assert.ok(updateCalls.length > 0)
|
|
650
647
|
})
|
|
651
648
|
|
|
652
|
-
|
|
653
|
-
// user.failedLoginAttempts when disabling. See BUGS.md.
|
|
654
|
-
it('should preserve failedLoginAttempts when disabling a user', { todo: 'references user.failedAttempts instead of user.failedLoginAttempts' }, async () => {
|
|
649
|
+
it('should preserve failedLoginAttempts when disabling a user', async () => {
|
|
655
650
|
const user = { _id: 'user-1', failedLoginAttempts: 7 }
|
|
656
651
|
await mod.setUserEnabled(user, false)
|
|
657
652
|
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
@@ -374,11 +374,7 @@ describe('PasswordUtils', () => {
|
|
|
374
374
|
await assert.doesNotReject(() => PasswordUtils.validate('anything1'))
|
|
375
375
|
})
|
|
376
376
|
|
|
377
|
-
|
|
378
|
-
// With multiple blacklisted values, a password containing one blacklisted
|
|
379
|
-
// value passes if another blacklisted value is absent.
|
|
380
|
-
// See BUGS.md and PasswordUtils.js line 67.
|
|
381
|
-
it('should throw when password contains any blacklisted value (multiple entries)', { todo: 'blacklist check uses .some() instead of .every()' }, async () => {
|
|
377
|
+
it('should throw when password contains any blacklisted value (multiple entries)', async () => {
|
|
382
378
|
authlocalConfig.blacklistedPasswordValues = ['password', 'qwerty']
|
|
383
379
|
await assert.rejects(
|
|
384
380
|
() => PasswordUtils.validate('password123'),
|
|
@@ -593,10 +589,7 @@ describe('PasswordUtils', () => {
|
|
|
593
589
|
)
|
|
594
590
|
})
|
|
595
591
|
|
|
596
|
-
|
|
597
|
-
// in the NOT_FOUND error data. Since token is a string, token.email is
|
|
598
|
-
// undefined. See PasswordUtils.js line 167.
|
|
599
|
-
it('should include correct email in NOT_FOUND error when user is missing', { todo: 'uses token.email (string) instead of tokenData.email' }, async () => {
|
|
592
|
+
it('should include correct email in NOT_FOUND error when user is missing', async () => {
|
|
600
593
|
mockPasswordResetsStore.push({
|
|
601
594
|
token: 'orphan-token',
|
|
602
595
|
email: 'orphan@example.com',
|
|
@@ -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
|
+
})
|