adapt-authoring-auth-local 2.4.1 → 2.6.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/conf/config.schema.json +5 -0
- package/lib/LocalAuthModule.js +78 -27
- package/lib/utils/isSmtpConnectionError.js +16 -0
- package/lib/utils.js +1 -0
- package/package.json +2 -2
- package/routes.json +1 -0
- package/tests/LocalAuthModule.spec.js +43 -5
- package/tests/utils-isSmtpConnectionError.spec.js +22 -0
package/conf/config.schema.json
CHANGED
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
"isTimeMs": true,
|
|
36
36
|
"default": "7d"
|
|
37
37
|
},
|
|
38
|
+
"resetUrl": {
|
|
39
|
+
"description": "Reset link path appended to the server root URL, matching the UI's reset route. Supports {{token}} and {{email}}.",
|
|
40
|
+
"type": "string",
|
|
41
|
+
"default": "#user/reset?token={{token}}&email={{email}}"
|
|
42
|
+
},
|
|
38
43
|
"minPasswordLength": {
|
|
39
44
|
"description": "Minimum password length",
|
|
40
45
|
"type": "number",
|
package/lib/LocalAuthModule.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import _ from 'lodash'
|
|
2
2
|
import { AbstractAuthModule } from 'adapt-authoring-auth'
|
|
3
|
-
import { compare, getRandomHex, validate } from './utils.js'
|
|
3
|
+
import { compare, getRandomHex, isSmtpConnectionError, validate } from './utils.js'
|
|
4
4
|
import { formatDistanceToNowStrict as toNow } from 'date-fns'
|
|
5
5
|
import PasswordUtils from './PasswordUtils.js'
|
|
6
6
|
/**
|
|
@@ -88,9 +88,42 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
88
88
|
|
|
89
89
|
/** @override */
|
|
90
90
|
async register (data) {
|
|
91
|
+
const passwordProvided = Boolean(data.password)
|
|
91
92
|
data.password = data.password ?? await getRandomHex()
|
|
92
93
|
await validate(data.password)
|
|
93
|
-
|
|
94
|
+
const user = await super.register({ ...data, password: await PasswordUtils.generate(data.password) })
|
|
95
|
+
// No password supplied: the generated one is unusable, so invite the user to
|
|
96
|
+
// set their own — but a mail failure must not fail registration, hence catch.
|
|
97
|
+
if (!passwordProvided) {
|
|
98
|
+
try {
|
|
99
|
+
await this.sendInvite(data.email)
|
|
100
|
+
} catch (e) {
|
|
101
|
+
this.log('warn', `failed to send invite to ${data.email}: ${e.message}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return user
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Emails a set-password invite to a user, reusing the invite password-reset
|
|
109
|
+
* flow. No-op when no mailer is configured. Throws on send failure — callers
|
|
110
|
+
* that shouldn't fail on a mail error (e.g. registration) must catch.
|
|
111
|
+
* @param {String} email
|
|
112
|
+
*/
|
|
113
|
+
async sendInvite (email) {
|
|
114
|
+
const mailer = await this.app.waitForModule('mailer')
|
|
115
|
+
if (!mailer.isEnabled) return
|
|
116
|
+
const appName = this.app.config.get('adapt-authoring-core.appName')
|
|
117
|
+
const t = (k) => this.app.lang.translate(undefined, `app.${k}`, { appName })
|
|
118
|
+
await this.createPasswordReset(email, {
|
|
119
|
+
subject: t('email_invite_subject'),
|
|
120
|
+
content: {
|
|
121
|
+
emblem: 'spark',
|
|
122
|
+
title: t('email_invite_title'),
|
|
123
|
+
body: t('email_invite_body'),
|
|
124
|
+
button: { label: t('email_invite_button') }
|
|
125
|
+
}
|
|
126
|
+
}, this.getConfig('inviteTokenLifespan'))
|
|
94
127
|
}
|
|
95
128
|
|
|
96
129
|
/**
|
|
@@ -147,11 +180,20 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
147
180
|
|
|
148
181
|
await this.disavowUser({ userId: user._id, authType: this.type })
|
|
149
182
|
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
const html = this.app.lang.translate(undefined, 'app.updateuserhtml')
|
|
183
|
+
const appName = this.app.config.get('adapt-authoring-core.appName')
|
|
184
|
+
const t = (k) => this.app.lang.translate(undefined, `app.${k}`, { appName })
|
|
153
185
|
try {
|
|
154
|
-
if (mailer.isEnabled)
|
|
186
|
+
if (mailer.isEnabled) {
|
|
187
|
+
await mailer.sendTemplated({
|
|
188
|
+
to: user.email,
|
|
189
|
+
subject: t('email_passwordupdated_subject'),
|
|
190
|
+
content: {
|
|
191
|
+
emblem: 'check',
|
|
192
|
+
title: t('email_passwordupdated_title'),
|
|
193
|
+
body: t('email_passwordupdated_body')
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
}
|
|
155
197
|
} catch (e) {
|
|
156
198
|
this.log('error', e)
|
|
157
199
|
}
|
|
@@ -160,29 +202,34 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
160
202
|
}
|
|
161
203
|
|
|
162
204
|
/**
|
|
163
|
-
* Creates a new password reset token and sends
|
|
205
|
+
* Creates a new password reset token and sends a templated email. The reset URL
|
|
206
|
+
* is built here and injected into the content's button.
|
|
164
207
|
* @param {String} email
|
|
165
|
-
* @param {
|
|
166
|
-
* @param {String}
|
|
167
|
-
* @param {
|
|
208
|
+
* @param {Object} message
|
|
209
|
+
* @param {String} message.subject Email subject (already translated)
|
|
210
|
+
* @param {Object} message.content Templated-email content ({ emblem?, title, body, button? }) — the button's url is set here
|
|
168
211
|
* @param {Number} lifespan The lifespan of the reset
|
|
169
212
|
*/
|
|
170
|
-
async createPasswordReset (email, subject,
|
|
213
|
+
async createPasswordReset (email, { subject, content } = {}, lifespan) {
|
|
171
214
|
if (!email) {
|
|
172
215
|
throw this.app.errors.INVALID_PARAMS.setData({ params: ['email'] })
|
|
173
216
|
}
|
|
174
217
|
try {
|
|
175
218
|
const [mailer, server] = await this.app.waitForModule('mailer', 'server')
|
|
176
219
|
const token = await PasswordUtils.createReset(email, lifespan)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
})
|
|
220
|
+
// resetUrl supplies the path; host comes from the server.
|
|
221
|
+
const path = this.getConfig('resetUrl')
|
|
222
|
+
.replace(/{{token}}/g, token)
|
|
223
|
+
.replace(/{{email}}/g, email)
|
|
224
|
+
if (content?.button) content.button.url = `${server.root.url}${path}`
|
|
225
|
+
await mailer.sendTemplated({ to: email, subject, content })
|
|
184
226
|
} catch (e) {
|
|
185
|
-
|
|
227
|
+
const cause = e?.data?.error ?? e
|
|
228
|
+
if (isSmtpConnectionError(e)) {
|
|
229
|
+
this.log('error', `Could not reach the SMTP server — check adapt-authoring-mailer is enabled with a valid connectionUrl and the server is running. Cause: ${cause.message ?? cause}`)
|
|
230
|
+
} else {
|
|
231
|
+
this.log('error', `Failed to create user password reset: ${cause.message ?? cause}`)
|
|
232
|
+
}
|
|
186
233
|
throw e
|
|
187
234
|
}
|
|
188
235
|
}
|
|
@@ -196,10 +243,7 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
196
243
|
async inviteHandler (req, res, next) {
|
|
197
244
|
try {
|
|
198
245
|
const { email } = req.body
|
|
199
|
-
|
|
200
|
-
const text = req.translate('app.invitepasswordtext')
|
|
201
|
-
const html = req.translate('app.invitepasswordhtml')
|
|
202
|
-
await this.createPasswordReset(email, subject, text, html, this.getConfig('inviteTokenLifespan'))
|
|
246
|
+
await this.sendInvite(email)
|
|
203
247
|
this.log('debug', 'INVITE_SENT', email, req?.auth?.user?._id?.toString())
|
|
204
248
|
} catch (e) {
|
|
205
249
|
return next(e)
|
|
@@ -231,10 +275,17 @@ class LocalAuthModule extends AbstractAuthModule {
|
|
|
231
275
|
async forgotPasswordHandler (req, res, next) {
|
|
232
276
|
try {
|
|
233
277
|
const { email } = req.body
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
278
|
+
const appName = this.app.config.get('adapt-authoring-core.appName')
|
|
279
|
+
const t = (k) => this.app.lang.translate(undefined, `app.${k}`, { appName })
|
|
280
|
+
await this.createPasswordReset(email, {
|
|
281
|
+
subject: t('email_reset_subject'),
|
|
282
|
+
content: {
|
|
283
|
+
emblem: 'key',
|
|
284
|
+
title: t('email_reset_title'),
|
|
285
|
+
body: t('email_reset_body'),
|
|
286
|
+
button: { label: t('email_reset_button') }
|
|
287
|
+
}
|
|
288
|
+
})
|
|
238
289
|
this.log('debug', 'RESET_SENT', email, req?.auth?.user?._id?.toString())
|
|
239
290
|
} catch (e) { // don't return an error to avoid signifying correct user/pass combinations
|
|
240
291
|
this.log('error', 'RESET_PASS_FAILED', e)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a mailer failure stems from being unable to reach the SMTP server
|
|
3
|
+
* (vs. a rejected/invalid message). MailerModule wraps the underlying transport
|
|
4
|
+
* error in err.data.error, so inspect that as well as the error itself.
|
|
5
|
+
* @param {Error} err Error thrown by the mailer
|
|
6
|
+
* @returns {Boolean}
|
|
7
|
+
* @memberof localauth
|
|
8
|
+
*/
|
|
9
|
+
const CONNECTION_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ECONNECTION', 'ETIMEDOUT', 'ESOCKET', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'EDNS']
|
|
10
|
+
|
|
11
|
+
export function isSmtpConnectionError (err) {
|
|
12
|
+
const cause = err?.data?.error ?? err
|
|
13
|
+
if (!cause) return false
|
|
14
|
+
if (CONNECTION_CODES.includes(cause.code)) return true
|
|
15
|
+
return typeof cause.message === 'string' && CONNECTION_CODES.some(code => cause.message.includes(code))
|
|
16
|
+
}
|
package/lib/utils.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-auth-local",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"adapt-authoring-jsonschema": "^1.2.0",
|
|
22
|
-
"adapt-authoring-mailer": "^1.0
|
|
22
|
+
"adapt-authoring-mailer": "^1.6.0",
|
|
23
23
|
"adapt-authoring-mongodb": "^3.0.0",
|
|
24
24
|
"adapt-authoring-roles": "^1.1.3",
|
|
25
25
|
"adapt-authoring-server": "^2.1.0",
|
package/routes.json
CHANGED
|
@@ -46,6 +46,7 @@ let disavowCalls = []
|
|
|
46
46
|
let secureRouteCalls = []
|
|
47
47
|
let unsecureRouteCalls = []
|
|
48
48
|
let mailerSendCalls = []
|
|
49
|
+
let mailerSendTemplatedCalls = []
|
|
49
50
|
|
|
50
51
|
const mockUsers = {
|
|
51
52
|
find: async (query) => usersStore.filter(u => {
|
|
@@ -72,7 +73,8 @@ const mockRoles = {
|
|
|
72
73
|
|
|
73
74
|
const mockMailer = {
|
|
74
75
|
isEnabled: true,
|
|
75
|
-
send: async (opts) => { mailerSendCalls.push(opts) }
|
|
76
|
+
send: async (opts) => { mailerSendCalls.push(opts) },
|
|
77
|
+
sendTemplated: async (opts) => { mailerSendTemplatedCalls.push(opts) }
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
const mockServer = {
|
|
@@ -114,6 +116,7 @@ const mockApp = {
|
|
|
114
116
|
if (names.length === 1) return moduleMap[names[0]]
|
|
115
117
|
return names.map(n => moduleMap[n])
|
|
116
118
|
},
|
|
119
|
+
config: { get: () => 'Adapt' },
|
|
117
120
|
lang: {
|
|
118
121
|
translate: (_, key) => 'translated:' + key
|
|
119
122
|
}
|
|
@@ -570,10 +573,20 @@ describe('LocalAuthModule', () => {
|
|
|
570
573
|
})
|
|
571
574
|
|
|
572
575
|
it('should send email notification after password update when mailer is enabled', async () => {
|
|
573
|
-
|
|
576
|
+
mailerSendTemplatedCalls = []
|
|
574
577
|
await mod.updateUser('user-id-1', { password: 'newpassword' })
|
|
575
|
-
assert.ok(
|
|
576
|
-
assert.equal(
|
|
578
|
+
assert.ok(mailerSendTemplatedCalls.length > 0)
|
|
579
|
+
assert.equal(mailerSendTemplatedCalls[0].to, 'test@example.com')
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('should send a templated password-updated email (check emblem, no button)', async () => {
|
|
583
|
+
mailerSendTemplatedCalls = []
|
|
584
|
+
await mod.updateUser('user-id-1', { password: 'newpassword' })
|
|
585
|
+
const sent = mailerSendTemplatedCalls[0]
|
|
586
|
+
assert.equal(sent.content.emblem, 'check')
|
|
587
|
+
assert.ok(sent.subject)
|
|
588
|
+
assert.ok(sent.content.title)
|
|
589
|
+
assert.equal(sent.content.button, undefined)
|
|
577
590
|
})
|
|
578
591
|
|
|
579
592
|
it('should pass useDefaults:false and ignoreRequired:true for non-password updates', async () => {
|
|
@@ -581,6 +594,31 @@ describe('LocalAuthModule', () => {
|
|
|
581
594
|
})
|
|
582
595
|
})
|
|
583
596
|
|
|
597
|
+
describe('#sendInvite()', () => {
|
|
598
|
+
it('should build invite content (spark emblem + set-password button) for createPasswordReset', async () => {
|
|
599
|
+
let captured
|
|
600
|
+
const original = mod.createPasswordReset.bind(mod)
|
|
601
|
+
mod.createPasswordReset = async (email, message) => { captured = { email, message } }
|
|
602
|
+
await mod.sendInvite('invite@example.com')
|
|
603
|
+
mod.createPasswordReset = original
|
|
604
|
+
assert.equal(captured.email, 'invite@example.com')
|
|
605
|
+
assert.equal(captured.message.content.emblem, 'spark')
|
|
606
|
+
assert.ok(captured.message.subject)
|
|
607
|
+
assert.ok(captured.message.content.button.label)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('should be a no-op when the mailer is disabled', async () => {
|
|
611
|
+
mockMailer.isEnabled = false
|
|
612
|
+
let called = false
|
|
613
|
+
const original = mod.createPasswordReset.bind(mod)
|
|
614
|
+
mod.createPasswordReset = async () => { called = true }
|
|
615
|
+
await mod.sendInvite('invite@example.com')
|
|
616
|
+
mod.createPasswordReset = original
|
|
617
|
+
mockMailer.isEnabled = true
|
|
618
|
+
assert.equal(called, false)
|
|
619
|
+
})
|
|
620
|
+
})
|
|
621
|
+
|
|
584
622
|
describe('#createPasswordReset()', () => {
|
|
585
623
|
it('should throw when email is not provided', async () => {
|
|
586
624
|
await assert.rejects(
|
|
@@ -648,7 +686,7 @@ describe('LocalAuthModule', () => {
|
|
|
648
686
|
it('should pass inviteTokenLifespan to createPasswordReset', async () => {
|
|
649
687
|
let receivedLifespan
|
|
650
688
|
const original = mod.createPasswordReset.bind(mod)
|
|
651
|
-
mod.createPasswordReset = async (email,
|
|
689
|
+
mod.createPasswordReset = async (email, message, lifespan) => {
|
|
652
690
|
receivedLifespan = lifespan
|
|
653
691
|
}
|
|
654
692
|
const req = {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { isSmtpConnectionError } from '../lib/utils/isSmtpConnectionError.js'
|
|
4
|
+
|
|
5
|
+
describe('isSmtpConnectionError()', () => {
|
|
6
|
+
const cases = [
|
|
7
|
+
['wrapped code (mailer err.data.error.code)', { data: { error: { code: 'ECONNREFUSED' } } }, true],
|
|
8
|
+
['direct code', { code: 'ETIMEDOUT' }, true],
|
|
9
|
+
['code embedded in message', { message: 'connect ECONNREFUSED 127.0.0.1:1025' }, true],
|
|
10
|
+
['wrapped code embedded in message', { data: { error: { message: 'getaddrinfo ENOTFOUND smtp.example.com' } } }, true],
|
|
11
|
+
['ESOCKET', { code: 'ESOCKET' }, true],
|
|
12
|
+
['rejected message, not a connection error', { code: 'EENVELOPE', message: 'Invalid recipient' }, false],
|
|
13
|
+
['generic error', new Error('something else'), false],
|
|
14
|
+
['null', null, false],
|
|
15
|
+
['undefined', undefined, false]
|
|
16
|
+
]
|
|
17
|
+
for (const [name, input, expected] of cases) {
|
|
18
|
+
it(`returns ${expected} for ${name}`, () => {
|
|
19
|
+
assert.equal(isSmtpConnectionError(input), expected)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
})
|