adapt-authoring-auth-local 2.4.0 → 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.
- package/.github/workflows/releases.yml +2 -1
- package/conf/config.schema.json +5 -0
- package/lib/LocalAuthModule.js +40 -8
- 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/utils-isSmtpConnectionError.spec.js +22 -0
|
@@ -10,7 +10,8 @@ permissions:
|
|
|
10
10
|
issues: write
|
|
11
11
|
pull-requests: write
|
|
12
12
|
id-token: write
|
|
13
|
+
packages: write
|
|
13
14
|
|
|
14
15
|
jobs:
|
|
15
16
|
release:
|
|
16
|
-
uses: adaptlearning/semantic-release-config/.github/workflows/release.yml@master
|
|
17
|
+
uses: adaptlearning/semantic-release-config/.github/workflows/release.yml@master
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-auth-local",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"repository": "github:adapt-security/adapt-authoring-auth-local",
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"adapt-authoring-auth": "^2.0.0",
|
|
15
|
-
"adapt-authoring-core": "^
|
|
15
|
+
"adapt-authoring-core": "^3.0.0",
|
|
16
16
|
"bcryptjs": "3.0.3",
|
|
17
17
|
"date-fns": "^4.1.0",
|
|
18
18
|
"lodash": "^4.17.21"
|
package/routes.json
CHANGED
|
@@ -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
|
+
})
|