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.
@@ -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",
@@ -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
- return super.register({ ...data, password: await PasswordUtils.generate(data.password) })
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 subject = this.app.lang.translate(undefined, 'app.updateusersubject')
151
- const text = this.app.lang.translate(undefined, 'app.updateusertext')
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) await mailer.send({ to: user.email, subject, text, html })
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 an email
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 {String} subject
166
- * @param {String} textContent
167
- * @param {String} htmlContent
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, textContent, htmlContent, lifespan) {
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
- const url = `${server.root.url}#user/reset?token=${token}&email=${email}`
178
- await mailer.send({
179
- to: email,
180
- subject,
181
- text: textContent.replace(/{{url}}/g, url),
182
- html: htmlContent.replace(/{{url}}/g, url)
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
- this.log('error', `Failed to create user password reset, ${e}`)
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
- const subject = req.translate('app.invitepasswordsubject')
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 subject = req.translate('app.forgotpasswordsubject')
235
- const text = req.translate('app.forgotpasswordtext')
236
- const html = req.translate('app.forgotpasswordhtml')
237
- await this.createPasswordReset(email, subject, text, html)
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
@@ -1,3 +1,4 @@
1
1
  export { compare } from './utils/compare.js'
2
2
  export { getRandomHex } from './utils/getRandomHex.js'
3
+ export { isSmtpConnectionError } from './utils/isSmtpConnectionError.js'
3
4
  export { validate } from './utils/validate.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-auth-local",
3
- "version": "2.4.1",
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.2",
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
@@ -141,6 +141,7 @@
141
141
  "type": "object",
142
142
  "properties": {
143
143
  "email": { "type": "string" },
144
+ "oldPassword": { "type": "string" },
144
145
  "password": { "type": "string" },
145
146
  "token": { "type": "string" }
146
147
  }
@@ -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
- mailerSendCalls = []
576
+ mailerSendTemplatedCalls = []
574
577
  await mod.updateUser('user-id-1', { password: 'newpassword' })
575
- assert.ok(mailerSendCalls.length > 0)
576
- assert.equal(mailerSendCalls[0].to, 'test@example.com')
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, subject, text, html, lifespan) => {
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
+ })