adapt-authoring-auth-local 2.4.1 → 2.5.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,35 @@ 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 subject = this.app.lang.translate(undefined, 'app.invitepasswordsubject')
117
+ const text = this.app.lang.translate(undefined, 'app.invitepasswordtext')
118
+ const html = this.app.lang.translate(undefined, 'app.invitepasswordhtml')
119
+ await this.createPasswordReset(email, subject, text, html, this.getConfig('inviteTokenLifespan'))
94
120
  }
95
121
 
96
122
  /**
@@ -174,7 +200,11 @@ class LocalAuthModule extends AbstractAuthModule {
174
200
  try {
175
201
  const [mailer, server] = await this.app.waitForModule('mailer', 'server')
176
202
  const token = await PasswordUtils.createReset(email, lifespan)
177
- const url = `${server.root.url}#user/reset?token=${token}&email=${email}`
203
+ // resetUrl supplies the path; host comes from the server.
204
+ const path = this.getConfig('resetUrl')
205
+ .replace(/{{token}}/g, token)
206
+ .replace(/{{email}}/g, email)
207
+ const url = `${server.root.url}${path}`
178
208
  await mailer.send({
179
209
  to: email,
180
210
  subject,
@@ -182,7 +212,12 @@ class LocalAuthModule extends AbstractAuthModule {
182
212
  html: htmlContent.replace(/{{url}}/g, url)
183
213
  })
184
214
  } catch (e) {
185
- this.log('error', `Failed to create user password reset, ${e}`)
215
+ const cause = e?.data?.error ?? e
216
+ if (isSmtpConnectionError(e)) {
217
+ 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}`)
218
+ } else {
219
+ this.log('error', `Failed to create user password reset: ${cause.message ?? cause}`)
220
+ }
186
221
  throw e
187
222
  }
188
223
  }
@@ -196,10 +231,7 @@ class LocalAuthModule extends AbstractAuthModule {
196
231
  async inviteHandler (req, res, next) {
197
232
  try {
198
233
  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'))
234
+ await this.sendInvite(email)
203
235
  this.log('debug', 'INVITE_SENT', email, req?.auth?.user?._id?.toString())
204
236
  } catch (e) {
205
237
  return next(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.5.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",
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
  }
@@ -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
+ })