adapt-authoring-auth-local 0.0.1

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/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ node_modules
package/.eslintrc ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "env": {
3
+ "browser": false,
4
+ "node": true,
5
+ "commonjs": false,
6
+ "es2020": true
7
+ },
8
+ "extends": [
9
+ "standard"
10
+ ],
11
+ "parserOptions": {
12
+ "ecmaVersion": 2020
13
+ }
14
+ }
@@ -0,0 +1,55 @@
1
+ name: Bug Report
2
+ description: File a bug report
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to fill out this bug report!
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: What happened?
13
+ description: Please describe the issue
14
+ validations:
15
+ required: true
16
+ - type: textarea
17
+ id: expected
18
+ attributes:
19
+ label: Expected behaviour
20
+ description: Tell us what should have happened
21
+ - type: textarea
22
+ id: repro-steps
23
+ attributes:
24
+ label: Steps to reproduce
25
+ description: Tell us how to reproduce the issue
26
+ validations:
27
+ required: true
28
+ - type: input
29
+ id: aat-version
30
+ attributes:
31
+ label: Authoring tool version
32
+ description: What version of the Adapt authoring tool are you running?
33
+ validations:
34
+ required: true
35
+ - type: input
36
+ id: fw-version
37
+ attributes:
38
+ label: Framework version
39
+ description: What version of the Adapt framework are you running?
40
+ - type: dropdown
41
+ id: browsers
42
+ attributes:
43
+ label: What browsers are you seeing the problem on?
44
+ multiple: true
45
+ options:
46
+ - Firefox
47
+ - Chrome
48
+ - Safari
49
+ - Microsoft Edge
50
+ - type: textarea
51
+ id: logs
52
+ attributes:
53
+ label: Relevant log output
54
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
55
+ render: sh
@@ -0,0 +1 @@
1
+ blank_issues_enabled: false
@@ -0,0 +1,22 @@
1
+ name: Feature request
2
+ description: Request a new feature
3
+ labels: ["enhancement"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to request a new feature in the Adapt authoring tool! The Adapt team will consider all new feature requests, but unfortunately cannot commit to every one.
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: Feature description
13
+ description: Please describe your feature request
14
+ validations:
15
+ required: true
16
+ - type: checkboxes
17
+ id: contribute
18
+ attributes:
19
+ label: Can you work on this feature?
20
+ description: If you are able to commit your own time to work on this feature, it will greatly increase the liklihood of it being implemented by the core dev team. Otherwise, it will be triaged and prioritised alongside the core team's current priorities.
21
+ options:
22
+ - label: I can contribute
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "npm" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,25 @@
1
+ [//]: # (Please title your PR according to eslint commit conventions)
2
+ [//]: # (See https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint#eslint-convention for details)
3
+
4
+ [//]: # (Add a link to the original issue)
5
+
6
+ [//]: # (Delete as appropriate)
7
+ ### Fix
8
+ * A sentence describing each fix
9
+
10
+ ### Update
11
+ * A sentence describing each udpate
12
+
13
+ ### New
14
+ * A sentence describing each new feature
15
+
16
+ ### Breaking
17
+ * A sentence describing each breaking change
18
+
19
+ [//]: # (List appropriate steps for testing if needed)
20
+ ### Testing
21
+ 1. Steps for testing
22
+
23
+ [//]: # (Mention any other dependencies)
24
+
25
+
@@ -0,0 +1,16 @@
1
+ name: Add labelled PRs to project
2
+
3
+ on:
4
+ pull_request:
5
+ types: [ labeled ]
6
+
7
+ jobs:
8
+ add-to-project:
9
+ if: ${{ github.event.label.name == 'dependencies' }}
10
+ name: Add to main project
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/add-to-project@v0.1.0
14
+ with:
15
+ project-url: https://github.com/orgs/adapt-security/projects/5
16
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,19 @@
1
+ name: Add to main project
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - opened
7
+ pull_request:
8
+ types:
9
+ - opened
10
+
11
+ jobs:
12
+ add-to-project:
13
+ name: Add to main project
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/add-to-project@v0.1.0
17
+ with:
18
+ project-url: https://github.com/orgs/adapt-security/projects/5
19
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,5 @@
1
+ {
2
+ "documentation": {
3
+ "enable": true
4
+ }
5
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "saltRounds": {
6
+ "description": "The number of rounds performed when generating a password hash",
7
+ "type": "number",
8
+ "default": 10
9
+ },
10
+ "failsUntilTemporaryLock": {
11
+ "description": "The number of failed login attempts allowed before the account is locked for a short period of time",
12
+ "type": "number",
13
+ "default": 5
14
+ },
15
+ "failsUntilPermanentLock": {
16
+ "description": "The number of failed login attempts allowed before the account is permanently locked",
17
+ "type": "number",
18
+ "default": 20
19
+ },
20
+ "temporaryLockDuration": {
21
+ "description": "The amount of time a locked user must wait before attempting to log in again",
22
+ "type": "string",
23
+ "isTimeMs": true,
24
+ "default": "1m"
25
+ },
26
+ "resetTokenLifespan": {
27
+ "description": "The amount of time a password reset token remains valid for",
28
+ "type": "string",
29
+ "isTimeMs": true,
30
+ "default": "24h"
31
+ },
32
+ "inviteTokenLifespan": {
33
+ "description": "The amount of time an invite password reset token remains valid for",
34
+ "type": "string",
35
+ "isTimeMs": true,
36
+ "default": "7d"
37
+ },
38
+ "minPasswordLength": {
39
+ "description": "Minimum password length",
40
+ "type": "number",
41
+ "default": 8,
42
+ "_adapt": {
43
+ "isPublic": true
44
+ }
45
+ },
46
+ "passwordMustHaveNumber": {
47
+ "description": "Whether the password must contain at least one number",
48
+ "type": "boolean",
49
+ "default": false,
50
+ "_adapt": {
51
+ "isPublic": true
52
+ }
53
+ },
54
+ "passwordMustHaveUppercase": {
55
+ "description": "Whether the password must contain at least one uppercase character",
56
+ "type": "boolean",
57
+ "default": false,
58
+ "_adapt": {
59
+ "isPublic": true
60
+ }
61
+ },
62
+ "passwordMustHaveLowercase": {
63
+ "description": "Whether the password must contain at least one lowercase character",
64
+ "type": "boolean",
65
+ "default": false,
66
+ "_adapt": {
67
+ "isPublic": true
68
+ }
69
+ },
70
+ "passwordMustHaveSpecial": {
71
+ "description": "Whether the password must contain at least one special character",
72
+ "type": "boolean",
73
+ "default": false,
74
+ "_adapt": {
75
+ "isPublic": true
76
+ }
77
+ },
78
+ "blacklistedPasswordValues": {
79
+ "description": "Values which cannot be used in passwords",
80
+ "type": "array",
81
+ "default": []
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "ACCOUNT_LOCKED_PERM": {
3
+ "description": "User account has been permanently locked",
4
+ "statusCode": 401
5
+ },
6
+ "ACCOUNT_LOCKED_TEMP": {
7
+ "data": {
8
+ "remaining": "The amount of time remaining before account is unlocked"
9
+ },
10
+ "description": "User account has been temporarily locked",
11
+ "statusCode": 401
12
+ },
13
+ "ACCOUNT_NOT_LOCALAUTHD": {
14
+ "description": "Specified account is not authenticated with local auth",
15
+ "statusCode": 400
16
+ },
17
+ "BLACKLISTED_PASSWORD_VALUE": {
18
+ "description": "Password contains a blacklisted value",
19
+ "statusCode": 400
20
+ },
21
+ "INCORRECT_PASSWORD": {
22
+ "description": "Provided password does not match that stored",
23
+ "statusCode": 401
24
+ },
25
+ "INVALID_PASSWORD": {
26
+ "data": {
27
+ "errors": "The validation errors"
28
+ },
29
+ "description": "Password failed validation",
30
+ "statusCode": 400
31
+ },
32
+ "INVALID_PASSWORD_LENGTH": {
33
+ "data": {
34
+ "length": "the minimum required number of characters"
35
+ },
36
+ "description": "Password must be at least the required number of characters",
37
+ "statusCode": 400
38
+ },
39
+ "INVALID_PASSWORD_UPPERCASE": {
40
+ "description": "Password must contain at least one uppercase character",
41
+ "statusCode": 400
42
+ },
43
+ "INVALID_PASSWORD_LOWERCASE": {
44
+ "description": "Password must contain at least one lowercase character",
45
+ "statusCode": 400
46
+ },
47
+ "INVALID_PASSWORD_NUMBER": {
48
+ "description": "Password must contain at least one number",
49
+ "statusCode": 400
50
+ },
51
+ "INVALID_PASSWORD_SPECIAL": {
52
+ "description": "Password must contain at least one special character (#?!@$%^&*-)",
53
+ "statusCode": 400
54
+ },
55
+ "SUPER_USER_EXISTS": {
56
+ "description": "A Super Admin account already exists",
57
+ "statusCode": 400
58
+ }
59
+ }
package/index.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Local (username/password) authentication
3
+ * @namespace localauth
4
+ */
5
+ export { default } from './lib/LocalAuthModule.js'
6
+ export { default as PasswordUtils } from './lib/PasswordUtils.js'
@@ -0,0 +1,331 @@
1
+ import _ from 'lodash'
2
+ import { AbstractAuthModule } from 'adapt-authoring-auth'
3
+ import apidefs from './apidefs.js'
4
+ import { formatDistanceToNowStrict as toNow } from 'date-fns'
5
+ import PasswordUtils from './PasswordUtils.js'
6
+ /**
7
+ * Module which implements username/password (local) authentication
8
+ * @memberof localauth
9
+ * @extends {AbstractAuthModule}
10
+ */
11
+ class LocalAuthModule extends AbstractAuthModule {
12
+ /**
13
+ * Returns a human-readable string to denote how many seconds are remaining
14
+ * @param {Number} secs The remaining seconds
15
+ */
16
+ static formatRemainingTime (secs) {
17
+ return toNow(Date.now() + (secs * 1000))
18
+ }
19
+
20
+ /** @override */
21
+ async setValues () {
22
+ /** @ignore */ this.userSchema = 'localauthuser'
23
+ /** @ignore */ this.type = 'local'
24
+ /** @ignore */ this.routes = [
25
+ {
26
+ route: '/invite',
27
+ handlers: { post: this.inviteHandler.bind(this) },
28
+ meta: apidefs.invite
29
+ }, {
30
+ route: '/registersuper',
31
+ internal: true,
32
+ handlers: { post: this.registerSuperHandler.bind(this) },
33
+ meta: apidefs.registersuper
34
+ }, {
35
+ route: '/changepass',
36
+ handlers: { post: this.changePasswordHandler.bind(this) },
37
+ meta: apidefs.changepass
38
+ }, {
39
+ route: '/forgotpass',
40
+ handlers: { post: this.forgotPasswordHandler.bind(this) },
41
+ meta: apidefs.forgotpass
42
+ }, {
43
+ route: '/validatepass',
44
+ handlers: { post: this.validatePasswordHandler.bind(this) },
45
+ meta: apidefs.validatepass
46
+ }
47
+ ]
48
+ }
49
+
50
+ /** @override */
51
+ async init () {
52
+ await super.init()
53
+ this.secureRoute('/invite', 'post', ['register:users'])
54
+ this.secureRoute('/validatepass', 'post', ['read:me'])
55
+ this.unsecureRoute('/registersuper', 'post')
56
+ this.unsecureRoute('/changepass', 'post')
57
+ this.unsecureRoute('/forgotpass', 'post')
58
+ // add API metadata
59
+ this.router.routes.find(r => r.route === '/').meta = apidefs.root
60
+ this.router.routes.find(r => r.route === '/register').meta = apidefs.register
61
+
62
+ const users = await this.app.waitForModule('users')
63
+ /**
64
+ * Local reference to the current UsersModule instance for convenience
65
+ * @type {UsersModule}
66
+ */
67
+ this.users = users
68
+ }
69
+
70
+ /** @override */
71
+ async authenticate (user, req, res) {
72
+ if (!req.body.password) {
73
+ throw this.app.errors.INVALID_LOGIN_DETAILS
74
+ }
75
+ const isTempLockTimeout = user.isTempLocked && (new Date(user.lastFailedLoginAttempt).getTime() + this.getConfig('temporaryLockDuration') - Date.now()) > 0
76
+ let failedLoginAttempts = user.failedLoginAttempts
77
+ let lastFailedLoginAttempt
78
+ let error
79
+ try {
80
+ await PasswordUtils.compare(req.body.password, user.password)
81
+ } catch (e) {
82
+ if (!user.isPermLocked && !isTempLockTimeout) { // only update failed login data when account isn't locked
83
+ failedLoginAttempts += 1
84
+ lastFailedLoginAttempt = new Date().toISOString()
85
+ }
86
+ error = e
87
+ }
88
+ const isPermLocked = user.isPermLocked || failedLoginAttempts >= this.getConfig('failsUntilPermanentLock')
89
+ const isTempLocked = isTempLockTimeout || (failedLoginAttempts > 0 && (failedLoginAttempts % this.getConfig('failsUntilTemporaryLock') === 0))
90
+
91
+ if (!error && !isPermLocked && !isTempLocked) {
92
+ failedLoginAttempts = 0
93
+ }
94
+ if (user) {
95
+ await this.updateUser(user._id, { isPermLocked, isTempLocked, lastFailedLoginAttempt, failedLoginAttempts })
96
+ }
97
+ if (isPermLocked) throw this.app.errors.ACCOUNT_LOCKED_PERM
98
+ if (isTempLocked) throw this.app.errors.ACCOUNT_LOCKED_TEMP
99
+ if (error) throw error
100
+ }
101
+
102
+ /**
103
+ * Checks if the user account is currently locked, and unlocks a temporarily locked account if appropriate
104
+ * @param {external:ExpressRequest} req
105
+ * @param {Object} user The current user
106
+ */
107
+ async handleLockStatus (user) {
108
+ const tempLockEndTime = new Date(user.lastFailedLoginAttempt).getTime() + this.getConfig('temporaryLockDuration') * 1000
109
+ const tempLockRemainingSecs = Math.round((tempLockEndTime - Date.now()) / 1000)
110
+
111
+ if (user.isPermLocked) {
112
+ throw this.app.errors.ACCOUNT_LOCKED_PERM
113
+ }
114
+ if (user.isTempLocked) {
115
+ if (tempLockRemainingSecs > 0) {
116
+ throw this.app.errors.ACCOUNT_LOCKED_TEMP
117
+ .setData({ remaining: LocalAuthModule.formatRemainingTime(tempLockRemainingSecs) })
118
+ }
119
+ await this.updateUser(user._id, { isTempLocked: false })
120
+ }
121
+ }
122
+
123
+ /** @override */
124
+ async register (data) {
125
+ await PasswordUtils.validate(data.password)
126
+ return super.register({ ...data, password: await PasswordUtils.generate(data.password) })
127
+ }
128
+
129
+ /**
130
+ * Register a new super user
131
+ * @param {Object} data
132
+ */
133
+ async registerSuper (data) {
134
+ const [roles, users] = await this.app.waitForModule('roles', 'users')
135
+ const [superRole] = await roles.find({ shortName: 'superuser' })
136
+ const superUsers = await users.find({ roles: [superRole._id] })
137
+ if (superUsers.length) {
138
+ throw this.app.errors.SUPER_USER_EXISTS
139
+ }
140
+ await this.register({
141
+ email: data.email,
142
+ password: data.password,
143
+ firstName: 'Super',
144
+ lastName: 'User',
145
+ roles: [superRole._id.toString()]
146
+ })
147
+ }
148
+
149
+ /** @override */
150
+ async setUserEnabled (user, isEnabled) {
151
+ await super.setUserEnabled(user, isEnabled)
152
+ await this.users.update({ _id: user._id }, {
153
+ failedLoginAttempts: isEnabled ? 0 : user.failedAttempts,
154
+ isPermLocked: !isEnabled,
155
+ isTempLocked: !isEnabled
156
+ }, {
157
+ schemaName: this.userSchema
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Updates a single user
163
+ * @param {external:ExpressRequest} req
164
+ * @param {String|ObjectId|Object} userIdOrQuery Accepts a user _id or a query object
165
+ * @param {Object} updateData JSON data to use for update
166
+ * @return {Promise}
167
+ */
168
+ async updateUser (userIdOrQuery, updateData) {
169
+ const isId = _.isString(userIdOrQuery) || (userIdOrQuery.constructor && userIdOrQuery.constructor.name === 'ObjectId')
170
+ const query = isId ? { _id: userIdOrQuery } : userIdOrQuery
171
+
172
+ if (!Object.prototype.hasOwnProperty.call(updateData, 'password')) {
173
+ return this.users.update(query, updateData, { schemaName: this.userSchema, useDefaults: false, ignoreRequired: true })
174
+ }
175
+ await PasswordUtils.validate(updateData.password)
176
+ updateData.password = await PasswordUtils.generate(updateData.password)
177
+ // password updates required special process
178
+ const [mailer, mongodb] = await this.app.waitForModule('mailer', 'mongodb')
179
+ const user = await mongodb.update(this.users.collectionName, query, { $set: updateData })
180
+
181
+ await this.disavowUser({ userId: user._id, authType: this.type })
182
+
183
+ const subject = this.app.lang.translate(undefined, 'app.updateusersubject')
184
+ const text = this.app.lang.translate(undefined, 'app.updateusertext')
185
+ const html = this.app.lang.translate(undefined, 'app.updateuserhtml')
186
+ try {
187
+ if(mailer.isEnabled) await mailer.send({ to: user.email, subject, text, html })
188
+ } catch(e) {
189
+ this.log('error', e)
190
+ }
191
+
192
+ return user
193
+ }
194
+
195
+ /**
196
+ * Creates a new password reset token and sends an email
197
+ * @param {String} email
198
+ * @param {String} subject
199
+ * @param {String} textContent
200
+ * @param {String} htmlContent
201
+ * @param {Number} lifespan The lifespan of the reset
202
+ */
203
+ async createPasswordReset (email, subject, textContent, htmlContent, lifespan) {
204
+ if (!email) {
205
+ throw this.app.errors.INVALID_PARAMS.setData({ params: ['email'] })
206
+ }
207
+ try {
208
+ const [mailer, server] = await this.app.waitForModule('mailer', 'server')
209
+ const token = await PasswordUtils.createReset(email, lifespan)
210
+ const url = `${server.root.url}#user/reset?token=${token}&email=${email}`
211
+ await mailer.send({
212
+ to: email,
213
+ subject,
214
+ text: textContent.replace(/{{url}}/g, url),
215
+ html: htmlContent.replace(/{{url}}/g, url)
216
+ })
217
+ } catch (e) {
218
+ this.log('error', `Failed to create user password reset, ${e}`)
219
+ throw e
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Handles inviting a new user to the system
225
+ * @param {external:ExpressRequest} req
226
+ * @param {external:ExpressResponse} res
227
+ * @param {Function} next
228
+ */
229
+ async inviteHandler (req, res, next) {
230
+ try {
231
+ const { email } = req.body
232
+ const subject = req.translate('app.invitepasswordsubject')
233
+ const text = req.translate('app.invitepasswordtext')
234
+ const html = req.translate('app.invitepasswordhtml')
235
+ await this.createPasswordReset(email, subject, text, html, this.getConfig('inviteTokenLifespan'))
236
+ this.log('debug', 'INVITE_SENT', email, req?.auth?.user?._id?.toString())
237
+ } catch (e) {
238
+ return next(e)
239
+ }
240
+ res.sendStatus(204)
241
+ }
242
+
243
+ /**
244
+ * Registers a Super User. This is restricted to localhost, and can only be used to create the first Super User.
245
+ * @param {external:ExpressRequest} req
246
+ * @param {external:ExpressResponse} res
247
+ * @param {Function} next
248
+ */
249
+ async registerSuperHandler (req, res, next) {
250
+ try {
251
+ await this.registerSuper(req.body)
252
+ res.sendStatus(204)
253
+ } catch (e) {
254
+ next(e)
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Handles sending a user password reset
260
+ * @param {external:ExpressRequest} req
261
+ * @param {external:ExpressResponse} res
262
+ * @param {Function} next
263
+ */
264
+ async forgotPasswordHandler (req, res, next) {
265
+ try {
266
+ const { email } = req.body
267
+ const subject = req.translate('app.forgotpasswordsubject')
268
+ const text = req.translate('app.forgotpasswordtext')
269
+ const html = req.translate('app.forgotpasswordhtml')
270
+ await this.createPasswordReset(email, subject, text, html)
271
+ this.log('debug', 'RESET_SENT', email, req?.auth?.user?._id?.toString())
272
+ } catch (e) { // don't return an error to avoid signifying correct user/pass combinations
273
+ this.log('error', 'RESET_PASS_FAILED', e)
274
+ }
275
+ res.status(200).json({ message: req.translate('app.forgotpasswordmessage') })
276
+ }
277
+
278
+ /**
279
+ * Handles changing a user password. If no auth is given, a reset token must be present
280
+ * @param {external:ExpressRequest} req
281
+ * @param {external:ExpressResponse} res
282
+ * @param {Function} next
283
+ */
284
+ async changePasswordHandler (req, res, next) {
285
+ let email
286
+ try {
287
+ if (req.auth.token) { // already authenticated, so can use auth data
288
+ if (req.auth.token.type !== this.type) throw new Error()
289
+ // allow for a specific email to be passed via body, falling back to the email from the auth data
290
+ email = req.body.email || req.auth.user.email
291
+ // validate the existing password for security
292
+ const [user] = await this.users.find({ email });
293
+ await PasswordUtils.compare(req.body.oldPassword, user.password);
294
+ } else { // no authenticated, so should expect body data
295
+ const tokenData = await PasswordUtils.validateReset(req.body.token)
296
+ email = tokenData.email
297
+ }
298
+ if (!email) throw new Error()
299
+
300
+ const { _id } = await this.updateUser({ email }, { password: req.body.password })
301
+
302
+ if (!req.auth.token) {
303
+ await PasswordUtils.deleteReset(req.body.token)
304
+ }
305
+ await this.disavowUser({ userId: _id, signature: req.auth?.token?.signature })
306
+ this.log('debug', 'CHANGE_PASS', _id, req?.auth?.user?._id?.toString())
307
+ res.status(204).end()
308
+ } catch (e) {
309
+ if (email) this.log('debug', 'CHANGE_PASS_FAILED', email)
310
+ return next(e)
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Handles changing a user password. If no auth is given, a reset token must be present
316
+ * @param {external:ExpressRequest} req
317
+ * @param {external:ExpressResponse} res
318
+ * @param {Function} next
319
+ */
320
+ async validatePasswordHandler (req, res, next) {
321
+ try {
322
+ await PasswordUtils.validate(req.body.password)
323
+ res.json({ message: req.translate('app.passwordindicatorstrong') })
324
+ } catch (e) {
325
+ e.data.errors = e.data.errors.map(req.translate).join(', ')
326
+ res.sendError(e)
327
+ }
328
+ }
329
+ }
330
+
331
+ export default LocalAuthModule
@@ -0,0 +1,173 @@
1
+ import { App } from 'adapt-authoring-core'
2
+ import bcrypt from 'bcryptjs'
3
+ import crypto from 'crypto'
4
+ import { promisify } from 'util'
5
+
6
+ /** @ignore */ const passwordResetsCollectionName = 'passwordresets'
7
+ /**
8
+ * Various utilities related to password functionality
9
+ * @memberof localauth
10
+ */
11
+ class PasswordUtils {
12
+ /**
13
+ * Retrieves a localauth config item
14
+ * @return {Promise}
15
+ */
16
+ static async getConfig (...keys) {
17
+ const authlocal = await App.instance.waitForModule('auth-local')
18
+
19
+ if (keys.length === 1) {
20
+ return authlocal.getConfig(keys[0])
21
+ }
22
+ return keys.reduce((m, k) => {
23
+ m[k] = authlocal.getConfig(k)
24
+ return m
25
+ }, {})
26
+ }
27
+
28
+ /**
29
+ * Compares a plain password to a hash
30
+ * @param {String} plainPassword
31
+ * @param {String} hash
32
+ * @return {Promise}
33
+ */
34
+ static async compare (plainPassword, hash) {
35
+ const error = App.instance.errors.INVALID_LOGIN_DETAILS
36
+ if (!plainPassword || !hash) {
37
+ throw error.setData({
38
+ error: App.instance.errors.INVALID_PARAMS.setData({ params: ['plainPassword', 'hash'] })
39
+ })
40
+ }
41
+ try {
42
+ const isValid = await promisify(bcrypt.compare)(plainPassword, hash)
43
+ if (!isValid) throw new Error()
44
+ } catch (e) {
45
+ throw error.setData({ error: App.instance.errors.INCORRECT_PASSWORD })
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Validates a password against the stored config settings
51
+ * @param {String} password Password to validate
52
+ * @returns {Promise} Resolves if the password passes the validation
53
+ */
54
+ static async validate (password) {
55
+ const authlocal = await App.instance.waitForModule('auth-local')
56
+ if (typeof password !== 'string') {
57
+ throw App.instance.errors.INVALID_PARAMS.setData({ params: ['password'] })
58
+ }
59
+ const blacklisted = authlocal.getConfig('blacklistedPasswordValues')
60
+ const match = (key, re) => !authlocal.getConfig(key) || password.search(re) > -1
61
+ const validationChecks = {
62
+ INVALID_PASSWORD_LENGTH: [password.length >= authlocal.getConfig('minPasswordLength'), { length: authlocal.getConfig('minPasswordLength') }],
63
+ INVALID_PASSWORD_NUMBER: [match('passwordMustHaveNumber', /[0-9]/)],
64
+ INVALID_PASSWORD_UPPERCASE: [match('passwordMustHaveUppercase', /[A-Z]/)],
65
+ INVALID_PASSWORD_LOWERCASE: [match('passwordMustHaveLowercase', /[a-z]/)],
66
+ INVALID_PASSWORD_SPECIAL: [match('passwordMustHaveSpecial', /[#?!@$%^&*-]/)],
67
+ BLACKLISTED_PASSWORD_VALUE: [blacklisted.length === 0 || blacklisted.some(p => !(password.includes(p)))]
68
+ }
69
+ const errors = Object.entries(validationChecks).reduce((m, [code, [isValid, data]]) => {
70
+ if (!isValid) m.push(App.instance.errors[code].setData(data))
71
+ return m
72
+ }, [])
73
+ if (errors.length) throw App.instance.errors.INVALID_PASSWORD.setData({ errors })
74
+ }
75
+
76
+ /**
77
+ * Generates a secure hash from a plain-text password
78
+ * @param {String} plainPassword
79
+ * @return {Promise} Resolves with the hash
80
+ */
81
+ static async generate (plainPassword) {
82
+ if (!plainPassword) {
83
+ throw App.instance.errors.INVALID_PARAMS.setData({ params: ['plainPassword'] })
84
+ }
85
+ const jsonschema = await App.instance.waitForModule('jsonschema')
86
+ const schema = await jsonschema.getSchema('localpassword')
87
+ await schema.validate({ password: plainPassword })
88
+
89
+ const saltRounds = await PasswordUtils.getConfig('saltRounds')
90
+ const salt = await promisify(bcrypt.genSalt)(saltRounds)
91
+
92
+ return promisify(bcrypt.hash)(plainPassword, salt)
93
+ }
94
+
95
+ /**
96
+ * Creates a password reset token
97
+ * @param {String} email The user's email address
98
+ * @param {Number} lifespan The intended token lifespan in milliseconds
99
+ * @return {Promise} Resolves with the token value
100
+ */
101
+ static async createReset (email, lifespan) {
102
+ const [mongodb, users] = await App.instance.waitForModule('mongodb', 'users')
103
+ const [user] = await users.find({ email })
104
+ if (!user) {
105
+ throw App.instance.errors.NOT_FOUND
106
+ .setData({ type: 'user', id: email })
107
+ }
108
+ if (user.authType !== 'local') {
109
+ const authlocal = await App.instance.waitForModule('auth-local')
110
+ authlocal.log('error', `Failed to reset ${user._id} password, not authenticated with local auth`)
111
+ throw App.instance.errors.ACCOUNT_NOT_LOCALAUTHD
112
+ }
113
+ // invalidate any previous tokens for this user
114
+ await mongodb.getCollection(passwordResetsCollectionName).deleteMany({ email })
115
+
116
+ if (!lifespan) {
117
+ lifespan = await this.getConfig('resetTokenLifespan')
118
+ }
119
+ const { token } = await mongodb.insert(passwordResetsCollectionName, {
120
+ email,
121
+ expiresAt: new Date(Date.now() + lifespan).toISOString(),
122
+ token: await this.getRandomHex()
123
+ })
124
+ return token
125
+ }
126
+
127
+ /**
128
+ * Deletes a stored password reset token
129
+ * @param {String} token The token value
130
+ * @return {Promise}
131
+ */
132
+ static async deleteReset (token) {
133
+ const mongodb = await App.instance.waitForModule('mongodb')
134
+ return mongodb.delete(passwordResetsCollectionName, { token })
135
+ }
136
+
137
+ /**
138
+ * Creates a random hex string
139
+ * @param {Number} size Size of string
140
+ * @return {Promise} Resolves with the string value
141
+ */
142
+ static async getRandomHex (size = 32) {
143
+ const buffer = await promisify(crypto.randomBytes)(size)
144
+ return buffer.toString('hex')
145
+ }
146
+
147
+ /**
148
+ * Validates a password reset token
149
+ * @param {String} token The password reset token
150
+ * @return {Promise} Rejects on invalid token
151
+ */
152
+ static async validateReset (token) {
153
+ if (!token) {
154
+ throw App.instance.errors.INVALID_PARAMS.setData({ params: ['token'] })
155
+ }
156
+ const [mongodb, users] = await App.instance.waitForModule('mongodb', 'users')
157
+ const [tokenData] = await mongodb.find(passwordResetsCollectionName, { token })
158
+ if (!tokenData) {
159
+ throw App.instance.errors.AUTH_TOKEN_INVALID
160
+ }
161
+ if (new Date(tokenData.expiresAt) < new Date()) {
162
+ throw App.instance.errors.AUTH_TOKEN_EXPIRED
163
+ }
164
+ const [user] = await users.find({ email: tokenData.email })
165
+ if (!user) {
166
+ throw App.instance.errors.NOT_FOUND
167
+ .setData({ type: 'user', id: token.email })
168
+ }
169
+ return tokenData
170
+ }
171
+ }
172
+
173
+ export default PasswordUtils
package/lib/apidefs.js ADDED
@@ -0,0 +1,195 @@
1
+ export default {
2
+ changepass: {
3
+ post: {
4
+ summary: 'Change the password of a user',
5
+ description: 'Can be used with or without authentication. If authenticated, an email/password combination will be acepted. If unauthenticated, a valid reset token and password must be specified.',
6
+ requestBody: {
7
+ content: {
8
+ 'application/json': {
9
+ schema: {
10
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
11
+ type: 'object',
12
+ properties: {
13
+ email: { type: 'string' },
14
+ password: { type: 'string' },
15
+ token: { type: 'string' }
16
+ }
17
+ }
18
+ }
19
+ }
20
+ },
21
+ responses: { 204: {} }
22
+ }
23
+ },
24
+ forgotpass: {
25
+ post: {
26
+ summary: 'Trigger a password reset',
27
+ description: 'Generates a password reset and emails this to the user with instructions on updating their password.',
28
+ requestBody: {
29
+ content: {
30
+ 'application/json': {
31
+ schema: {
32
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
33
+ type: 'object',
34
+ properties: {
35
+ email: { type: 'string' }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ },
41
+ responses: {
42
+ 200: {
43
+ content: {
44
+ 'application/json': {
45
+ schema: {
46
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
47
+ type: 'object',
48
+ properties: {
49
+ message: { type: 'string' }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ },
58
+ invite: {
59
+ post: {
60
+ summary: 'Invite a new user',
61
+ requestBody: {
62
+ content: {
63
+ 'application/json': {
64
+ schema: {
65
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
66
+ type: 'object',
67
+ properties: {
68
+ email: { type: 'string' }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ },
74
+ responses: { 204: {} }
75
+ }
76
+ },
77
+ register: {
78
+ post: {
79
+ summary: 'Register a new user',
80
+ requestBody: {
81
+ content: {
82
+ 'application/json': {
83
+ schema: {
84
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
85
+ type: 'object',
86
+ properties: {
87
+ email: { type: 'string', required: true },
88
+ firstName: { type: 'string', required: true },
89
+ lastName: { type: 'string', required: true },
90
+ password: { type: 'string', required: true },
91
+ roles: {
92
+ type: 'array',
93
+ items: { type: 'string' }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ },
100
+ responses: {
101
+ 200: {
102
+ content: {
103
+ 'application/json': {
104
+ schema: { $ref: '#components/schemas/localauthuser' }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ },
111
+ registersuper: {
112
+ post: {
113
+ summary: 'Register a new super user',
114
+ description: 'Only one user can be registered in this way, and if a super user already exists the request will fail.',
115
+ requestBody: {
116
+ content: {
117
+ 'application/json': {
118
+ schema: {
119
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
120
+ type: 'object',
121
+ properties: {
122
+ email: { type: 'string', required: true },
123
+ password: { type: 'string', required: true }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ },
129
+ responses: {
130
+ 200: {
131
+ content: {
132
+ 'application/json': {
133
+ schema: { $ref: '#components/schemas/localauthuser' }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+ },
140
+ root: {
141
+ post: {
142
+ summary: 'Authenticate with the API',
143
+ requestBody: {
144
+ content: {
145
+ 'application/json': {
146
+ schema: {
147
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
148
+ type: 'object',
149
+ properties: {
150
+ email: { type: 'string', required: true },
151
+ password: { type: 'string', required: true },
152
+ persistSession: { type: 'boolean' }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ },
158
+ responses: { 204: {} }
159
+ }
160
+ },
161
+ validatepass: {
162
+ post: {
163
+ summary: 'Validate password',
164
+ description: 'Checks that a password passes the required complexity specified in the application\'s configuration settings.',
165
+ requestBody: {
166
+ content: {
167
+ 'application/json': {
168
+ schema: {
169
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
170
+ type: 'object',
171
+ properties: {
172
+ password: { type: 'string', required: true }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ },
178
+ responses: {
179
+ 200: {
180
+ content: {
181
+ 'application/json': {
182
+ schema: {
183
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
184
+ type: 'object',
185
+ properties: {
186
+ message: { type: 'string' }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "adapt-authoring-auth-local",
3
+ "version": "0.0.1",
4
+ "description": "Module which implements username/password (local) authentication",
5
+ "homepage": "https://github.com/adapt-security/adapt-authoring-auth-local",
6
+ "license": "GPL-3.0",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "repository": "github:adapt-security/adapt-authoring-auth-local",
10
+ "dependencies": {
11
+ "bcryptjs": "3.0.2",
12
+ "date-fns": "^4.1.0",
13
+ "lodash": "^4.17.21"
14
+ },
15
+ "peerDependencies": {
16
+ "adapt-authoring-auth": "github:adapt-security/adapt-authoring-auth",
17
+ "adapt-authoring-core": "github:adapt-security/adapt-authoring-core",
18
+ "adapt-authoring-sessions": "github:adapt-security/adapt-authoring-sessions"
19
+ },
20
+ "devDependencies": {
21
+ "eslint": "^9.11.0",
22
+ "standard": "^17.1.0"
23
+ }
24
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$anchor": "localauthuser",
4
+ "description": "Local authentication user",
5
+ "$merge": {
6
+ "source": { "$ref": "user" },
7
+ "with": {
8
+ "properties": {
9
+ "isTempLocked": {
10
+ "description": "Whether the user account has been temporarily locked",
11
+ "type": "boolean",
12
+ "default": false,
13
+ "isReadOnly": true
14
+ },
15
+ "isPermLocked": {
16
+ "description": "Whether the user account has been permanently locked",
17
+ "type": "boolean",
18
+ "default": false,
19
+ "isReadOnly": true
20
+ },
21
+ "password": {
22
+ "description": "Password for the user",
23
+ "type": "string",
24
+ "isInternal": true
25
+ },
26
+ "failedLoginAttempts": {
27
+ "description": "The number of failed login attempts",
28
+ "type": "number",
29
+ "default": 0
30
+ },
31
+ "lastFailedLoginAttempt": {
32
+ "description": "Timestamp of the last failed login attempt",
33
+ "type": "string",
34
+ "format": "date-time",
35
+ "isDate": true,
36
+ "isReadOnly": true
37
+ }
38
+ },
39
+ "required": ["password"]
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$anchor": "localpassword",
4
+ "description": "Local authentication password",
5
+ "properties": {
6
+ "password": {
7
+ "description": "Password value",
8
+ "type": "string",
9
+ "password": "password"
10
+ }
11
+ },
12
+ "required": ["password"]
13
+ }