adapt-authoring-auth-local 1.1.0 → 1.2.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/.github/workflows/tests.yml +13 -0
- package/package.json +4 -1
- package/tests/LocalAuthModule.spec.js +635 -0
- package/tests/PasswordUtils.spec.js +465 -0
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-auth-local",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "index.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --experimental-test-module-mocks --test 'tests/*.spec.js'"
|
|
11
|
+
},
|
|
9
12
|
"repository": "github:adapt-security/adapt-authoring-auth-local",
|
|
10
13
|
"dependencies": {
|
|
11
14
|
"adapt-authoring-auth": "^1.0.7",
|
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import { describe, it, before, beforeEach, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
// --- Stub framework dependencies before importing LocalAuthModule ---
|
|
5
|
+
|
|
6
|
+
const makeError = (name) => {
|
|
7
|
+
const err = { name, setData: (d) => ({ ...err, data: d }) }
|
|
8
|
+
return err
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const mockErrors = {
|
|
12
|
+
INVALID_LOGIN_DETAILS: makeError('INVALID_LOGIN_DETAILS'),
|
|
13
|
+
ACCOUNT_LOCKED_PERM: makeError('ACCOUNT_LOCKED_PERM'),
|
|
14
|
+
ACCOUNT_LOCKED_TEMP: makeError('ACCOUNT_LOCKED_TEMP'),
|
|
15
|
+
INVALID_PARAMS: makeError('INVALID_PARAMS'),
|
|
16
|
+
INVALID_PASSWORD: makeError('INVALID_PASSWORD'),
|
|
17
|
+
INVALID_PASSWORD_LENGTH: makeError('INVALID_PASSWORD_LENGTH'),
|
|
18
|
+
INVALID_PASSWORD_NUMBER: makeError('INVALID_PASSWORD_NUMBER'),
|
|
19
|
+
INVALID_PASSWORD_UPPERCASE: makeError('INVALID_PASSWORD_UPPERCASE'),
|
|
20
|
+
INVALID_PASSWORD_LOWERCASE: makeError('INVALID_PASSWORD_LOWERCASE'),
|
|
21
|
+
INVALID_PASSWORD_SPECIAL: makeError('INVALID_PASSWORD_SPECIAL'),
|
|
22
|
+
BLACKLISTED_PASSWORD_VALUE: makeError('BLACKLISTED_PASSWORD_VALUE'),
|
|
23
|
+
INCORRECT_PASSWORD: makeError('INCORRECT_PASSWORD'),
|
|
24
|
+
NOT_FOUND: makeError('NOT_FOUND'),
|
|
25
|
+
SUPER_USER_EXISTS: makeError('SUPER_USER_EXISTS')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let authlocalConfig = {
|
|
29
|
+
failsUntilTemporaryLock: 5,
|
|
30
|
+
failsUntilPermanentLock: 20,
|
|
31
|
+
temporaryLockDuration: 60000,
|
|
32
|
+
inviteTokenLifespan: 604800000,
|
|
33
|
+
resetTokenLifespan: 86400000,
|
|
34
|
+
saltRounds: 10,
|
|
35
|
+
minPasswordLength: 8,
|
|
36
|
+
passwordMustHaveNumber: false,
|
|
37
|
+
passwordMustHaveUppercase: false,
|
|
38
|
+
passwordMustHaveLowercase: false,
|
|
39
|
+
passwordMustHaveSpecial: false,
|
|
40
|
+
blacklistedPasswordValues: []
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const usersStore = []
|
|
44
|
+
let updateCalls = []
|
|
45
|
+
let disavowCalls = []
|
|
46
|
+
let secureRouteCalls = []
|
|
47
|
+
let unsecureRouteCalls = []
|
|
48
|
+
|
|
49
|
+
const mockUsers = {
|
|
50
|
+
find: async (query) => usersStore.filter(u => {
|
|
51
|
+
return Object.entries(query).every(([k, v]) => JSON.stringify(u[k]) === JSON.stringify(v))
|
|
52
|
+
}),
|
|
53
|
+
update: async (query, data) => {
|
|
54
|
+
updateCalls.push({ query, data })
|
|
55
|
+
return { ...query, ...data }
|
|
56
|
+
},
|
|
57
|
+
collectionName: 'users'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const mockRoles = {
|
|
61
|
+
find: async () => [{ _id: 'role-super-id', shortName: 'superuser' }]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const mockMailer = {
|
|
65
|
+
isEnabled: true,
|
|
66
|
+
send: async () => {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const mockServer = {
|
|
70
|
+
root: { url: 'http://localhost:5000' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const mockMongodb = {
|
|
74
|
+
update: async (collection, query, update) => {
|
|
75
|
+
return { _id: 'user-id-1', email: 'test@example.com', ...update.$set }
|
|
76
|
+
},
|
|
77
|
+
find: async () => [],
|
|
78
|
+
insert: async (collection, data) => data,
|
|
79
|
+
delete: async () => {},
|
|
80
|
+
getCollection: () => ({ deleteMany: async () => {} })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const mockJsonschema = {
|
|
84
|
+
getSchema: async () => ({
|
|
85
|
+
validate: async () => true
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const moduleMap = {
|
|
90
|
+
users: mockUsers,
|
|
91
|
+
roles: mockRoles,
|
|
92
|
+
mailer: mockMailer,
|
|
93
|
+
server: mockServer,
|
|
94
|
+
mongodb: mockMongodb,
|
|
95
|
+
jsonschema: mockJsonschema,
|
|
96
|
+
'auth-local': {
|
|
97
|
+
getConfig: (key) => authlocalConfig[key],
|
|
98
|
+
log: () => {}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mockApp = {
|
|
103
|
+
errors: mockErrors,
|
|
104
|
+
waitForModule: async (...names) => {
|
|
105
|
+
if (names.length === 1) return moduleMap[names[0]]
|
|
106
|
+
return names.map(n => moduleMap[n])
|
|
107
|
+
},
|
|
108
|
+
lang: {
|
|
109
|
+
translate: (_, key) => `translated:${key}`
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Stub adapt-authoring-auth to provide AbstractAuthModule
|
|
114
|
+
mock.module('adapt-authoring-auth', {
|
|
115
|
+
namedExports: {
|
|
116
|
+
AbstractAuthModule: class AbstractAuthModule {
|
|
117
|
+
constructor () {
|
|
118
|
+
this.app = mockApp
|
|
119
|
+
this.router = { routes: [{ route: '/', meta: null }, { route: '/register', meta: null }] }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getConfig (key) { return authlocalConfig[key] }
|
|
123
|
+
|
|
124
|
+
async init () {}
|
|
125
|
+
|
|
126
|
+
async register (data) { return { _id: 'new-user-id', ...data } }
|
|
127
|
+
|
|
128
|
+
async setUserEnabled () {}
|
|
129
|
+
|
|
130
|
+
secureRoute (...args) { secureRouteCalls.push(args) }
|
|
131
|
+
|
|
132
|
+
unsecureRoute (...args) { unsecureRouteCalls.push(args) }
|
|
133
|
+
|
|
134
|
+
disavowUser (...args) { disavowCalls.push(args) }
|
|
135
|
+
|
|
136
|
+
log () {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
mock.module('adapt-authoring-core', {
|
|
142
|
+
namedExports: { App: { instance: mockApp } }
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const { default: LocalAuthModule } = await import('../lib/LocalAuthModule.js')
|
|
146
|
+
|
|
147
|
+
describe('LocalAuthModule', () => {
|
|
148
|
+
let mod
|
|
149
|
+
|
|
150
|
+
before(async () => {
|
|
151
|
+
mod = new LocalAuthModule()
|
|
152
|
+
mod.app = mockApp
|
|
153
|
+
mod.users = mockUsers
|
|
154
|
+
mod.userSchema = 'localauthuser'
|
|
155
|
+
mod.type = 'local'
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
updateCalls = []
|
|
160
|
+
disavowCalls = []
|
|
161
|
+
secureRouteCalls = []
|
|
162
|
+
unsecureRouteCalls = []
|
|
163
|
+
usersStore.length = 0
|
|
164
|
+
authlocalConfig = {
|
|
165
|
+
failsUntilTemporaryLock: 5,
|
|
166
|
+
failsUntilPermanentLock: 20,
|
|
167
|
+
temporaryLockDuration: 60000,
|
|
168
|
+
inviteTokenLifespan: 604800000,
|
|
169
|
+
resetTokenLifespan: 86400000,
|
|
170
|
+
saltRounds: 10,
|
|
171
|
+
minPasswordLength: 8,
|
|
172
|
+
passwordMustHaveNumber: false,
|
|
173
|
+
passwordMustHaveUppercase: false,
|
|
174
|
+
passwordMustHaveLowercase: false,
|
|
175
|
+
passwordMustHaveSpecial: false,
|
|
176
|
+
blacklistedPasswordValues: []
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('.formatRemainingTime()', () => {
|
|
181
|
+
it('should return a human-readable string for remaining seconds', () => {
|
|
182
|
+
const result = LocalAuthModule.formatRemainingTime(60)
|
|
183
|
+
assert.equal(typeof result, 'string')
|
|
184
|
+
assert.ok(result.length > 0)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('should return a string containing "second" for small values', () => {
|
|
188
|
+
const result = LocalAuthModule.formatRemainingTime(5)
|
|
189
|
+
assert.ok(result.includes('second'))
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('should return a string containing "minute" for 60 seconds', () => {
|
|
193
|
+
const result = LocalAuthModule.formatRemainingTime(60)
|
|
194
|
+
assert.ok(result.includes('minute'))
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('#setValues()', () => {
|
|
199
|
+
it('should set userSchema to localauthuser', async () => {
|
|
200
|
+
const instance = new LocalAuthModule()
|
|
201
|
+
await instance.setValues()
|
|
202
|
+
assert.equal(instance.userSchema, 'localauthuser')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should set type to local', async () => {
|
|
206
|
+
const instance = new LocalAuthModule()
|
|
207
|
+
await instance.setValues()
|
|
208
|
+
assert.equal(instance.type, 'local')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should define 5 routes', async () => {
|
|
212
|
+
const instance = new LocalAuthModule()
|
|
213
|
+
await instance.setValues()
|
|
214
|
+
assert.equal(instance.routes.length, 5)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should include the expected route paths', async () => {
|
|
218
|
+
const instance = new LocalAuthModule()
|
|
219
|
+
await instance.setValues()
|
|
220
|
+
const paths = instance.routes.map(r => r.route)
|
|
221
|
+
assert.deepEqual(paths, ['/invite', '/registersuper', '/changepass', '/forgotpass', '/validatepass'])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('should mark registersuper as internal', async () => {
|
|
225
|
+
const instance = new LocalAuthModule()
|
|
226
|
+
await instance.setValues()
|
|
227
|
+
const superRoute = instance.routes.find(r => r.route === '/registersuper')
|
|
228
|
+
assert.equal(superRoute.internal, true)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('#init()', () => {
|
|
233
|
+
it('should secure the invite route', async () => {
|
|
234
|
+
const instance = new LocalAuthModule()
|
|
235
|
+
instance.app = mockApp
|
|
236
|
+
instance.router = { routes: [{ route: '/', meta: null }, { route: '/register', meta: null }] }
|
|
237
|
+
await instance.setValues()
|
|
238
|
+
await instance.init()
|
|
239
|
+
assert.ok(secureRouteCalls.some(c => c[0] === '/invite' && c[1] === 'post'))
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('should secure the validatepass route', async () => {
|
|
243
|
+
const instance = new LocalAuthModule()
|
|
244
|
+
instance.app = mockApp
|
|
245
|
+
instance.router = { routes: [{ route: '/', meta: null }, { route: '/register', meta: null }] }
|
|
246
|
+
await instance.setValues()
|
|
247
|
+
await instance.init()
|
|
248
|
+
assert.ok(secureRouteCalls.some(c => c[0] === '/validatepass' && c[1] === 'post'))
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('should unsecure registersuper, changepass, and forgotpass routes', async () => {
|
|
252
|
+
const instance = new LocalAuthModule()
|
|
253
|
+
instance.app = mockApp
|
|
254
|
+
instance.router = { routes: [{ route: '/', meta: null }, { route: '/register', meta: null }] }
|
|
255
|
+
await instance.setValues()
|
|
256
|
+
await instance.init()
|
|
257
|
+
const unsecuredPaths = unsecureRouteCalls.map(c => c[0])
|
|
258
|
+
assert.ok(unsecuredPaths.includes('/registersuper'))
|
|
259
|
+
assert.ok(unsecuredPaths.includes('/changepass'))
|
|
260
|
+
assert.ok(unsecuredPaths.includes('/forgotpass'))
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('should set the users property', async () => {
|
|
264
|
+
const instance = new LocalAuthModule()
|
|
265
|
+
instance.app = mockApp
|
|
266
|
+
instance.router = { routes: [{ route: '/', meta: null }, { route: '/register', meta: null }] }
|
|
267
|
+
await instance.setValues()
|
|
268
|
+
await instance.init()
|
|
269
|
+
assert.equal(instance.users, mockUsers)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('#authenticate()', () => {
|
|
274
|
+
it('should throw when password is not provided in request body', async () => {
|
|
275
|
+
const req = { body: {} }
|
|
276
|
+
await assert.rejects(
|
|
277
|
+
() => mod.authenticate({}, req, {}),
|
|
278
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should reset failed attempts on successful authentication', async () => {
|
|
283
|
+
const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
|
|
284
|
+
const hash = await PasswordUtils.generate('correctpass')
|
|
285
|
+
const user = {
|
|
286
|
+
_id: 'user-1',
|
|
287
|
+
password: hash,
|
|
288
|
+
isPermLocked: false,
|
|
289
|
+
isTempLocked: false,
|
|
290
|
+
failedLoginAttempts: 2,
|
|
291
|
+
lastFailedLoginAttempt: null
|
|
292
|
+
}
|
|
293
|
+
const req = { body: { password: 'correctpass' } }
|
|
294
|
+
await mod.authenticate(user, req, {})
|
|
295
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
296
|
+
assert.equal(lastUpdate.data.failedLoginAttempts, 0)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should increment failed attempts on wrong password', async () => {
|
|
300
|
+
const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
|
|
301
|
+
const hash = await PasswordUtils.generate('correctpass')
|
|
302
|
+
const user = {
|
|
303
|
+
_id: 'user-1',
|
|
304
|
+
password: hash,
|
|
305
|
+
isPermLocked: false,
|
|
306
|
+
isTempLocked: false,
|
|
307
|
+
failedLoginAttempts: 0,
|
|
308
|
+
lastFailedLoginAttempt: null
|
|
309
|
+
}
|
|
310
|
+
const req = { body: { password: 'wrongpass' } }
|
|
311
|
+
await assert.rejects(() => mod.authenticate(user, req, {}))
|
|
312
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
313
|
+
assert.equal(lastUpdate.data.failedLoginAttempts, 1)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('should temporarily lock after reaching failsUntilTemporaryLock threshold', async () => {
|
|
317
|
+
const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
|
|
318
|
+
const hash = await PasswordUtils.generate('correctpass')
|
|
319
|
+
const user = {
|
|
320
|
+
_id: 'user-1',
|
|
321
|
+
password: hash,
|
|
322
|
+
isPermLocked: false,
|
|
323
|
+
isTempLocked: false,
|
|
324
|
+
failedLoginAttempts: 4, // one more will be 5 (the threshold)
|
|
325
|
+
lastFailedLoginAttempt: null
|
|
326
|
+
}
|
|
327
|
+
const req = { body: { password: 'wrongpass' } }
|
|
328
|
+
await assert.rejects(
|
|
329
|
+
() => mod.authenticate(user, req, {}),
|
|
330
|
+
(err) => err.name === 'ACCOUNT_LOCKED_TEMP'
|
|
331
|
+
)
|
|
332
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
333
|
+
assert.equal(lastUpdate.data.isTempLocked, true)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('should permanently lock after reaching failsUntilPermanentLock threshold', async () => {
|
|
337
|
+
const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
|
|
338
|
+
const hash = await PasswordUtils.generate('correctpass')
|
|
339
|
+
const user = {
|
|
340
|
+
_id: 'user-1',
|
|
341
|
+
password: hash,
|
|
342
|
+
isPermLocked: false,
|
|
343
|
+
isTempLocked: false,
|
|
344
|
+
failedLoginAttempts: 19, // one more will be 20 (the threshold)
|
|
345
|
+
lastFailedLoginAttempt: null
|
|
346
|
+
}
|
|
347
|
+
const req = { body: { password: 'wrongpass' } }
|
|
348
|
+
await assert.rejects(
|
|
349
|
+
() => mod.authenticate(user, req, {}),
|
|
350
|
+
(err) => err.name === 'ACCOUNT_LOCKED_PERM'
|
|
351
|
+
)
|
|
352
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
353
|
+
assert.equal(lastUpdate.data.isPermLocked, true)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('should throw ACCOUNT_LOCKED_PERM when user is already permanently locked', async () => {
|
|
357
|
+
const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
|
|
358
|
+
const hash = await PasswordUtils.generate('correctpass')
|
|
359
|
+
const user = {
|
|
360
|
+
_id: 'user-1',
|
|
361
|
+
password: hash,
|
|
362
|
+
isPermLocked: true,
|
|
363
|
+
isTempLocked: false,
|
|
364
|
+
failedLoginAttempts: 20,
|
|
365
|
+
lastFailedLoginAttempt: new Date().toISOString()
|
|
366
|
+
}
|
|
367
|
+
const req = { body: { password: 'correctpass' } }
|
|
368
|
+
await assert.rejects(
|
|
369
|
+
() => mod.authenticate(user, req, {}),
|
|
370
|
+
(err) => err.name === 'ACCOUNT_LOCKED_PERM'
|
|
371
|
+
)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('should not increment failed attempts when account is permanently locked', async () => {
|
|
375
|
+
const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
|
|
376
|
+
const hash = await PasswordUtils.generate('correctpass')
|
|
377
|
+
const user = {
|
|
378
|
+
_id: 'user-1',
|
|
379
|
+
password: hash,
|
|
380
|
+
isPermLocked: true,
|
|
381
|
+
isTempLocked: false,
|
|
382
|
+
failedLoginAttempts: 20,
|
|
383
|
+
lastFailedLoginAttempt: new Date().toISOString()
|
|
384
|
+
}
|
|
385
|
+
const req = { body: { password: 'wrongpass' } }
|
|
386
|
+
await assert.rejects(() => mod.authenticate(user, req, {}))
|
|
387
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
388
|
+
assert.equal(lastUpdate.data.failedLoginAttempts, 20)
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
describe('#handleLockStatus()', () => {
|
|
393
|
+
it('should throw ACCOUNT_LOCKED_PERM when user is permanently locked', async () => {
|
|
394
|
+
await assert.rejects(
|
|
395
|
+
() => mod.handleLockStatus({ isPermLocked: true, isTempLocked: false }),
|
|
396
|
+
(err) => err.name === 'ACCOUNT_LOCKED_PERM'
|
|
397
|
+
)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('should throw ACCOUNT_LOCKED_TEMP when user is temp locked and lock has not expired', async () => {
|
|
401
|
+
const user = {
|
|
402
|
+
isPermLocked: false,
|
|
403
|
+
isTempLocked: true,
|
|
404
|
+
// NOTE: handleLockStatus multiplies temporaryLockDuration by 1000, which
|
|
405
|
+
// appears to be a bug if the config value is already in ms (isTimeMs: true).
|
|
406
|
+
// We set lastFailedLoginAttempt to now so the lock is still active with
|
|
407
|
+
// the doubled value.
|
|
408
|
+
lastFailedLoginAttempt: new Date().toISOString()
|
|
409
|
+
}
|
|
410
|
+
await assert.rejects(
|
|
411
|
+
() => mod.handleLockStatus(user),
|
|
412
|
+
(err) => err.name === 'ACCOUNT_LOCKED_TEMP'
|
|
413
|
+
)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('should unlock user when temp lock has expired', async () => {
|
|
417
|
+
const user = {
|
|
418
|
+
_id: 'user-1',
|
|
419
|
+
isPermLocked: false,
|
|
420
|
+
isTempLocked: true,
|
|
421
|
+
// Set to long ago so the lock is expired (even with the * 1000 bug)
|
|
422
|
+
lastFailedLoginAttempt: new Date(Date.now() - 999999999).toISOString()
|
|
423
|
+
}
|
|
424
|
+
await mod.handleLockStatus(user)
|
|
425
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
426
|
+
assert.equal(lastUpdate.data.isTempLocked, false)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('should not update anything when user is not locked', async () => {
|
|
430
|
+
const user = {
|
|
431
|
+
isPermLocked: false,
|
|
432
|
+
isTempLocked: false,
|
|
433
|
+
lastFailedLoginAttempt: null
|
|
434
|
+
}
|
|
435
|
+
await mod.handleLockStatus(user)
|
|
436
|
+
assert.equal(updateCalls.length, 0)
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
describe('#register()', () => {
|
|
441
|
+
it('should hash the password and call super.register', async () => {
|
|
442
|
+
const result = await mod.register({ email: 'new@example.com', password: 'validpassword' })
|
|
443
|
+
assert.equal(result._id, 'new-user-id')
|
|
444
|
+
assert.equal(result.email, 'new@example.com')
|
|
445
|
+
assert.ok(result.password.startsWith('$2a$') || result.password.startsWith('$2b$'))
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('should auto-generate a password when none is provided', async () => {
|
|
449
|
+
const result = await mod.register({ email: 'new@example.com' })
|
|
450
|
+
assert.ok(result.password)
|
|
451
|
+
assert.ok(result.password.startsWith('$2a$') || result.password.startsWith('$2b$'))
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
describe('#registerSuper()', () => {
|
|
456
|
+
it('should throw SUPER_USER_EXISTS when a super user already exists', async () => {
|
|
457
|
+
usersStore.push({ _id: 'existing-super', roles: ['role-super-id'] })
|
|
458
|
+
await assert.rejects(
|
|
459
|
+
() => mod.registerSuper({ email: 'super@example.com', password: 'validpassword' }),
|
|
460
|
+
(err) => err.name === 'SUPER_USER_EXISTS'
|
|
461
|
+
)
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
describe('#setUserEnabled()', () => {
|
|
466
|
+
it('should reset failed attempts and unlock when enabling a user', async () => {
|
|
467
|
+
const user = { _id: 'user-1', failedLoginAttempts: 10 }
|
|
468
|
+
await mod.setUserEnabled(user, true)
|
|
469
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
470
|
+
assert.equal(lastUpdate.data.failedLoginAttempts, 0)
|
|
471
|
+
assert.equal(lastUpdate.data.isPermLocked, false)
|
|
472
|
+
assert.equal(lastUpdate.data.isTempLocked, false)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
// NOTE: There is a bug in setUserEnabled — when disabling (isEnabled=false),
|
|
476
|
+
// it references user.failedAttempts which doesn't exist on the schema.
|
|
477
|
+
// The schema field is user.failedLoginAttempts. This means
|
|
478
|
+
// failedLoginAttempts will always be set to undefined when disabling.
|
|
479
|
+
it('should lock the account when disabling a user', async () => {
|
|
480
|
+
const user = { _id: 'user-1', failedLoginAttempts: 3, failedAttempts: 3 }
|
|
481
|
+
await mod.setUserEnabled(user, false)
|
|
482
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
483
|
+
assert.equal(lastUpdate.data.isPermLocked, true)
|
|
484
|
+
assert.equal(lastUpdate.data.isTempLocked, true)
|
|
485
|
+
})
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
describe('#updateUser()', () => {
|
|
489
|
+
it('should accept a string ID as userIdOrQuery', async () => {
|
|
490
|
+
await mod.updateUser('user-id-1', { firstName: 'Updated' })
|
|
491
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
492
|
+
assert.deepEqual(lastUpdate.query, { _id: 'user-id-1' })
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('should accept a query object as userIdOrQuery', async () => {
|
|
496
|
+
await mod.updateUser({ email: 'test@example.com' }, { firstName: 'Updated' })
|
|
497
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
498
|
+
assert.deepEqual(lastUpdate.query, { email: 'test@example.com' })
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('should hash password when update includes password', async () => {
|
|
502
|
+
const result = await mod.updateUser('user-id-1', { password: 'newpassword' })
|
|
503
|
+
assert.ok(result)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('should not hash password when update does not include password', async () => {
|
|
507
|
+
await mod.updateUser('user-id-1', { firstName: 'NoPassword' })
|
|
508
|
+
const lastUpdate = updateCalls[updateCalls.length - 1]
|
|
509
|
+
assert.equal(lastUpdate.data.firstName, 'NoPassword')
|
|
510
|
+
assert.equal(Object.prototype.hasOwnProperty.call(lastUpdate.data, 'password'), false)
|
|
511
|
+
})
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
describe('#createPasswordReset()', () => {
|
|
515
|
+
it('should throw when email is not provided', async () => {
|
|
516
|
+
await assert.rejects(
|
|
517
|
+
() => mod.createPasswordReset(''),
|
|
518
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
519
|
+
)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('should throw when email is undefined', async () => {
|
|
523
|
+
await assert.rejects(
|
|
524
|
+
() => mod.createPasswordReset(undefined),
|
|
525
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
526
|
+
)
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
describe('#inviteHandler()', () => {
|
|
531
|
+
it('should respond with 204 on success', async () => {
|
|
532
|
+
let statusSent
|
|
533
|
+
const req = {
|
|
534
|
+
body: { email: 'invite@example.com' },
|
|
535
|
+
translate: (key) => `translated:${key}`,
|
|
536
|
+
auth: { user: { _id: { toString: () => 'admin-id' } } }
|
|
537
|
+
}
|
|
538
|
+
const res = {
|
|
539
|
+
sendStatus: (code) => { statusSent = code }
|
|
540
|
+
}
|
|
541
|
+
// Stub createPasswordReset to avoid real execution
|
|
542
|
+
const original = mod.createPasswordReset.bind(mod)
|
|
543
|
+
mod.createPasswordReset = async () => {}
|
|
544
|
+
await mod.inviteHandler(req, res, () => {})
|
|
545
|
+
mod.createPasswordReset = original
|
|
546
|
+
assert.equal(statusSent, 204)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('should call next with error on failure', async () => {
|
|
550
|
+
let nextError
|
|
551
|
+
const req = {
|
|
552
|
+
body: { email: '' },
|
|
553
|
+
translate: (key) => `translated:${key}`,
|
|
554
|
+
auth: {}
|
|
555
|
+
}
|
|
556
|
+
const res = { sendStatus: () => {} }
|
|
557
|
+
await mod.inviteHandler(req, res, (err) => { nextError = err })
|
|
558
|
+
assert.ok(nextError)
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
describe('#registerSuperHandler()', () => {
|
|
563
|
+
it('should respond with 204 on success', async () => {
|
|
564
|
+
let statusSent
|
|
565
|
+
const req = { body: { email: 'super@example.com', password: 'validpassword' } }
|
|
566
|
+
const res = { sendStatus: (code) => { statusSent = code } }
|
|
567
|
+
// Stub registerSuper
|
|
568
|
+
const original = mod.registerSuper.bind(mod)
|
|
569
|
+
mod.registerSuper = async () => {}
|
|
570
|
+
await mod.registerSuperHandler(req, res, () => {})
|
|
571
|
+
mod.registerSuper = original
|
|
572
|
+
assert.equal(statusSent, 204)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('should call next with error on failure', async () => {
|
|
576
|
+
let nextError
|
|
577
|
+
const req = { body: { email: 'super@example.com', password: 'validpassword' } }
|
|
578
|
+
const res = { sendStatus: () => {} }
|
|
579
|
+
usersStore.push({ _id: 'existing', roles: ['role-super-id'] })
|
|
580
|
+
await mod.registerSuperHandler(req, res, (err) => { nextError = err })
|
|
581
|
+
assert.ok(nextError)
|
|
582
|
+
})
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
describe('#forgotPasswordHandler()', () => {
|
|
586
|
+
it('should always respond with 200 and a message (even on error)', async () => {
|
|
587
|
+
let responseStatus
|
|
588
|
+
let responseJson
|
|
589
|
+
const req = {
|
|
590
|
+
body: { email: '' },
|
|
591
|
+
translate: (key) => `translated:${key}`,
|
|
592
|
+
auth: {}
|
|
593
|
+
}
|
|
594
|
+
const res = {
|
|
595
|
+
status: (code) => {
|
|
596
|
+
responseStatus = code
|
|
597
|
+
return { json: (data) => { responseJson = data } }
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
await mod.forgotPasswordHandler(req, res, () => {})
|
|
601
|
+
assert.equal(responseStatus, 200)
|
|
602
|
+
assert.ok(responseJson.message)
|
|
603
|
+
})
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
describe('#validatePasswordHandler()', () => {
|
|
607
|
+
it('should respond with a success message for a valid password', async () => {
|
|
608
|
+
let responseJson
|
|
609
|
+
const req = {
|
|
610
|
+
body: { password: 'validpassword' },
|
|
611
|
+
translate: (key) => `translated:${key}`
|
|
612
|
+
}
|
|
613
|
+
const res = {
|
|
614
|
+
json: (data) => { responseJson = data }
|
|
615
|
+
}
|
|
616
|
+
await mod.validatePasswordHandler(req, res, () => {})
|
|
617
|
+
assert.ok(responseJson.message)
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('should call sendError for an invalid password', async () => {
|
|
621
|
+
let sentError
|
|
622
|
+
authlocalConfig.minPasswordLength = 20
|
|
623
|
+
const req = {
|
|
624
|
+
body: { password: 'short' },
|
|
625
|
+
translate: (key) => `translated:${key}`
|
|
626
|
+
}
|
|
627
|
+
const res = {
|
|
628
|
+
sendError: (err) => { sentError = err }
|
|
629
|
+
}
|
|
630
|
+
await mod.validatePasswordHandler(req, res, () => {})
|
|
631
|
+
assert.ok(sentError)
|
|
632
|
+
assert.equal(sentError.name, 'INVALID_PASSWORD')
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
})
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { describe, it, before, beforeEach, after, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import bcrypt from 'bcryptjs'
|
|
4
|
+
import { promisify } from 'util'
|
|
5
|
+
|
|
6
|
+
// --- Stub App.instance before importing PasswordUtils ---
|
|
7
|
+
|
|
8
|
+
const mockErrors = {
|
|
9
|
+
INVALID_LOGIN_DETAILS: { setData: (d) => ({ ...mockErrors.INVALID_LOGIN_DETAILS, data: d, name: 'INVALID_LOGIN_DETAILS' }), name: 'INVALID_LOGIN_DETAILS' },
|
|
10
|
+
INVALID_PARAMS: { setData: (d) => ({ ...mockErrors.INVALID_PARAMS, data: d, name: 'INVALID_PARAMS' }), name: 'INVALID_PARAMS' },
|
|
11
|
+
INVALID_PASSWORD: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD, data: d, name: 'INVALID_PASSWORD' }), name: 'INVALID_PASSWORD' },
|
|
12
|
+
INVALID_PASSWORD_LENGTH: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_LENGTH, data: d, name: 'INVALID_PASSWORD_LENGTH' }), name: 'INVALID_PASSWORD_LENGTH' },
|
|
13
|
+
INVALID_PASSWORD_NUMBER: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_NUMBER, data: d, name: 'INVALID_PASSWORD_NUMBER' }), name: 'INVALID_PASSWORD_NUMBER' },
|
|
14
|
+
INVALID_PASSWORD_UPPERCASE: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_UPPERCASE, data: d, name: 'INVALID_PASSWORD_UPPERCASE' }), name: 'INVALID_PASSWORD_UPPERCASE' },
|
|
15
|
+
INVALID_PASSWORD_LOWERCASE: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_LOWERCASE, data: d, name: 'INVALID_PASSWORD_LOWERCASE' }), name: 'INVALID_PASSWORD_LOWERCASE' },
|
|
16
|
+
INVALID_PASSWORD_SPECIAL: { setData: (d) => ({ ...mockErrors.INVALID_PASSWORD_SPECIAL, data: d, name: 'INVALID_PASSWORD_SPECIAL' }), name: 'INVALID_PASSWORD_SPECIAL' },
|
|
17
|
+
BLACKLISTED_PASSWORD_VALUE: { setData: (d) => ({ ...mockErrors.BLACKLISTED_PASSWORD_VALUE, data: d, name: 'BLACKLISTED_PASSWORD_VALUE' }), name: 'BLACKLISTED_PASSWORD_VALUE' },
|
|
18
|
+
INCORRECT_PASSWORD: { name: 'INCORRECT_PASSWORD' },
|
|
19
|
+
NOT_FOUND: { setData: (d) => ({ ...mockErrors.NOT_FOUND, data: d, name: 'NOT_FOUND' }), name: 'NOT_FOUND' },
|
|
20
|
+
AUTH_TOKEN_INVALID: { name: 'AUTH_TOKEN_INVALID' },
|
|
21
|
+
AUTH_TOKEN_EXPIRED: { name: 'AUTH_TOKEN_EXPIRED' },
|
|
22
|
+
ACCOUNT_NOT_LOCALAUTHD: { name: 'ACCOUNT_NOT_LOCALAUTHD' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let authlocalConfig = {
|
|
26
|
+
saltRounds: 10,
|
|
27
|
+
minPasswordLength: 8,
|
|
28
|
+
passwordMustHaveNumber: false,
|
|
29
|
+
passwordMustHaveUppercase: false,
|
|
30
|
+
passwordMustHaveLowercase: false,
|
|
31
|
+
passwordMustHaveSpecial: false,
|
|
32
|
+
blacklistedPasswordValues: [],
|
|
33
|
+
resetTokenLifespan: 86400000
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mockPasswordResetsStore = []
|
|
37
|
+
const mockUsersStore = []
|
|
38
|
+
|
|
39
|
+
const mockAuthlocal = {
|
|
40
|
+
getConfig: (key) => authlocalConfig[key],
|
|
41
|
+
log: () => {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mockJsonschema = {
|
|
45
|
+
getSchema: async () => ({
|
|
46
|
+
validate: async () => true
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mockMongodbCollection = {
|
|
51
|
+
deleteMany: async () => {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mockMongodb = {
|
|
55
|
+
find: async (collection, query) => {
|
|
56
|
+
if (collection === 'passwordresets') {
|
|
57
|
+
return mockPasswordResetsStore.filter(r => r.token === query.token)
|
|
58
|
+
}
|
|
59
|
+
return []
|
|
60
|
+
},
|
|
61
|
+
insert: async (collection, data) => {
|
|
62
|
+
mockPasswordResetsStore.push(data)
|
|
63
|
+
return data
|
|
64
|
+
},
|
|
65
|
+
delete: async (collection, query) => {
|
|
66
|
+
const idx = mockPasswordResetsStore.findIndex(r => r.token === query.token)
|
|
67
|
+
if (idx !== -1) mockPasswordResetsStore.splice(idx, 1)
|
|
68
|
+
},
|
|
69
|
+
getCollection: () => mockMongodbCollection
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const mockUsers = {
|
|
73
|
+
find: async (query) => {
|
|
74
|
+
return mockUsersStore.filter(u => u.email === query.email)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const moduleMap = {
|
|
79
|
+
'auth-local': mockAuthlocal,
|
|
80
|
+
jsonschema: mockJsonschema,
|
|
81
|
+
mongodb: mockMongodb,
|
|
82
|
+
users: mockUsers
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const mockApp = {
|
|
86
|
+
errors: mockErrors,
|
|
87
|
+
waitForModule: async (...names) => {
|
|
88
|
+
if (names.length === 1) return moduleMap[names[0]]
|
|
89
|
+
return names.map(n => moduleMap[n])
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Register the mock for adapt-authoring-core before importing PasswordUtils
|
|
94
|
+
mock.module('adapt-authoring-core', {
|
|
95
|
+
namedExports: { App: { instance: mockApp } }
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
|
|
99
|
+
|
|
100
|
+
describe('PasswordUtils', () => {
|
|
101
|
+
describe('#getConfig()', () => {
|
|
102
|
+
it('should return a single config value when one key is passed', async () => {
|
|
103
|
+
const result = await PasswordUtils.getConfig('saltRounds')
|
|
104
|
+
assert.equal(result, 10)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should return an object of config values when multiple keys are passed', async () => {
|
|
108
|
+
const result = await PasswordUtils.getConfig('saltRounds', 'minPasswordLength')
|
|
109
|
+
assert.deepEqual(result, { saltRounds: 10, minPasswordLength: 8 })
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('#compare()', () => {
|
|
114
|
+
let hash
|
|
115
|
+
|
|
116
|
+
before(async () => {
|
|
117
|
+
const salt = await promisify(bcrypt.genSalt)(10)
|
|
118
|
+
hash = await promisify(bcrypt.hash)('correctpassword', salt)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should resolve without error when password matches hash', async () => {
|
|
122
|
+
await assert.doesNotReject(() => PasswordUtils.compare('correctpassword', hash))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should throw when password does not match hash', async () => {
|
|
126
|
+
await assert.rejects(
|
|
127
|
+
() => PasswordUtils.compare('wrongpassword', hash),
|
|
128
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should throw when plainPassword is empty', async () => {
|
|
133
|
+
await assert.rejects(
|
|
134
|
+
() => PasswordUtils.compare('', hash),
|
|
135
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should throw when hash is empty', async () => {
|
|
140
|
+
await assert.rejects(
|
|
141
|
+
() => PasswordUtils.compare('password', ''),
|
|
142
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should throw when both arguments are missing', async () => {
|
|
147
|
+
await assert.rejects(
|
|
148
|
+
() => PasswordUtils.compare(undefined, undefined),
|
|
149
|
+
(err) => err.name === 'INVALID_LOGIN_DETAILS'
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('#validate()', () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
authlocalConfig = {
|
|
157
|
+
...authlocalConfig,
|
|
158
|
+
minPasswordLength: 8,
|
|
159
|
+
passwordMustHaveNumber: false,
|
|
160
|
+
passwordMustHaveUppercase: false,
|
|
161
|
+
passwordMustHaveLowercase: false,
|
|
162
|
+
passwordMustHaveSpecial: false,
|
|
163
|
+
blacklistedPasswordValues: []
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should resolve for a valid password meeting minimum length', async () => {
|
|
168
|
+
await assert.doesNotReject(() => PasswordUtils.validate('abcdefgh'))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should throw when password is not a string', async () => {
|
|
172
|
+
await assert.rejects(
|
|
173
|
+
() => PasswordUtils.validate(12345678),
|
|
174
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('should throw when password is too short', async () => {
|
|
179
|
+
await assert.rejects(
|
|
180
|
+
() => PasswordUtils.validate('short'),
|
|
181
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('should throw when number is required but missing', async () => {
|
|
186
|
+
authlocalConfig.passwordMustHaveNumber = true
|
|
187
|
+
await assert.rejects(
|
|
188
|
+
() => PasswordUtils.validate('abcdefgh'),
|
|
189
|
+
(err) => {
|
|
190
|
+
assert.equal(err.name, 'INVALID_PASSWORD')
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('should accept a password with a number when required', async () => {
|
|
197
|
+
authlocalConfig.passwordMustHaveNumber = true
|
|
198
|
+
await assert.doesNotReject(() => PasswordUtils.validate('abcdefg1'))
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should throw when uppercase is required but missing', async () => {
|
|
202
|
+
authlocalConfig.passwordMustHaveUppercase = true
|
|
203
|
+
await assert.rejects(
|
|
204
|
+
() => PasswordUtils.validate('abcdefgh'),
|
|
205
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should accept a password with uppercase when required', async () => {
|
|
210
|
+
authlocalConfig.passwordMustHaveUppercase = true
|
|
211
|
+
await assert.doesNotReject(() => PasswordUtils.validate('Abcdefgh'))
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should throw when lowercase is required but missing', async () => {
|
|
215
|
+
authlocalConfig.passwordMustHaveLowercase = true
|
|
216
|
+
await assert.rejects(
|
|
217
|
+
() => PasswordUtils.validate('ABCDEFGH'),
|
|
218
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('should accept a password with lowercase when required', async () => {
|
|
223
|
+
authlocalConfig.passwordMustHaveLowercase = true
|
|
224
|
+
await assert.doesNotReject(() => PasswordUtils.validate('abcdefgh'))
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should throw when special character is required but missing', async () => {
|
|
228
|
+
authlocalConfig.passwordMustHaveSpecial = true
|
|
229
|
+
await assert.rejects(
|
|
230
|
+
() => PasswordUtils.validate('abcdefgh'),
|
|
231
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should accept a password with special character when required', async () => {
|
|
236
|
+
authlocalConfig.passwordMustHaveSpecial = true
|
|
237
|
+
const specials = ['#', '?', '!', '@', '$', '%', '^', '&', '*', '-']
|
|
238
|
+
for (const ch of specials) {
|
|
239
|
+
await assert.doesNotReject(() => PasswordUtils.validate(`abcdefg${ch}`))
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should collect multiple validation errors', async () => {
|
|
244
|
+
authlocalConfig.passwordMustHaveNumber = true
|
|
245
|
+
authlocalConfig.passwordMustHaveUppercase = true
|
|
246
|
+
authlocalConfig.passwordMustHaveSpecial = true
|
|
247
|
+
await assert.rejects(
|
|
248
|
+
() => PasswordUtils.validate('abcdefgh'),
|
|
249
|
+
(err) => {
|
|
250
|
+
assert.equal(err.name, 'INVALID_PASSWORD')
|
|
251
|
+
assert.ok(err.data.errors.length >= 3)
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('should pass all complexity rules simultaneously', async () => {
|
|
258
|
+
authlocalConfig.passwordMustHaveNumber = true
|
|
259
|
+
authlocalConfig.passwordMustHaveUppercase = true
|
|
260
|
+
authlocalConfig.passwordMustHaveLowercase = true
|
|
261
|
+
authlocalConfig.passwordMustHaveSpecial = true
|
|
262
|
+
await assert.doesNotReject(() => PasswordUtils.validate('Abcdef1!'))
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// NOTE: The blacklist check in the source has a bug — it uses .some() where
|
|
266
|
+
// it should use .every(). This means a password containing a blacklisted
|
|
267
|
+
// value can still pass if there are other blacklisted values it doesn't
|
|
268
|
+
// contain. The test below documents the expected (correct) behaviour.
|
|
269
|
+
// See PasswordUtils.js line 67.
|
|
270
|
+
it('should throw when password contains a blacklisted value', async () => {
|
|
271
|
+
authlocalConfig.blacklistedPasswordValues = ['password']
|
|
272
|
+
// With only one blacklisted entry, .some() and .every() behave identically
|
|
273
|
+
await assert.rejects(
|
|
274
|
+
() => PasswordUtils.validate('password123'),
|
|
275
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
276
|
+
)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('should accept a password not containing blacklisted values', async () => {
|
|
280
|
+
authlocalConfig.blacklistedPasswordValues = ['password']
|
|
281
|
+
await assert.doesNotReject(() => PasswordUtils.validate('securevalue'))
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// BUG: The blacklist check uses .some(p => !(password.includes(p))) where
|
|
285
|
+
// it should use .every(). With multiple blacklisted values, a password
|
|
286
|
+
// containing one blacklisted value passes if another blacklisted value is
|
|
287
|
+
// absent. See PasswordUtils.js line 67.
|
|
288
|
+
it('should throw when password contains any blacklisted value (multiple entries)', { todo: 'blacklist check uses .some() instead of .every()' }, async () => {
|
|
289
|
+
authlocalConfig.blacklistedPasswordValues = ['password', 'qwerty']
|
|
290
|
+
await assert.rejects(
|
|
291
|
+
() => PasswordUtils.validate('password123'),
|
|
292
|
+
(err) => err.name === 'INVALID_PASSWORD'
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('#generate()', () => {
|
|
298
|
+
it('should return a bcrypt hash for a valid password', async () => {
|
|
299
|
+
const hash = await PasswordUtils.generate('validpassword')
|
|
300
|
+
assert.equal(typeof hash, 'string')
|
|
301
|
+
assert.ok(hash.startsWith('$2a$') || hash.startsWith('$2b$'))
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('should throw when plainPassword is empty', async () => {
|
|
305
|
+
await assert.rejects(
|
|
306
|
+
() => PasswordUtils.generate(''),
|
|
307
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
308
|
+
)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('should throw when plainPassword is undefined', async () => {
|
|
312
|
+
await assert.rejects(
|
|
313
|
+
() => PasswordUtils.generate(undefined),
|
|
314
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('should generate different hashes for the same password (salted)', async () => {
|
|
319
|
+
const hash1 = await PasswordUtils.generate('samepassword')
|
|
320
|
+
const hash2 = await PasswordUtils.generate('samepassword')
|
|
321
|
+
assert.notEqual(hash1, hash2)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('#getRandomHex()', () => {
|
|
326
|
+
it('should return a hex string of default length (64 chars for 32 bytes)', async () => {
|
|
327
|
+
const hex = await PasswordUtils.getRandomHex()
|
|
328
|
+
assert.equal(typeof hex, 'string')
|
|
329
|
+
assert.equal(hex.length, 64)
|
|
330
|
+
assert.ok(/^[0-9a-f]+$/.test(hex))
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('should return a hex string of specified length', async () => {
|
|
334
|
+
const hex = await PasswordUtils.getRandomHex(16)
|
|
335
|
+
assert.equal(hex.length, 32)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('should return unique values on subsequent calls', async () => {
|
|
339
|
+
const hex1 = await PasswordUtils.getRandomHex()
|
|
340
|
+
const hex2 = await PasswordUtils.getRandomHex()
|
|
341
|
+
assert.notEqual(hex1, hex2)
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
describe('#createReset()', () => {
|
|
346
|
+
beforeEach(() => {
|
|
347
|
+
mockPasswordResetsStore.length = 0
|
|
348
|
+
mockUsersStore.length = 0
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('should create a reset token for a valid local auth user', async () => {
|
|
352
|
+
mockUsersStore.push({ email: 'test@example.com', authType: 'local' })
|
|
353
|
+
const token = await PasswordUtils.createReset('test@example.com', 86400000)
|
|
354
|
+
assert.equal(typeof token, 'string')
|
|
355
|
+
assert.ok(token.length > 0)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('should store the reset data in the collection', async () => {
|
|
359
|
+
mockUsersStore.push({ email: 'test@example.com', authType: 'local' })
|
|
360
|
+
await PasswordUtils.createReset('test@example.com', 86400000)
|
|
361
|
+
assert.equal(mockPasswordResetsStore.length, 1)
|
|
362
|
+
assert.equal(mockPasswordResetsStore[0].email, 'test@example.com')
|
|
363
|
+
assert.ok(mockPasswordResetsStore[0].expiresAt)
|
|
364
|
+
assert.ok(mockPasswordResetsStore[0].token)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('should throw when user is not found', async () => {
|
|
368
|
+
await assert.rejects(
|
|
369
|
+
() => PasswordUtils.createReset('noone@example.com', 86400000),
|
|
370
|
+
(err) => err.name === 'NOT_FOUND'
|
|
371
|
+
)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('should throw when user is not authenticated with local auth', async () => {
|
|
375
|
+
mockUsersStore.push({ email: 'sso@example.com', authType: 'sso' })
|
|
376
|
+
await assert.rejects(
|
|
377
|
+
() => PasswordUtils.createReset('sso@example.com', 86400000),
|
|
378
|
+
(err) => err.name === 'ACCOUNT_NOT_LOCALAUTHD'
|
|
379
|
+
)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('should use default resetTokenLifespan when lifespan is not provided', async () => {
|
|
383
|
+
mockUsersStore.push({ email: 'test@example.com', authType: 'local' })
|
|
384
|
+
const token = await PasswordUtils.createReset('test@example.com')
|
|
385
|
+
assert.equal(typeof token, 'string')
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
describe('#deleteReset()', () => {
|
|
390
|
+
beforeEach(() => {
|
|
391
|
+
mockPasswordResetsStore.length = 0
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('should remove the reset token from the store', async () => {
|
|
395
|
+
mockPasswordResetsStore.push({ token: 'abc123', email: 'test@example.com' })
|
|
396
|
+
await PasswordUtils.deleteReset('abc123')
|
|
397
|
+
assert.equal(mockPasswordResetsStore.length, 0)
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
describe('#validateReset()', () => {
|
|
402
|
+
beforeEach(() => {
|
|
403
|
+
mockPasswordResetsStore.length = 0
|
|
404
|
+
mockUsersStore.length = 0
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('should throw when token is empty', async () => {
|
|
408
|
+
await assert.rejects(
|
|
409
|
+
() => PasswordUtils.validateReset(''),
|
|
410
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
411
|
+
)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('should throw when token is undefined', async () => {
|
|
415
|
+
await assert.rejects(
|
|
416
|
+
() => PasswordUtils.validateReset(undefined),
|
|
417
|
+
(err) => err.name === 'INVALID_PARAMS'
|
|
418
|
+
)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('should throw when token is not found', async () => {
|
|
422
|
+
await assert.rejects(
|
|
423
|
+
() => PasswordUtils.validateReset('nonexistent'),
|
|
424
|
+
(err) => err.name === 'AUTH_TOKEN_INVALID'
|
|
425
|
+
)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('should throw when token is expired', async () => {
|
|
429
|
+
mockPasswordResetsStore.push({
|
|
430
|
+
token: 'expired-token',
|
|
431
|
+
email: 'test@example.com',
|
|
432
|
+
expiresAt: new Date(Date.now() - 1000).toISOString()
|
|
433
|
+
})
|
|
434
|
+
await assert.rejects(
|
|
435
|
+
() => PasswordUtils.validateReset('expired-token'),
|
|
436
|
+
(err) => err.name === 'AUTH_TOKEN_EXPIRED'
|
|
437
|
+
)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('should throw when user associated with token is not found', async () => {
|
|
441
|
+
mockPasswordResetsStore.push({
|
|
442
|
+
token: 'valid-token',
|
|
443
|
+
email: 'deleted@example.com',
|
|
444
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString()
|
|
445
|
+
})
|
|
446
|
+
await assert.rejects(
|
|
447
|
+
() => PasswordUtils.validateReset('valid-token'),
|
|
448
|
+
(err) => err.name === 'NOT_FOUND'
|
|
449
|
+
)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('should return token data for a valid, non-expired token', async () => {
|
|
453
|
+
const tokenData = {
|
|
454
|
+
token: 'valid-token',
|
|
455
|
+
email: 'test@example.com',
|
|
456
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString()
|
|
457
|
+
}
|
|
458
|
+
mockPasswordResetsStore.push(tokenData)
|
|
459
|
+
mockUsersStore.push({ email: 'test@example.com' })
|
|
460
|
+
const result = await PasswordUtils.validateReset('valid-token')
|
|
461
|
+
assert.equal(result.token, 'valid-token')
|
|
462
|
+
assert.equal(result.email, 'test@example.com')
|
|
463
|
+
})
|
|
464
|
+
})
|
|
465
|
+
})
|