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 +1 -0
- package/.eslintrc +14 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +55 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +22 -0
- package/.github/dependabot.yml +11 -0
- package/.github/pull_request_template.md +25 -0
- package/.github/workflows/labelled_prs.yml +16 -0
- package/.github/workflows/new.yml +19 -0
- package/adapt-authoring.json +5 -0
- package/conf/config.schema.json +84 -0
- package/errors/errors.json +59 -0
- package/index.js +6 -0
- package/lib/LocalAuthModule.js +331 -0
- package/lib/PasswordUtils.js +173 -0
- package/lib/apidefs.js +195 -0
- package/package.json +24 -0
- package/schema/localauthuser.schema.json +42 -0
- package/schema/localpassword.schema.json +13 -0
package/.eslintignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
node_modules
|
package/.eslintrc
ADDED
|
@@ -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,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,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
|
+
}
|