@ti-engine/web-framework 1.21.0 → 1.23.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/CHANGELOG.md +56 -0
- package/README.md +52 -0
- package/bin/build/hash-password.js +36 -0
- package/bin/config/local-users.example.json +8 -0
- package/bin/localization/web-server-labels.json +4 -0
- package/bin/static/fragments/frame-login.html +6 -1
- package/bin/static/scripts/ti-framework.css +4 -0
- package/bin/static/scripts/ti-framework.js +23 -0
- package/bin/web-server.js +18 -1
- package/bin/web-server.json +3 -0
- package/components/auth-manager.js +159 -18
- package/components/local-user-directory.js +428 -0
- package/components/web-config-env.js +6 -1
- package/components/web-handlers.js +10 -2
- package/package.json +11 -2
- package/types/bin/web-server.d.ts +19 -1
- package/types/components/auth-manager.d.ts +7 -0
- package/types/components/local-user-directory.d.ts +111 -0
- package/types/components/web-config-env.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
This document will contain the list of changes made to the framework. The format is based on the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
|
4
4
|
|
|
5
|
+
## Version 1.23.0
|
|
6
|
+
|
|
7
|
+
Local (username/password) authentication is real. It had never been implemented: the constructor overwrote whatever
|
|
8
|
+
was configured with `admin`/`admin` behind a "for testing purposes only" TODO, the check was a plain `===` on both
|
|
9
|
+
fields, and the session user it produced carried no email — which since `competence` began resolving identity by
|
|
10
|
+
email meant a local sign-in could not reach an application at all.
|
|
11
|
+
|
|
12
|
+
* feat(local-user-directory): new `#local-user-directory` module — a JSON file of user records loaded on boot and
|
|
13
|
+
reconciled into Redis under `ti:web:auth:local-users`. Records carry `username`, `email`, `name` and a
|
|
14
|
+
`passwordHash`; `email` is required, because it is the field a consuming application resolves an identity by. The
|
|
15
|
+
file is the source of truth: a boot reconcile adds, updates and **removes**, so deleting a user revokes access
|
|
16
|
+
* feat(local-user-directory): scrypt password hashing via `node:crypto` — no new dependency — with a per-user random
|
|
17
|
+
salt and the cost parameters recorded in each hash, so they can be raised later without invalidating existing
|
|
18
|
+
hashes. Verification is timing-safe, and an unknown username still performs a hash computation so the login form
|
|
19
|
+
is not a username-enumeration oracle
|
|
20
|
+
* feat(auth-manager)!: **the hardcoded `admin`/`admin` pair is gone.** Local sign-ins are verified against the
|
|
21
|
+
directory, and `authorize()` returns a `User` carrying `userID`, `username`, `email` and `name` instead of a
|
|
22
|
+
random-UUID stub. Any deployment relying on the hardcoded credentials must provision a users file
|
|
23
|
+
* fix(auth-manager): a local user's `userID` is stable across logins. It was a fresh UUID each time, so
|
|
24
|
+
`auth.admins` could never match a local user by userID — only by username
|
|
25
|
+
* feat(build): `npm run hash-password` generates a record's hash, reading the password from **stdin** rather than
|
|
26
|
+
argv, which would put it in shell history and in `ps`
|
|
27
|
+
* feat(web-config-env): `TI_WEB_AUTH_LOCAL_USERS_PATH` overrides `auth.local.usersPath`
|
|
28
|
+
* fix(auth-manager): close a fail-open found by the whole-branch review — `#authenticateLocal` and `authorize()`
|
|
29
|
+
consulted the Redis-backed directory directly, so a users file that failed to load (missing, unreadable, or
|
|
30
|
+
unconfigured) still authenticated against records reconciled by an **earlier successful boot**, even while
|
|
31
|
+
logging that every local sign-in would be refused. A new `#localDirectoryUsable` flag is required by both
|
|
32
|
+
before any lookup, and is set only after a load that reconciled at least one record. `authorize()` also now
|
|
33
|
+
refuses a `disabled` record on its own, rather than relying on `authenticate()` having already been called
|
|
34
|
+
* fix(auth-manager): a Redis error during directory reconcile is now logged with only its `message`/`code` —
|
|
35
|
+
the raw ioredis error carries the failed command's full arguments, which for this call includes every user's
|
|
36
|
+
salt and scrypt hash, so logging it verbatim printed the entire directory's credential material at WARNING level
|
|
37
|
+
* build(release): bump package version from `1.22.0` to `1.23.0`
|
|
38
|
+
|
|
39
|
+
**Not included, and required before `local` is the sole method on an internet-facing deployment:** rate limiting,
|
|
40
|
+
lockout after repeated failures, and password policy.
|
|
41
|
+
|
|
42
|
+
## Version 1.22.0
|
|
43
|
+
|
|
44
|
+
An application may now refuse a sign-in from its `augmentSession` hook, and that refusal is genuinely fail-closed.
|
|
45
|
+
Sign-in failures also present identically across every auth method, which local (username/password) auth needs before
|
|
46
|
+
it can be offered as a production option.
|
|
47
|
+
|
|
48
|
+
* fix(web-handlers): destroy the session when an augment hook throws. `session.user` is assigned in place before the
|
|
49
|
+
hook runs and `verifySession` only checks that it exists, so a merely-rejected session was still persisted by
|
|
50
|
+
express-session at response end and would have admitted the refused user
|
|
51
|
+
* feat(web-server): document the `augmentSession` refusal contract — throwing refuses the login, destroys the session,
|
|
52
|
+
and redirects to the login page carrying the exception code
|
|
53
|
+
* feat(web-handlers): redirect any HTML-accepting, non-HTMX **401** to `/?error=<code>`, not only `GET` requests, so a
|
|
54
|
+
local-auth POST failure presents exactly like an OAuth callback failure. Non-401 responses are unaffected
|
|
55
|
+
* feat(web-app): render the sign-in failure message on the login page. `#ti-error` was an empty element and the
|
|
56
|
+
`getUrlParam` helper had no call sites, so a failed sign-in previously returned a blank login form
|
|
57
|
+
* feat(exports): expose the authorization helpers as `@ti-engine/web-framework/authorization`, so an application can
|
|
58
|
+
reuse `isAdminIdentity` for its own allowlist decisions instead of reimplementing the match
|
|
59
|
+
* build(release): bump package version from `1.21.0` to `1.22.0`
|
|
60
|
+
|
|
5
61
|
## Version 1.21.0
|
|
6
62
|
|
|
7
63
|
Two read-only screens the framework now provides for every consumer: **Profile** — which has been a registered
|
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ The web server configuration (host, port, TLS, cookies, etc.) is normally provid
|
|
|
18
18
|
* `TI_WEB_TLS_CERT_PATH` / `TI_WEB_TLS_KEY_PATH` override the TLS certificate/key paths (only used when TLS is enabled).
|
|
19
19
|
* `TI_WEB_COOKIE_SECRET` sets the session cookie signing secret. Set a stable, private value for durable sessions and multi-replica deployments (otherwise a random per-process value is used).
|
|
20
20
|
* `TI_WEB_AUTH_METHODS` (comma-separated) **replaces** the enabled authentication methods (`auth.enabledMethods`), e.g. `openid-google` or `local,openid-google`.
|
|
21
|
+
* `TI_WEB_AUTH_LOCAL_USERS_PATH` overrides the local user directory's file path (`auth.local.usersPath`), which backs `local` sign-in. An explicitly empty value means *no directory*, so every local sign-in is refused. See [Local (username/password) authentication](#local-usernamepassword-authentication).
|
|
21
22
|
* `TI_WEB_AUTH_ADMINS` (comma-separated) **replaces** the admin allowlist (`auth.admins`). Entries are matched against the session user's user ID, username or email, so an OpenID deployment lists emails. An explicitly empty value means *no admins*.
|
|
22
23
|
* `TI_WEB_TRUSTED_ORIGINS` (comma-separated) **replaces** the trusted request origins (`trustedOrigins`) — needed behind proxies that do not present the real external origin.
|
|
23
24
|
* `TI_WEB_STATIC_MAX_AGE` (whole seconds) overrides `staticCache.maxAge`. See [Static asset caching](#static-asset-caching).
|
|
@@ -26,6 +27,57 @@ The web server configuration (host, port, TLS, cookies, etc.) is normally provid
|
|
|
26
27
|
|
|
27
28
|
OpenID Connect providers are configured with their own variables — `TI_AZURE_AUTH_CLIENT_ID` / `TI_AZURE_AUTH_CLIENT_SECRET` / `TI_AZURE_AUTH_CALLBACK_URL` / `TI_AZURE_AUTH_DISCOVERY_URL`, and the `TI_GCLOUD_AUTH_*` equivalents. A callback URL may be given either as the full absolute URL registered with the provider (`https://your-host/login/azure-callback`) or as a path (`/login/azure-callback`): the server always listens on the path, while the `redirect_uri` sent to the provider is the absolute value verbatim if one was configured, and otherwise assembled from the request's forwarded protocol/host.
|
|
28
29
|
|
|
30
|
+
## Authentication and authorization
|
|
31
|
+
|
|
32
|
+
`TiWebServer#augmentSession` is the hook through which an application derives its own session roles — from an identity store, the org chart, or wherever a deployment keeps that mapping — once per login, before the framework's own additive `admin` role (`auth.admins`, see [Environment variables](#environment-variables)) is applied on top; the default is a no-op that returns the session unchanged. Throwing from the hook refuses the sign-in rather than admitting a session the application could not map to a principal: the framework destroys the freshly regenerated session so nothing usable survives the refusal, the login handler responds `401`, and the error handler sends the browser back to the login page with the exception code in the `?error=` query parameter — the same path a failed OpenID callback takes, regardless of which auth method was used.
|
|
33
|
+
|
|
34
|
+
## Local (username/password) authentication
|
|
35
|
+
|
|
36
|
+
`local` is one of the configurable `auth.enabledMethods` sign-in methods (see [Environment variables](#environment-variables), `TI_WEB_AUTH_METHODS`). It is backed by a JSON file of user records — there is no built-in account of any kind.
|
|
37
|
+
|
|
38
|
+
### The users file
|
|
39
|
+
|
|
40
|
+
`auth.local.usersPath` (override: `TI_WEB_AUTH_LOCAL_USERS_PATH`) points at a JSON file holding an array of records:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
"username": "jdoe",
|
|
46
|
+
"email": "jane.doe@example.com",
|
|
47
|
+
"name": "Jane Doe",
|
|
48
|
+
"passwordHash": "scrypt$16384$8$1$<salt-base64>$<hash-base64>"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
* `username`, `email`, `name` and `passwordHash` are required. `email` is required because a consuming application resolves the signed-in identity by it, the same way it would for an OpenID identity — a record with no email cannot reach an application at all.
|
|
54
|
+
* `userID` is optional. When omitted, one is derived from the username, so it stays stable across restarts and logins; supply it explicitly only when something else needs to match a specific value (e.g. `auth.admins`).
|
|
55
|
+
* `disabled: true` keeps the record (and its username) in the file while refusing every sign-in for it.
|
|
56
|
+
|
|
57
|
+
Generate `passwordHash` with the bundled CLI. It reads the password from **stdin**, never an argument, so it never lands in shell history or a process listing (`ps`), and it never echoes the password back:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm run hash-password -w @ti-engine/web-framework
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Type or pipe the password, then EOF; only the resulting hash is written to stdout.
|
|
64
|
+
|
|
65
|
+
That `npm run` form only works inside this monorepo (it is a workspace script). A consumer of the published `@ti-engine/web-framework` package has no `bin` entry to run it by name — the script ships under `bin/build/` regardless, so invoke it by its path inside `node_modules` instead:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
node ./node_modules/@ti-engine/web-framework/bin/build/hash-password.js
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### The file is the source of truth
|
|
72
|
+
|
|
73
|
+
On every boot, the file is read and reconciled into the running directory: an added record starts working, a changed `passwordHash` takes effect, and — this is the point — **a record removed from the file is removed from the directory**, revoking that user's access on the next restart. Editing the file and restarting is the whole revocation mechanism; there is no separate delete action.
|
|
74
|
+
|
|
75
|
+
### Every failure refuses rather than admits
|
|
76
|
+
|
|
77
|
+
`local` enabled with no `auth.local.usersPath` configured, a file that cannot be read, a file that is not valid JSON, or a file that yields zero valid records after validation — each of these logs a startup **WARNING** and refuses every local sign-in, rather than admitting one or falling back to a default. A failed *read* deliberately does not reconcile, so a temporarily broken volume mount leaves previously stored records untouched instead of wiping them; those records stay inert (unused) while the load keeps failing, because sign-ins are refused anyway.
|
|
78
|
+
|
|
79
|
+
**There is no rate limiting, no lockout after repeated failures, and no password policy.** Treat `local` on an internet-facing deployment as a deliberate risk until those exist.
|
|
80
|
+
|
|
29
81
|
## Static asset caching
|
|
30
82
|
|
|
31
83
|
Everything under `/static` is served with a `Cache-Control` policy configured by the `staticCache` block:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
+
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
+
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generates a password hash for a local-users file entry.
|
|
13
|
+
*
|
|
14
|
+
* Usage: npm run hash-password -w @ti-engine/web-framework
|
|
15
|
+
*
|
|
16
|
+
* The password is read from stdin, never from an argument: an argv value lands in shell history and is visible to
|
|
17
|
+
* every other user on the machine through `ps`. Only the resulting hash is written to stdout — the password itself
|
|
18
|
+
* is never echoed, logged, or written to a file by this tool.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const directory = require( "#local-user-directory" );
|
|
22
|
+
|
|
23
|
+
let input = "";
|
|
24
|
+
process.stdin.setEncoding( "utf8" );
|
|
25
|
+
process.stdin.on( "data", ( chunk ) => {
|
|
26
|
+
input += chunk;
|
|
27
|
+
} );
|
|
28
|
+
process.stdin.on( "end", () => {
|
|
29
|
+
// Strip only the trailing newline a shell or editor adds; a password may legitimately contain spaces.
|
|
30
|
+
const password = input.replace( /\r?\n$/, "" );
|
|
31
|
+
if ( password.length === 0 ) {
|
|
32
|
+
process.stderr.write( "hash-password: no password on stdin\n" );
|
|
33
|
+
process.exit( 1 );
|
|
34
|
+
}
|
|
35
|
+
process.stdout.write( directory.hashPassword( password ) + "\n" );
|
|
36
|
+
} );
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
"interface": {
|
|
3
3
|
"default": {
|
|
4
4
|
"login": {
|
|
5
|
+
"error-sign-in-failed": {
|
|
6
|
+
"en": "We couldn't sign you in. Check your credentials, or contact your administrator if your account is not set up for this application.",
|
|
7
|
+
"bg": "Неуспешно влизане. Проверете данните си за достъп или се свържете с администратора, ако акаунтът ви не е настроен за това приложение."
|
|
8
|
+
},
|
|
5
9
|
"password": {
|
|
6
10
|
"en": "Password",
|
|
7
11
|
"bg": "Парола"
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
<!-- Login card -->
|
|
13
13
|
<div class="ti-login-card">
|
|
14
14
|
<!-- Error message -->
|
|
15
|
-
<div id="ti-error"
|
|
15
|
+
<div id="ti-error"
|
|
16
|
+
class="ti-login-error"
|
|
17
|
+
x-data="tiLoginError"
|
|
18
|
+
x-bind:class="{ visible: hasError }"
|
|
19
|
+
role="alert"
|
|
20
|
+
x-text-label="interface.default.login.error-sign-in-failed"></div>
|
|
16
21
|
|
|
17
22
|
<!--ti-auth-method:local-->
|
|
18
23
|
<!-- Local auth form -->
|
|
@@ -1615,6 +1615,28 @@ const configureScreenAbout = () => {
|
|
|
1615
1615
|
};
|
|
1616
1616
|
};
|
|
1617
1617
|
|
|
1618
|
+
/**
|
|
1619
|
+
* Returns a configuration object for the login screen error message.
|
|
1620
|
+
* <br/>
|
|
1621
|
+
* The login handlers answer a failed sign-in with a `303` to `/?error=<code>` (see `defaultErrorHandler`). This reads
|
|
1622
|
+
* that parameter and reveals the message element; the copy itself stays declarative via `x-text-label` so it localizes
|
|
1623
|
+
* through the normal path. Deliberately independent of the throwaway test-user panel so it survives that panel's removal.
|
|
1624
|
+
*
|
|
1625
|
+
* @method
|
|
1626
|
+
* @returns {Object}
|
|
1627
|
+
* @public
|
|
1628
|
+
*/
|
|
1629
|
+
const configureLoginError = () => {
|
|
1630
|
+
return {
|
|
1631
|
+
hasError: false,
|
|
1632
|
+
|
|
1633
|
+
init() {
|
|
1634
|
+
const tiToolbox = Alpine.store( "tiToolbox" );
|
|
1635
|
+
this.hasError = Boolean( tiToolbox.getUrlParam( "error" ) );
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1618
1640
|
/**
|
|
1619
1641
|
* Returns a configuration object for the login screen test user pill panel.
|
|
1620
1642
|
* <br/>
|
|
@@ -1738,6 +1760,7 @@ document.addEventListener( "alpine:init", () => {
|
|
|
1738
1760
|
Alpine.data( "tiComponentNotificationBar", configureComponentNotificationBar );
|
|
1739
1761
|
Alpine.data( "tiComponentTooltip", configureComponentTooltip );
|
|
1740
1762
|
Alpine.data( "tiLoginTestUserPanel", configureLoginTestUserPanel );
|
|
1763
|
+
Alpine.data( "tiLoginError", configureLoginError );
|
|
1741
1764
|
Alpine.data( "tiScreenProfile", configureScreenProfile );
|
|
1742
1765
|
Alpine.data( "tiScreenAbout", configureScreenAbout );
|
|
1743
1766
|
} );
|
package/bin/web-server.js
CHANGED
|
@@ -60,12 +60,18 @@ const applyWebConfigEnvOverrides = require( "#web-config-env" );
|
|
|
60
60
|
/**
|
|
61
61
|
* @typedef {Object} SettingsAuth
|
|
62
62
|
* @property {string[]} enabledMethods
|
|
63
|
-
* @property {
|
|
63
|
+
* @property {SettingsAuthLocal} local
|
|
64
64
|
* @property {Object} oauth2
|
|
65
65
|
* @property {SettingsOAuth2Client} [oauth2.azure]
|
|
66
66
|
* @property {SettingsOAuth2Client} [oauth2.google]
|
|
67
67
|
*/
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {Object} SettingsAuthLocal
|
|
71
|
+
* @property {string} [usersPath] Path to the JSON file of local user records (see `TI_WEB_AUTH_LOCAL_USERS_PATH`).
|
|
72
|
+
* Local sign-in refuses everyone whenever this is absent, unreadable, or yields no usable records.
|
|
73
|
+
*/
|
|
74
|
+
|
|
69
75
|
/**
|
|
70
76
|
* @typedef {Object} SettingsOAuth2Client
|
|
71
77
|
* @property {string} [clientID]
|
|
@@ -430,6 +436,11 @@ class TiWebServer extends ServiceConsumer {
|
|
|
430
436
|
* Hook for the application to augment the freshly-authenticated session (e.g. derive domain roles from an
|
|
431
437
|
* identity store or the org chart). Runs synchronously, once per login, before the framework's additive `admin`
|
|
432
438
|
* role is applied. The default is a no-op. Any test-user role injection is an override of whatever the app derives.
|
|
439
|
+
* <br/>
|
|
440
|
+
* **Refusing a login.** Throwing from this hook refuses the sign-in: the framework destroys the freshly regenerated
|
|
441
|
+
* session (so no usable session survives the refusal), the login handler raises `401`, and the error handler
|
|
442
|
+
* redirects the browser to the login page with the exception code in `?error=`. Throw when the authenticated
|
|
443
|
+
* identity cannot be mapped to an application principal; return the session unchanged to accept it.
|
|
433
444
|
*
|
|
434
445
|
* @method
|
|
435
446
|
* @virtual
|
|
@@ -457,6 +468,12 @@ class TiWebServer extends ServiceConsumer {
|
|
|
457
468
|
|
|
458
469
|
/**
|
|
459
470
|
* Used to set up user authorization according to the specified auth method.
|
|
471
|
+
* <br/>
|
|
472
|
+
* NOTE: This presupposes a successful, immediately preceding {@link TiWebServer#authenticate} call for the same
|
|
473
|
+
* credentials — it is **not** an independent authentication check. For the `local` method it builds the session
|
|
474
|
+
* user from the directory record named by `oidc.username`, verifying that the record exists and is not disabled
|
|
475
|
+
* but performing no password comparison of its own; the framework's own login route calls `authenticate` first.
|
|
476
|
+
* Calling this directly without that preceding step would mint a session for any known username.
|
|
460
477
|
*
|
|
461
478
|
* @method
|
|
462
479
|
* @param {TiAuthMethod} authMethod
|
package/bin/web-server.json
CHANGED
|
@@ -10,8 +10,10 @@ const tools = require( "@ti-engine/core/tools" );
|
|
|
10
10
|
const logger = require( "@ti-engine/core/logger" );
|
|
11
11
|
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
12
12
|
const { randomBytes } = require( "node:crypto" );
|
|
13
|
+
const fs = require( "node:fs" );
|
|
13
14
|
const openidClient = require( "openid-client" );
|
|
14
15
|
const User = require( "#user" );
|
|
16
|
+
const localUserDirectory = require( "#local-user-directory" );
|
|
15
17
|
|
|
16
18
|
/** @import { SettingsAuth } from "#web-server" */
|
|
17
19
|
|
|
@@ -54,14 +56,48 @@ class AuthManager {
|
|
|
54
56
|
#authSettings = {
|
|
55
57
|
enabledMethods: [],
|
|
56
58
|
local: {
|
|
57
|
-
|
|
58
|
-
password: undefined
|
|
59
|
+
usersPath: undefined
|
|
59
60
|
},
|
|
60
61
|
oauth2: {}
|
|
61
62
|
};
|
|
62
63
|
#clientConfigOAuth2Google = {};
|
|
63
64
|
#clientConfigOAuth2Azure = {};
|
|
64
65
|
|
|
66
|
+
// Whether the local user directory is genuinely usable: set true only after #loadLocalUserDirectory performs
|
|
67
|
+
// a successful reconcile that produced at least one record. Every other outcome — no 'usersPath' configured,
|
|
68
|
+
// an unreadable/unparseable file, a file that reconciles to zero records, or a Redis failure during reconcile
|
|
69
|
+
// — leaves this false. #authenticateLocal and authorize() both require it before consulting the directory,
|
|
70
|
+
// because localUserDirectory.findByUsername reads Redis directly: without this flag, records reconciled by
|
|
71
|
+
// an EARLIER successful boot would remain live and would still authenticate even though the CURRENT boot's
|
|
72
|
+
// log already told the operator "every local sign-in will be refused". A failed load deliberately still does
|
|
73
|
+
// not erase those stale Redis records (see #loadLocalUserDirectory's own doc comment) — this flag is what
|
|
74
|
+
// makes them inert instead of merely unmentioned.
|
|
75
|
+
#localDirectoryUsable = false;
|
|
76
|
+
|
|
77
|
+
// A fixed, valid encoding used only to spend comparable time on an unknown or disabled username. It corresponds
|
|
78
|
+
// to no usable password: the key material is random, so nothing can ever verify against it.
|
|
79
|
+
//
|
|
80
|
+
// It is ASSEMBLED, not hashed. `verifyPassword` reads N/r/p and the key length out of the encoded string and
|
|
81
|
+
// then derives asynchronously off the main thread, so the decoy only has to be *decodable* — the derive that
|
|
82
|
+
// equalizes the timing happens inside `verifyPassword` either way, at parameters identical to a real record's
|
|
83
|
+
// because they come from the same HASH_DEFAULTS. Calling `hashPassword` here would additionally run a
|
|
84
|
+
// ~100 ms blocking `scryptSync`, and it bought nothing: an earlier version paid that at class load (so every
|
|
85
|
+
// instance paid it, including one with 'local' disabled entirely — competence's shipped Azure-only image),
|
|
86
|
+
// and making it lazy only moved the same blocking cost onto the first refused login, i.e. onto a request path.
|
|
87
|
+
// Assembling it costs a few random bytes, so it can be eager again without the lazy-getter machinery.
|
|
88
|
+
//
|
|
89
|
+
// It MUST remain decodable: if `decodeHash` ever rejected it, `verifyPassword` would return false immediately
|
|
90
|
+
// without deriving, and the timing-equalization this exists for would silently stop working. A test asserts
|
|
91
|
+
// the decoy still round-trips through the directory's own validation.
|
|
92
|
+
static #timingDecoyHash = [
|
|
93
|
+
localUserDirectory.ALGORITHM,
|
|
94
|
+
localUserDirectory.HASH_DEFAULTS.N,
|
|
95
|
+
localUserDirectory.HASH_DEFAULTS.r,
|
|
96
|
+
localUserDirectory.HASH_DEFAULTS.p,
|
|
97
|
+
randomBytes( localUserDirectory.HASH_DEFAULTS.saltBytes ).toString( "base64" ),
|
|
98
|
+
randomBytes( localUserDirectory.HASH_DEFAULTS.keyBytes ).toString( "base64" )
|
|
99
|
+
].join( "$" );
|
|
100
|
+
|
|
65
101
|
/**
|
|
66
102
|
* @constructor
|
|
67
103
|
* @param {SettingsAuth} settings
|
|
@@ -71,14 +107,6 @@ class AuthManager {
|
|
|
71
107
|
this.#authSettings = settings;
|
|
72
108
|
}
|
|
73
109
|
|
|
74
|
-
// Set up local authentication configuration:
|
|
75
|
-
if ( this.isAuthEnabled( authMethodEnum.LOCAL ) ) {
|
|
76
|
-
// TODO: For testing purposes only! Implement real local auth later!
|
|
77
|
-
this.#authSettings.local = this.#authSettings.local || {};
|
|
78
|
-
this.#authSettings.local.username = "admin";
|
|
79
|
-
this.#authSettings.local.password = "admin";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
110
|
// Set up OAuth2 configuration:
|
|
83
111
|
this.#authSettings.oauth2 = this.#authSettings.oauth2 || {};
|
|
84
112
|
if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
|
|
@@ -113,6 +141,9 @@ class AuthManager {
|
|
|
113
141
|
this.#dropUnconfiguredOpenIDProviders();
|
|
114
142
|
|
|
115
143
|
let promises = [];
|
|
144
|
+
if ( this.isAuthEnabled( authMethodEnum.LOCAL ) ) {
|
|
145
|
+
promises.push( this.#loadLocalUserDirectory() );
|
|
146
|
+
}
|
|
116
147
|
if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
|
|
117
148
|
promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.google ).then( ( configuration ) => {
|
|
118
149
|
this.#clientConfigOAuth2Google = configuration;
|
|
@@ -186,6 +217,13 @@ class AuthManager {
|
|
|
186
217
|
|
|
187
218
|
/**
|
|
188
219
|
* Used to set up user authorization according to the specified authentication method.
|
|
220
|
+
* <br/>
|
|
221
|
+
* NOTE: This presupposes a successful, immediately preceding {@link AuthManager#authenticate} call for the
|
|
222
|
+
* same credentials and is NOT an independent authentication check on its own — for `LOCAL` it performs no
|
|
223
|
+
* password verification. It refuses an absent, disabled, or (for `LOCAL`) not-yet-usable-directory record,
|
|
224
|
+
* but a caller that invokes it without having just authenticated bypasses password verification entirely.
|
|
225
|
+
* The framework's own login route always calls `authenticate()` first (see `web-handlers.js`); this method
|
|
226
|
+
* is public on both `AuthManager` and `TiWebServer`, so any other caller must preserve that ordering itself.
|
|
189
227
|
*
|
|
190
228
|
* @method
|
|
191
229
|
* @param {TiAuthMethod} authMethod
|
|
@@ -198,7 +236,27 @@ class AuthManager {
|
|
|
198
236
|
authorize( authMethod, currentUrl, oidc ) {
|
|
199
237
|
switch ( authMethod ) {
|
|
200
238
|
case authMethodEnum.LOCAL:
|
|
201
|
-
|
|
239
|
+
// Requires the same #localDirectoryUsable flag #authenticateLocal requires — see its declaration
|
|
240
|
+
// — so a stale Redis-backed record from an earlier successful boot cannot mint a session User
|
|
241
|
+
// merely because authorize() looks the username up independently of #authenticateLocal.
|
|
242
|
+
if ( !this.#localDirectoryUsable ) {
|
|
243
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
|
|
244
|
+
}
|
|
245
|
+
return localUserDirectory.findByUsername( oidc.username ).then( ( record ) => {
|
|
246
|
+
// A disabled record must be refused here too, not only by #authenticateLocal: the two
|
|
247
|
+
// lookups are independent reads of the same Redis-backed directory, and a reconcile that
|
|
248
|
+
// flips 'disabled' between them would otherwise let authorize() admit what authenticate()
|
|
249
|
+
// had just refused (or vice versa).
|
|
250
|
+
if ( !record || record.disabled === true ) {
|
|
251
|
+
throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 );
|
|
252
|
+
}
|
|
253
|
+
return new User( {
|
|
254
|
+
userID: record.userID,
|
|
255
|
+
username: record.username,
|
|
256
|
+
email: record.email,
|
|
257
|
+
name: record.name
|
|
258
|
+
} );
|
|
259
|
+
} );
|
|
202
260
|
case authMethodEnum.OPENID_GOOGLE:
|
|
203
261
|
return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Google );
|
|
204
262
|
case authMethodEnum.OPENID_AZURE:
|
|
@@ -355,7 +413,83 @@ class AuthManager {
|
|
|
355
413
|
}
|
|
356
414
|
|
|
357
415
|
/**
|
|
358
|
-
*
|
|
416
|
+
* Loads the configured local users file and reconciles it into the directory. Every failure path leaves the
|
|
417
|
+
* directory unusable and logs why, so local authentication refuses rather than admits — the same fail-soft
|
|
418
|
+
* stance as {@link AuthManager#dropUnconfiguredOpenIDProviders}: a bad local-users file must not take down an
|
|
419
|
+
* instance whose other auth method works, and must not let anyone in either.
|
|
420
|
+
* <br/>
|
|
421
|
+
* "Unusable" is not just a log line: {@link AuthManager#localDirectoryUsable} is the flag that actually makes
|
|
422
|
+
* it so. `localUserDirectory.findByUsername` reads Redis directly, so without this flag a record reconciled
|
|
423
|
+
* by an EARLIER successful boot would remain live — and would still authenticate — even on a boot where this
|
|
424
|
+
* method logs that every local sign-in will be refused. The flag defaults to `false` and is set `true` only
|
|
425
|
+
* at the very end of a successful reconcile that yielded at least one record; every failure path below
|
|
426
|
+
* returns (or rejects) without ever setting it, so it stays `false`.
|
|
427
|
+
* <br/>
|
|
428
|
+
* A failed read deliberately does NOT reconcile, so a broken volume mount leaves the stored records untouched
|
|
429
|
+
* instead of destroying them. They are inert while the load is failing — not because they are gone, but
|
|
430
|
+
* because {@link AuthManager#localDirectoryUsable} stays `false` and #authenticateLocal/authorize() both
|
|
431
|
+
* require it before ever consulting the directory.
|
|
432
|
+
*
|
|
433
|
+
* @method
|
|
434
|
+
* @returns {Promise}
|
|
435
|
+
*/
|
|
436
|
+
#loadLocalUserDirectory() {
|
|
437
|
+
const usersPath = this.#authSettings.local?.usersPath;
|
|
438
|
+
if ( !usersPath ) {
|
|
439
|
+
this.#localDirectoryUsable = false;
|
|
440
|
+
logger.log( "Local authentication is enabled but no 'auth.local.usersPath' is configured (see TI_WEB_AUTH_LOCAL_USERS_PATH) — every local sign-in will be refused.", logger.logSeverity.WARNING );
|
|
441
|
+
return Promise.resolve();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let raw;
|
|
445
|
+
try {
|
|
446
|
+
raw = JSON.parse( fs.readFileSync( usersPath, "utf8" ) );
|
|
447
|
+
} catch ( error ) {
|
|
448
|
+
this.#localDirectoryUsable = false;
|
|
449
|
+
logger.log( `Could not read the local users file '${ usersPath }' — every local sign-in will be refused. Previously stored records are left untouched.`, logger.logSeverity.WARNING, exceptions.raise( error ) );
|
|
450
|
+
return Promise.resolve();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const parsed = localUserDirectory.parseRecords( raw );
|
|
454
|
+
parsed.problems.forEach( ( problem ) => {
|
|
455
|
+
logger.log( `Local users file '${ usersPath }': ${ problem }`, logger.logSeverity.WARNING );
|
|
456
|
+
} );
|
|
457
|
+
if ( parsed.records.length === 0 ) {
|
|
458
|
+
logger.log( `The local users file '${ usersPath }' yielded no usable records — every local sign-in will be refused.`, logger.logSeverity.WARNING );
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return localUserDirectory.reconcile( parsed.records ).then( ( result ) => {
|
|
462
|
+
// Usable only when the reconcile actually produced at least one record — a file that parses cleanly
|
|
463
|
+
// but yields zero valid records (logged above) must refuse just as completely as one that could not
|
|
464
|
+
// be read at all.
|
|
465
|
+
this.#localDirectoryUsable = parsed.records.length > 0;
|
|
466
|
+
logger.log( `Local user directory reconciled: ${ result.added.length } added, ${ result.updated.length } updated, ${ result.removed.length } removed.`, logger.logSeverity.NOTICE );
|
|
467
|
+
} ).catch( ( error ) => {
|
|
468
|
+
this.#localDirectoryUsable = false;
|
|
469
|
+
// Log only the error's message and code — never the raw error object or an exception wrapping it.
|
|
470
|
+
// ioredis attaches `err.command = { name, args }` to reply errors and connection aborts, and
|
|
471
|
+
// `tools.errorToJSON` (invoked when the logger's data argument is an Error, including one wrapped by
|
|
472
|
+
// exceptions.raise) copies every own property, `command` included. For this call `args` is
|
|
473
|
+
// `[ "JSON.SET", localUserDirectory.CACHE_KEY, "$", <the entire directory JSON> ]`, so passing the
|
|
474
|
+
// raw error through here would print every local user's salt and scrypt hash at WARNING level on a
|
|
475
|
+
// WRONGTYPE, OOM, ACL failure, or mid-command disconnect. Do not "simplify" this back to
|
|
476
|
+
// `exceptions.raise( error )` or `error` directly.
|
|
477
|
+
logger.log( "Could not reconcile the local user directory — every local sign-in will be refused.", logger.logSeverity.WARNING, { message: error?.message, code: error?.code } );
|
|
478
|
+
} );
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Verifies a local sign-in against the user directory.
|
|
483
|
+
* <br/>
|
|
484
|
+
* An unknown username still performs a hash computation against a placeholder before failing, so a missing user
|
|
485
|
+
* and a wrong password take comparable time. Without it the response time answers "does this username exist?",
|
|
486
|
+
* which turns the login form into an enumeration oracle.
|
|
487
|
+
* <br/>
|
|
488
|
+
* Requires {@link AuthManager#localDirectoryUsable} in addition to {@link AuthManager#isAuthEnabled} before
|
|
489
|
+
* ever calling `findByUsername` — that function reads Redis directly, so without this check a record
|
|
490
|
+
* reconciled by an earlier successful boot would still authenticate on a boot whose own load just failed.
|
|
491
|
+
* This check is a boot-time configuration gate, not a per-request secret, so it refuses immediately rather
|
|
492
|
+
* than through the timing-decoy path below.
|
|
359
493
|
*
|
|
360
494
|
* @method
|
|
361
495
|
* @param {string} username
|
|
@@ -363,13 +497,20 @@ class AuthManager {
|
|
|
363
497
|
* @returns {Promise}
|
|
364
498
|
*/
|
|
365
499
|
#authenticateLocal( username, password ) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
500
|
+
if ( !this.isAuthEnabled( authMethodEnum.LOCAL ) || !this.#localDirectoryUsable ) {
|
|
501
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const refuse = () => Promise.reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
|
|
505
|
+
|
|
506
|
+
return localUserDirectory.findByUsername( username ).then( ( record ) => {
|
|
507
|
+
if ( !record || record.disabled === true ) {
|
|
508
|
+
// Burn comparable time before refusing, so timing does not reveal whether the username exists.
|
|
509
|
+
return localUserDirectory.verifyPassword( password, AuthManager.#timingDecoyHash ).then( () => refuse() );
|
|
372
510
|
}
|
|
511
|
+
return localUserDirectory.verifyPassword( password, record.passwordHash ).then( ( matches ) => {
|
|
512
|
+
return matches ? Promise.resolve() : refuse();
|
|
513
|
+
} );
|
|
373
514
|
} );
|
|
374
515
|
}
|
|
375
516
|
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
+
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
+
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const crypto = require( "node:crypto" );
|
|
10
|
+
const tools = require( "@ti-engine/core/tools" );
|
|
11
|
+
const cache = require( "@ti-engine/core/cache" );
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} LocalUserRecord
|
|
15
|
+
* @property {string} userID
|
|
16
|
+
* @property {string} username
|
|
17
|
+
* @property {string} email
|
|
18
|
+
* @property {string} name
|
|
19
|
+
* @property {string} passwordHash
|
|
20
|
+
* @property {boolean} disabled
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ALGORITHM = "scrypt";
|
|
24
|
+
|
|
25
|
+
const CACHE_KEY = "ti:web:auth:local-users";
|
|
26
|
+
|
|
27
|
+
// scrypt at N=16384, r=8 needs 128 * N * r = 16 MiB, comfortably inside node's 32 MiB default `maxmem`.
|
|
28
|
+
const HASH_DEFAULTS = Object.freeze( { N: 16384, r: 8, p: 1, saltBytes: 16, keyBytes: 64 } );
|
|
29
|
+
|
|
30
|
+
// Minimums enforced by decodeHash so a truncated or hand-edited record is rejected at load time — where
|
|
31
|
+
// parseRecords can report it — rather than silently authenticating with far less entropy than the encoding
|
|
32
|
+
// implies (e.g. a copy-pasted base64 key cut short: Buffer.from() shortens it instead of throwing).
|
|
33
|
+
const MIN_SALT_BYTES = 8;
|
|
34
|
+
const MIN_KEY_BYTES = 32;
|
|
35
|
+
|
|
36
|
+
// p multiplies scrypt's CPU cost linearly and sits outside node's `maxmem` guard, so a mistyped value would
|
|
37
|
+
// hog a threadpool slot proportionally with no upper bound otherwise. The default is 1; 16 is ample headroom.
|
|
38
|
+
const MAX_P = 16;
|
|
39
|
+
|
|
40
|
+
// The lower bound a stored hash's N must clear before it is trusted, mirroring the salt/key length floors above:
|
|
41
|
+
// the security level of a record must come from policy, not from whatever survived into the stored string.
|
|
42
|
+
// Hardcoded to the same cost as HASH_DEFAULTS.N — deliberately NOT derived from it (e.g. `HASH_DEFAULTS.N`
|
|
43
|
+
// itself), so an edit to HASH_DEFAULTS.N in isolation is caught by the invariant check below instead of the
|
|
44
|
+
// floor silently tracking whatever the default becomes. Without this floor, `N & (N - 1)` still accepts any
|
|
45
|
+
// power of two, so a truncated or mistyped N (16384 -> 16) loads clean and verifies almost for free.
|
|
46
|
+
const MIN_N = 16384;
|
|
47
|
+
|
|
48
|
+
// A hard ceiling on N, kept for two narrow reasons: `N & (N - 1)` coerces both operands to int32 to test the
|
|
49
|
+
// power-of-two invariant, so it is only reliable strictly below 2^31 — at or above that a non-power-of-two value
|
|
50
|
+
// can pass the check anyway — and N is scrypt's dominant CPU-cost multiplier with no cap of its own.
|
|
51
|
+
// NOTE: this is NOT the effective ceiling, and an earlier version of this comment wrongly claimed it prevented
|
|
52
|
+
// the "loads clean, then fails every verifyPassword call forever" lockout. It does not: the memory budget below
|
|
53
|
+
// binds first by a wide margin. At the shipped r=8, N=32768 — a mere 2x the default and 32x below this value —
|
|
54
|
+
// already exceeds the memory limit. Treat MAX_N as a backstop, not as a description of the usable range.
|
|
55
|
+
const MAX_N = 2 ** 20;
|
|
56
|
+
|
|
57
|
+
// scrypt's memory requirement, and the budget it must fit inside.
|
|
58
|
+
//
|
|
59
|
+
// `crypto.scrypt` rejects parameters needing more than `maxmem`, which Node defaults to 32 MiB. Neither
|
|
60
|
+
// `deriveKey` nor `hashPassword` passes `maxmem`, so that default governs — deliberately: raising it would widen
|
|
61
|
+
// what this published package permits and multiply peak memory across concurrent logins, each holding its budget
|
|
62
|
+
// on a threadpool slot. The bound therefore matches the runtime's real limit rather than a limit of our choosing.
|
|
63
|
+
//
|
|
64
|
+
// The formula is the one OpenSSL actually applies — `128 * r * (N + 2 + p)`, the V array plus the B buffer — NOT
|
|
65
|
+
// the frequently-quoted `128 * N * r`. That distinction is load-bearing: for N=16384/r=16 the naive form computes
|
|
66
|
+
// exactly 33554432, at or under the 32 MiB budget, so a check written from it would ADMIT the very parameters it
|
|
67
|
+
// exists to reject, while the true requirement is 33560576 and OpenSSL refuses. Verified against
|
|
68
|
+
// `crypto.scryptSync` across the boundary; the accepted/rejected split matches this expression exactly.
|
|
69
|
+
//
|
|
70
|
+
// Without this bound a record whose parameters exceed the budget loads with zero reported problems and then makes
|
|
71
|
+
// every verification fail forever — `verifyPassword`'s `.catch( () => false )` turns the
|
|
72
|
+
// ERR_CRYPTO_INVALID_SCRYPT_PARAMS into an ordinary "wrong password", so the account is permanently locked out
|
|
73
|
+
// and indistinguishable from a typo. With it, the record is rejected at load and named in the operator's warning.
|
|
74
|
+
const SCRYPT_MAX_MEMORY_BYTES = 32 * 1024 * 1024;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The memory `crypto.scrypt` requires for the given cost parameters, in bytes.
|
|
78
|
+
*
|
|
79
|
+
* @method
|
|
80
|
+
* @param {number} N
|
|
81
|
+
* @param {number} r
|
|
82
|
+
* @param {number} p
|
|
83
|
+
* @returns {number}
|
|
84
|
+
* @private
|
|
85
|
+
*/
|
|
86
|
+
function scryptMemoryRequirement( N, r, p ) {
|
|
87
|
+
return 128 * r * ( N + 2 + p );
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Invariant: MIN_N must never be stricter than the cost this module itself mints new hashes at, or a hash
|
|
91
|
+
// produced by hashPassword() today could be rejected by decodeHash() tomorrow. Asserted at module load, not
|
|
92
|
+
// only documented in the comment above, so a future edit to either constant that breaks the relationship fails
|
|
93
|
+
// loudly at require() time instead of silently shipping a directory that can never authenticate its own
|
|
94
|
+
// freshly-hashed passwords.
|
|
95
|
+
if ( MIN_N > HASH_DEFAULTS.N ) {
|
|
96
|
+
throw new Error( "local-user-directory: MIN_N must not exceed HASH_DEFAULTS.N — every hash minted by hashPassword() must clear decodeHash()'s own floor" );
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Derives a key with scrypt. Asynchronous on purpose: `scryptSync` blocks the event loop for roughly 100 ms at
|
|
101
|
+
* these parameters, which on a login endpoint is a self-inflicted denial of service.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} password
|
|
104
|
+
* @param {Buffer} salt
|
|
105
|
+
* @param {{N: number, r: number, p: number}} parameters
|
|
106
|
+
* @param {number} keyBytes
|
|
107
|
+
* @returns {Promise<Buffer>}
|
|
108
|
+
*/
|
|
109
|
+
function deriveKey( password, salt, parameters, keyBytes ) {
|
|
110
|
+
return new Promise( ( resolve, reject ) => {
|
|
111
|
+
crypto.scrypt( password, salt, keyBytes, parameters, ( error, key ) => {
|
|
112
|
+
if ( error ) {
|
|
113
|
+
reject( error );
|
|
114
|
+
} else {
|
|
115
|
+
resolve( key );
|
|
116
|
+
}
|
|
117
|
+
} );
|
|
118
|
+
} );
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Splits an encoded hash into its parameters and material, or returns `null` when it is not a recognized
|
|
123
|
+
* encoding — including a structurally valid one whose cost parameters or material fall outside the minimums
|
|
124
|
+
* this module enforces (a non-power-of-two `N`, an excessive `p`, or salt/key material too short to trust).
|
|
125
|
+
*
|
|
126
|
+
* @param {string} encoded
|
|
127
|
+
* @returns {{parameters: {N: number, r: number, p: number}, salt: Buffer, key: Buffer}|null}
|
|
128
|
+
*/
|
|
129
|
+
function decodeHash( encoded ) {
|
|
130
|
+
if ( typeof encoded !== "string" ) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const parts = encoded.split( "$" );
|
|
134
|
+
if ( parts.length !== 6 || parts[ 0 ] !== ALGORITHM ) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const [ , rawN, rawR, rawP, rawSalt, rawKey ] = parts;
|
|
138
|
+
const N = Number( rawN );
|
|
139
|
+
const r = Number( rawR );
|
|
140
|
+
const p = Number( rawP );
|
|
141
|
+
// N is bounded on both sides by MIN_N/MAX_N (see their comments above) before the power-of-two test below —
|
|
142
|
+
// a value outside that range must never reach it, since the bitwise check alone is silently unreliable past
|
|
143
|
+
// the int32 boundary and provides no floor against a cost that is technically a power of two but far too
|
|
144
|
+
// cheap to trust.
|
|
145
|
+
if ( !Number.isInteger( N ) || !Number.isInteger( r ) || !Number.isInteger( p ) || N < MIN_N || N > MAX_N || r < 1 || p < 1 || p > MAX_P ) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
// crypto.scrypt requires N to be a power of two; anything else throws ERR_CRYPTO_INVALID_SCRYPT_PARAMS at
|
|
149
|
+
// derive time. verifyPassword's `.catch(() => false)` swallows that into an ordinary "wrong password", so
|
|
150
|
+
// without this check an operator would see permanent, silent failed logins with nothing reported anywhere.
|
|
151
|
+
if ( ( N & ( N - 1 ) ) !== 0 ) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
// The memory bound. Reached only once N/r/p are individually sane, because it is the combination that matters:
|
|
155
|
+
// every one of N=16384/r=16, N=32768/r=8 and N=65536/r=8 has each value in range yet needs more than the
|
|
156
|
+
// budget, and each one would otherwise load clean and then never verify again. There is deliberately no
|
|
157
|
+
// separate ceiling on `r` — r only matters through this requirement, and a standalone limit would either
|
|
158
|
+
// duplicate this one or contradict it.
|
|
159
|
+
if ( scryptMemoryRequirement( N, r, p ) > SCRYPT_MAX_MEMORY_BYTES ) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const salt = Buffer.from( rawSalt, "base64" );
|
|
164
|
+
const key = Buffer.from( rawKey, "base64" );
|
|
165
|
+
// Minimum lengths, not just non-empty: the security level of a record must come from policy, not from
|
|
166
|
+
// whatever happened to survive into the stored string. A truncated key still decodes without error
|
|
167
|
+
// (Buffer.from() shortens rather than throwing on invalid/incomplete base64), so length is the only
|
|
168
|
+
// signal left to catch it.
|
|
169
|
+
if ( salt.length < MIN_SALT_BYTES || key.length < MIN_KEY_BYTES ) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return { parameters: { N: N, r: r, p: p }, salt: salt, key: key };
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Hashes a password for storage in a local-users file. Synchronous because its only caller is the one-shot CLI,
|
|
180
|
+
* where blocking is free — never call it on a request path.
|
|
181
|
+
*
|
|
182
|
+
* @method
|
|
183
|
+
* @param {string} password
|
|
184
|
+
* @returns {string} The encoded hash: `scrypt$N$r$p$salt$hash`, base64 salt and key.
|
|
185
|
+
* @throws {TypeError} If `password` is empty or not a string — `verifyPassword` refuses empty passwords, so
|
|
186
|
+
* hashing one here would only mint a hash that can never be logged into.
|
|
187
|
+
* @public
|
|
188
|
+
*/
|
|
189
|
+
function hashPassword( password ) {
|
|
190
|
+
if ( typeof password !== "string" || password.length === 0 ) {
|
|
191
|
+
throw new TypeError( "hashPassword requires a non-empty string password" );
|
|
192
|
+
}
|
|
193
|
+
const salt = crypto.randomBytes( HASH_DEFAULTS.saltBytes );
|
|
194
|
+
const parameters = { N: HASH_DEFAULTS.N, r: HASH_DEFAULTS.r, p: HASH_DEFAULTS.p };
|
|
195
|
+
const key = crypto.scryptSync( password, salt, HASH_DEFAULTS.keyBytes, parameters );
|
|
196
|
+
return [ ALGORITHM, parameters.N, parameters.r, parameters.p, salt.toString( "base64" ), key.toString( "base64" ) ].join( "$" );
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Verifies a password against an encoded hash. The cost parameters come from the stored string rather than the
|
|
201
|
+
* current defaults, so raising the defaults never invalidates an existing hash.
|
|
202
|
+
*
|
|
203
|
+
* @method
|
|
204
|
+
* @param {string} password
|
|
205
|
+
* @param {string} encoded
|
|
206
|
+
* @returns {Promise<boolean>} `false` for a malformed encoding or an absent password — never a throw, because a
|
|
207
|
+
* bad stored value must read as "does not match", not as a server error on the login path.
|
|
208
|
+
* @public
|
|
209
|
+
*/
|
|
210
|
+
function verifyPassword( password, encoded ) {
|
|
211
|
+
const decoded = decodeHash( encoded );
|
|
212
|
+
if ( !decoded || typeof password !== "string" || password.length === 0 ) {
|
|
213
|
+
return Promise.resolve( false );
|
|
214
|
+
}
|
|
215
|
+
return deriveKey( password, decoded.salt, decoded.parameters, decoded.key.length )
|
|
216
|
+
// Compared as base64 strings, not raw Buffers: constantTimeEquals coerces each argument with
|
|
217
|
+
// `String(x || "")`, which would utf8-decode a key Buffer lossily (through U+FFFD replacement for any
|
|
218
|
+
// byte sequence that is not valid UTF-8) instead of comparing its bytes — silently breaking the
|
|
219
|
+
// comparison. Base64 text round-trips through String() exactly, so it stays safe to pass here.
|
|
220
|
+
.then( ( key ) => tools.constantTimeEquals( key.toString( "base64" ), decoded.key.toString( "base64" ) ) )
|
|
221
|
+
.catch( () => false );
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Usernames that JavaScript's object model treats specially, rejected here because the storage layer this
|
|
225
|
+
// module writes through cannot represent all of them safely — reusing the exact trio ('__proto__',
|
|
226
|
+
// 'constructor', 'prototype') this codebase already treats as reserved at every other prototype-pollution
|
|
227
|
+
// boundary (see the CA-91 employee field-path guards in packages/competence). Verified empirically per name,
|
|
228
|
+
// not assumed uniformly:
|
|
229
|
+
// - '__proto__' is the one that corrupted storage, in @ti-engine/core **before 1.11.0**.
|
|
230
|
+
// `cache.instance.setJSON` serializes through `tools.stringifyJSON` —
|
|
231
|
+
// `JSON.stringify( _.toPlainObject( decycle( value ) ) )` in @ti-engine/core/utils/tools.js. `decycle`'s
|
|
232
|
+
// object-copy branch built each replica with `newItem = {}; newItem[ name ] = derez( ... )`; for
|
|
233
|
+
// `name === "__proto__"` that assignment invoked the inherited accessor setter instead of creating an own key,
|
|
234
|
+
// repointing the replica's own prototype to the record. `_.toPlainObject` then flattened that prototype chain
|
|
235
|
+
// back into own keys, so the record's fields were spliced into the top level of the *entire* stored directory
|
|
236
|
+
// rather than merely dropped.
|
|
237
|
+
// **core 1.11.0 fixed that** (its `decycle` builds the replica with `Object.create( null )`), so against a
|
|
238
|
+
// current core the round-trip is intact — the test in local-user-directory.store.test.js now pins the fixed
|
|
239
|
+
// behaviour. The rejection stays regardless, and not as vague defence in depth: this package declares
|
|
240
|
+
// `"@ti-engine/core": "*"`, so a consumer of the published web-framework may pair it with any core, including
|
|
241
|
+
// a pre-1.11.0 one that still corrupts. web-framework cannot guarantee the serializer beneath it is fixed, so
|
|
242
|
+
// it must not accept a record it might be unable to represent.
|
|
243
|
+
// - 'constructor' and 'prototype' round-trip through that same pipeline correctly (verified the same way).
|
|
244
|
+
// 'constructor' is hazardous only for an *unguarded read* (`stored.constructor` resolves to the inherited
|
|
245
|
+
// Object constructor function when absent) — exactly what the hasOwnProperty guards in reconcile/
|
|
246
|
+
// findByUsername below exist to prevent — and 'prototype' collides with nothing in a plain object's
|
|
247
|
+
// prototype chain at all. Both are rejected anyway so this stays the same trio as everywhere else in the
|
|
248
|
+
// codebase, rather than a bespoke subset that has to be re-derived from this file's current implementation
|
|
249
|
+
// details every time something downstream changes.
|
|
250
|
+
// Do not "simplify" this back down to just '__proto__' on the assumption that only it is provably broken
|
|
251
|
+
// today — re-verify all three empirically first, the same way this comment's claims were verified, before
|
|
252
|
+
// removing any of them (including after @ti-engine/core's decycle/stringifyJSON pipeline is eventually fixed).
|
|
253
|
+
const RESERVED_USERNAMES = new Set( [ "__proto__", "constructor", "prototype" ] );
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Validates raw file content into records, reporting why any entry was excluded. Never throws: a malformed row is
|
|
257
|
+
* data, not a crash, so one bad entry cannot take an instance down.
|
|
258
|
+
*
|
|
259
|
+
* @method
|
|
260
|
+
* @param {*} raw
|
|
261
|
+
* @returns {{records: LocalUserRecord[], problems: string[]}}
|
|
262
|
+
* @public
|
|
263
|
+
*/
|
|
264
|
+
function parseRecords( raw ) {
|
|
265
|
+
const problems = [];
|
|
266
|
+
if ( !Array.isArray( raw ) ) {
|
|
267
|
+
return { records: [], problems: [ "the local users file must contain a JSON array of user records" ] };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const records = [];
|
|
271
|
+
const seen = new Set();
|
|
272
|
+
raw.forEach( ( entry, index ) => {
|
|
273
|
+
if ( !entry || typeof entry !== "object" || Array.isArray( entry ) ) {
|
|
274
|
+
problems.push( `entry ${ index } is not an object` );
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const username = typeof entry.username === "string" ? entry.username.trim() : "";
|
|
278
|
+
const email = typeof entry.email === "string" ? entry.email.trim() : "";
|
|
279
|
+
const passwordHash = typeof entry.passwordHash === "string" ? entry.passwordHash.trim() : "";
|
|
280
|
+
|
|
281
|
+
if ( !username ) {
|
|
282
|
+
problems.push( `entry ${ index } has no username` );
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// See the RESERVED_USERNAMES comment above: the storage layer cannot represent one of these three
|
|
286
|
+
// names without corrupting the directory (confirmed for '__proto__'; the other two are rejected for
|
|
287
|
+
// consistency), so a record using one is refused here — at load, where an operator sees why — rather
|
|
288
|
+
// than accepted and silently corrupted or lost the first time it is actually written.
|
|
289
|
+
if ( RESERVED_USERNAMES.has( username ) ) {
|
|
290
|
+
problems.push( `user '${ username }' cannot be stored — '${ username }' is a reserved name that collides with JavaScript's object model, not a typo` );
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if ( !email ) {
|
|
294
|
+
problems.push( `user '${ username }' has no email, which is the field an application resolves identity by` );
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if ( !passwordHash || !decodeHash( passwordHash ) ) {
|
|
298
|
+
problems.push( `user '${ username }' has no usable passwordHash — generate one with \`npm run hash-password -w @ti-engine/web-framework\`` );
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Usernames are matched exactly, so a repeat is a genuine duplicate. Keyed storage would silently keep the
|
|
302
|
+
// last one and leave the operator unable to tell which password is live, so it is reported instead.
|
|
303
|
+
//
|
|
304
|
+
// A duplicate *email* is deliberately not checked here, unlike a duplicate username: two credentials
|
|
305
|
+
// sharing an email still resolve deterministically to the same person, so there is no "which password
|
|
306
|
+
// is live" ambiguity the way there is for a repeated username. This asymmetry is intentional, not an
|
|
307
|
+
// oversight.
|
|
308
|
+
if ( seen.has( username ) ) {
|
|
309
|
+
problems.push( `duplicate username '${ username }' at entry ${ index } — ignored, the first occurrence is kept` );
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
seen.add( username );
|
|
313
|
+
|
|
314
|
+
records.push( {
|
|
315
|
+
userID: ( typeof entry.userID === "string" && entry.userID.trim() ) || `local:${ username }`,
|
|
316
|
+
username: username,
|
|
317
|
+
email: email,
|
|
318
|
+
name: ( typeof entry.name === "string" && entry.name.trim() ) || username,
|
|
319
|
+
passwordHash: passwordHash,
|
|
320
|
+
disabled: entry.disabled === true
|
|
321
|
+
} );
|
|
322
|
+
} );
|
|
323
|
+
|
|
324
|
+
return { records: records, problems: problems };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Reads the whole stored directory, or an empty object when it has never been written.
|
|
329
|
+
* <br/>
|
|
330
|
+
* `cache.instance.getJSON` queries the `$` root, and RedisJSON's JSONPath contract wraps that result in a
|
|
331
|
+
* single-element array on a hit — mirrored deliberately by the in-memory test double (see its own doc comment)
|
|
332
|
+
* and already unwrapped once in this package by `ConfigStore#readJSON`. The same unwrap happens here, otherwise
|
|
333
|
+
* every read after the first write would misread a populated directory as empty.
|
|
334
|
+
* <br/>
|
|
335
|
+
* A genuine Redis failure is deliberately left to propagate rather than caught here: swallowing it into `{}`
|
|
336
|
+
* would make an outage indistinguishable from "no users configured", silently failing every local login as
|
|
337
|
+
* "no such user" instead of surfacing the outage to the caller.
|
|
338
|
+
*
|
|
339
|
+
* @returns {Promise<Object>}
|
|
340
|
+
*/
|
|
341
|
+
function readStored() {
|
|
342
|
+
return cache.instance.getJSON( CACHE_KEY ).then( ( result ) => {
|
|
343
|
+
const stored = Array.isArray( result ) ? ( result[ 0 ] ?? null ) : ( result ?? null );
|
|
344
|
+
return ( stored && typeof stored === "object" && !Array.isArray( stored ) ) ? stored : {};
|
|
345
|
+
} );
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Writes the records as the complete directory, keyed by username, and reports what changed.
|
|
350
|
+
* <br/>
|
|
351
|
+
* The whole set is written rather than patched because the file is the source of truth: a username absent from
|
|
352
|
+
* `records` must disappear, which is what makes revocation-by-file-edit work. `@ti-engine/core/cache` exposes no
|
|
353
|
+
* delete, so a whole-object write is also the only way to remove a key.
|
|
354
|
+
* <br/>
|
|
355
|
+
* Usernames are attacker-influenceable (the local sign-in handler resolves them from request input), so both the write
|
|
356
|
+
* and every read below are guarded against `Object.prototype`'s reserved names rather than trusting plain bracket
|
|
357
|
+
* access:
|
|
358
|
+
* <br/>
|
|
359
|
+
* - `incoming` is built with a null prototype (`Object.create( null )`) so it inherits nothing. On an ordinary
|
|
360
|
+
* `{}`, `incoming[ "__proto__" ] = record` would not create an own key at all — it would invoke the inherited
|
|
361
|
+
* `__proto__` setter and silently repoint the object's own prototype to `record`, so the record never shows up
|
|
362
|
+
* in `Object.keys`/`JSON.stringify` and is never persisted, without error. On a null-prototype object that
|
|
363
|
+
* setter does not exist anywhere on the (empty) prototype chain, so the assignment falls back to creating a
|
|
364
|
+
* perfectly ordinary own data property instead — confirmed empirically (see the test file) that this still
|
|
365
|
+
* `JSON.stringify`s and round-trips normally.
|
|
366
|
+
* - Every classification read below checks ownership with `Object.prototype.hasOwnProperty.call(...)` rather than
|
|
367
|
+
* relying on truthiness, because `stored` comes back from `readStored()` — ultimately a `JSON.parse` result —
|
|
368
|
+
* with the ordinary `Object.prototype` chain. An unguarded `stored[ "constructor" ]` would resolve to the
|
|
369
|
+
* inherited `Object` constructor function (always truthy) rather than "not present", misclassifying a
|
|
370
|
+
* first-time `constructor`-named user as `updated` instead of `added`, and hiding its removal from `removed`.
|
|
371
|
+
*
|
|
372
|
+
* @method
|
|
373
|
+
* @param {LocalUserRecord[]} records
|
|
374
|
+
* @returns {Promise<{added: string[], updated: string[], removed: string[]}>}
|
|
375
|
+
* @public
|
|
376
|
+
*/
|
|
377
|
+
function reconcile( records ) {
|
|
378
|
+
const incoming = Object.create( null );
|
|
379
|
+
( Array.isArray( records ) ? records : [] ).forEach( ( record ) => {
|
|
380
|
+
incoming[ record.username ] = record;
|
|
381
|
+
} );
|
|
382
|
+
|
|
383
|
+
return readStored().then( ( stored ) => {
|
|
384
|
+
const added = [];
|
|
385
|
+
const updated = [];
|
|
386
|
+
Object.keys( incoming ).forEach( ( username ) => {
|
|
387
|
+
// Ownership check, not truthiness — see the function doc comment: `stored[ username ]` alone would
|
|
388
|
+
// resolve a username of 'constructor' to the inherited Object constructor instead of "not present".
|
|
389
|
+
if ( !Object.prototype.hasOwnProperty.call( stored, username ) ) {
|
|
390
|
+
added.push( username );
|
|
391
|
+
} else if ( JSON.stringify( stored[ username ] ) !== JSON.stringify( incoming[ username ] ) ) {
|
|
392
|
+
updated.push( username );
|
|
393
|
+
}
|
|
394
|
+
} );
|
|
395
|
+
// Same reasoning in the other direction: `incoming` is null-prototype, so this is already safe, but the
|
|
396
|
+
// explicit ownership check keeps both classification directions visibly consistent for the same reason.
|
|
397
|
+
const removed = Object.keys( stored ).filter( ( username ) => !Object.prototype.hasOwnProperty.call( incoming, username ) );
|
|
398
|
+
|
|
399
|
+
return cache.instance.setJSON( CACHE_KEY, incoming ).then( () => {
|
|
400
|
+
return { added: added, updated: updated, removed: removed };
|
|
401
|
+
} );
|
|
402
|
+
} );
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Looks a user up by exact username.
|
|
407
|
+
* <br/>
|
|
408
|
+
* `username` here is attacker-influenceable — this is the function the local sign-in handler calls with the
|
|
409
|
+
* value a client typed into the username field. Checked with `Object.prototype.hasOwnProperty.call(...)` rather than
|
|
410
|
+
* `stored[ username ] || null`, because `stored` carries the ordinary `Object.prototype` chain and an unguarded
|
|
411
|
+
* bracket read would resolve `findByUsername( "constructor" )` to the inherited `Object` constructor function
|
|
412
|
+
* instead of `null`, violating the declared return type for nearly every real query.
|
|
413
|
+
*
|
|
414
|
+
* @method
|
|
415
|
+
* @param {string} username
|
|
416
|
+
* @returns {Promise<LocalUserRecord|null>}
|
|
417
|
+
* @public
|
|
418
|
+
*/
|
|
419
|
+
function findByUsername( username ) {
|
|
420
|
+
if ( typeof username !== "string" || username.length === 0 ) {
|
|
421
|
+
return Promise.resolve( null );
|
|
422
|
+
}
|
|
423
|
+
return readStored().then( ( stored ) => {
|
|
424
|
+
return Object.prototype.hasOwnProperty.call( stored, username ) ? stored[ username ] : null;
|
|
425
|
+
} );
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
module.exports = { ALGORITHM, CACHE_KEY, HASH_DEFAULTS, hashPassword, verifyPassword, parseRecords, reconcile, findByUsername };
|
|
@@ -15,7 +15,7 @@ const tools = require( "@ti-engine/core/tools" );
|
|
|
15
15
|
* Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
|
|
16
16
|
* configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
|
|
17
17
|
* container-friendly control over network binding, TLS, the session cookie secret, the enabled authentication
|
|
18
|
-
* methods, the admin allowlist, the trusted request origins, and the `/static` cache policy without editing config files. Note `TI_WEB_AUTH_METHODS`,
|
|
18
|
+
* methods, the admin allowlist, the local auth users file path, the trusted request origins, and the `/static` cache policy without editing config files. Note `TI_WEB_AUTH_METHODS`,
|
|
19
19
|
* `TI_WEB_AUTH_ADMINS`, `TI_WEB_TRUSTED_ORIGINS`, and `TI_WEB_STATIC_IMMUTABLE_PATHS` fully REPLACE their config arrays (`auth.enabledMethods` / `auth.admins` / `trustedOrigins` / `staticCache.immutablePaths`) rather than
|
|
20
20
|
* merging — the config-file merge is by-index and cannot cleanly override an array.
|
|
21
21
|
*
|
|
@@ -59,6 +59,11 @@ function applyWebConfigEnvOverrides( config, env = process.env ) {
|
|
|
59
59
|
config.auth = config.auth || {};
|
|
60
60
|
config.auth.admins = env.TI_WEB_AUTH_ADMINS.split( "," ).map( ( entry ) => entry.trim() ).filter( ( entry ) => entry.length > 0 );
|
|
61
61
|
}
|
|
62
|
+
if ( env.TI_WEB_AUTH_LOCAL_USERS_PATH !== undefined ) {
|
|
63
|
+
config.auth = config.auth || {};
|
|
64
|
+
config.auth.local = config.auth.local || {};
|
|
65
|
+
config.auth.local.usersPath = env.TI_WEB_AUTH_LOCAL_USERS_PATH;
|
|
66
|
+
}
|
|
62
67
|
if ( env.TI_WEB_TRUSTED_ORIGINS !== undefined ) {
|
|
63
68
|
config.trustedOrigins = env.TI_WEB_TRUSTED_ORIGINS.split( "," ).map( ( origin ) => origin.trim() ).filter( ( origin ) => origin.length > 0 );
|
|
64
69
|
}
|
|
@@ -172,7 +172,13 @@ let regenerateAndSaveSession = ( request, redirectTo, modifier ) => {
|
|
|
172
172
|
request.session = modifier( request.session );
|
|
173
173
|
}
|
|
174
174
|
} catch ( error ) {
|
|
175
|
-
|
|
175
|
+
// The application's augment hook refused this login. `session.user` was already assigned in place
|
|
176
|
+
// before the hook ran, and `verifySession` only checks that it exists — so a merely-rejected
|
|
177
|
+
// session would still be persisted by express-session at response end and would admit the user.
|
|
178
|
+
// Destroy it before rejecting so a refusal is genuinely fail-closed.
|
|
179
|
+
request.session.destroy( () => {
|
|
180
|
+
reject( error );
|
|
181
|
+
} );
|
|
176
182
|
return;
|
|
177
183
|
}
|
|
178
184
|
request.session.save( ( error ) => {
|
|
@@ -528,7 +534,9 @@ module.exports.defaultErrorHandler = () => {
|
|
|
528
534
|
} )
|
|
529
535
|
} );
|
|
530
536
|
return response.status( status ).send( "" );
|
|
531
|
-
|
|
537
|
+
// A 401 on an HTML request means "you are not signed in" whatever the method — the useful answer is the
|
|
538
|
+
// sign-in page carrying the reason, so local auth's POST presents exactly like the OAuth callback's GET.
|
|
539
|
+
} else if ( isAcceptingResponseType( request, "html" ) && ( request.method === "GET" || status === exceptions.httpCode.C_401 ) ) {
|
|
532
540
|
response.redirect( exceptions.httpCode.C_303, "/?error=" + encodeURIComponent( exception.code ) );
|
|
533
541
|
} else {
|
|
534
542
|
response.status( status ).send( payload );
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ti-engine/web-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "A web-framework based on the ti-engine. It provides a customizable ready-to-use web-server microservice and a set of tools for creating web applications. NOTICE: This is still a work in progress and the full architecture, design, and functionality are not available!",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ti-engine",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"types": "./types/bin/web-server.d.ts",
|
|
28
28
|
"default": "./bin/web-server.js"
|
|
29
29
|
},
|
|
30
|
+
"./authorization": {
|
|
31
|
+
"types": "./types/components/authorization.d.ts",
|
|
32
|
+
"default": "./components/authorization.js"
|
|
33
|
+
},
|
|
30
34
|
"./definitions": {
|
|
31
35
|
"types": "./types/components/definitions.types.d.ts",
|
|
32
36
|
"default": "./components/definitions.types.js"
|
|
@@ -69,6 +73,10 @@
|
|
|
69
73
|
"types": "./types/components/definitions.types.d.ts",
|
|
70
74
|
"default": "./components/definitions.types.js"
|
|
71
75
|
},
|
|
76
|
+
"#local-user-directory": {
|
|
77
|
+
"types": "./types/components/local-user-directory.d.ts",
|
|
78
|
+
"default": "./components/local-user-directory.js"
|
|
79
|
+
},
|
|
72
80
|
"#session-store": {
|
|
73
81
|
"types": "./types/components/session-store.d.ts",
|
|
74
82
|
"default": "./components/session-store.js"
|
|
@@ -138,6 +146,7 @@
|
|
|
138
146
|
"scripts": {
|
|
139
147
|
"postinstall": "node ./bin/build/post-install.js",
|
|
140
148
|
"test": "node --test test/*.test.js",
|
|
141
|
-
"build:types": "tsc -p tsconfig.types.json"
|
|
149
|
+
"build:types": "tsc -p tsconfig.types.json",
|
|
150
|
+
"hash-password": "node ./bin/build/hash-password.js"
|
|
142
151
|
}
|
|
143
152
|
}
|
|
@@ -13,12 +13,19 @@ export type ApiConfig = {
|
|
|
13
13
|
};
|
|
14
14
|
export type SettingsAuth = {
|
|
15
15
|
enabledMethods: string[];
|
|
16
|
-
local:
|
|
16
|
+
local: SettingsAuthLocal;
|
|
17
17
|
oauth2: {
|
|
18
18
|
azure?: SettingsOAuth2Client;
|
|
19
19
|
google?: SettingsOAuth2Client;
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
|
+
export type SettingsAuthLocal = {
|
|
23
|
+
/**
|
|
24
|
+
* Path to the JSON file of local user records (see `TI_WEB_AUTH_LOCAL_USERS_PATH`).
|
|
25
|
+
* Local sign-in refuses everyone whenever this is absent, unreadable, or yields no usable records.
|
|
26
|
+
*/
|
|
27
|
+
usersPath?: string;
|
|
28
|
+
};
|
|
22
29
|
export type SettingsOAuth2Client = {
|
|
23
30
|
clientID?: string;
|
|
24
31
|
clientSecret?: string;
|
|
@@ -179,6 +186,11 @@ declare class TiWebServer extends ServiceConsumer {
|
|
|
179
186
|
* Hook for the application to augment the freshly-authenticated session (e.g. derive domain roles from an
|
|
180
187
|
* identity store or the org chart). Runs synchronously, once per login, before the framework's additive `admin`
|
|
181
188
|
* role is applied. The default is a no-op. Any test-user role injection is an override of whatever the app derives.
|
|
189
|
+
* <br/>
|
|
190
|
+
* **Refusing a login.** Throwing from this hook refuses the sign-in: the framework destroys the freshly regenerated
|
|
191
|
+
* session (so no usable session survives the refusal), the login handler raises `401`, and the error handler
|
|
192
|
+
* redirects the browser to the login page with the exception code in `?error=`. Throw when the authenticated
|
|
193
|
+
* identity cannot be mapped to an application principal; return the session unchanged to accept it.
|
|
182
194
|
*
|
|
183
195
|
* @method
|
|
184
196
|
* @virtual
|
|
@@ -200,6 +212,12 @@ declare class TiWebServer extends ServiceConsumer {
|
|
|
200
212
|
authenticate(authMethod: TiAuthMethod, authDetails?: Object): Promise<any>;
|
|
201
213
|
/**
|
|
202
214
|
* Used to set up user authorization according to the specified auth method.
|
|
215
|
+
* <br/>
|
|
216
|
+
* NOTE: This presupposes a successful, immediately preceding {@link TiWebServer#authenticate} call for the same
|
|
217
|
+
* credentials — it is **not** an independent authentication check. For the `local` method it builds the session
|
|
218
|
+
* user from the directory record named by `oidc.username`, verifying that the record exists and is not disabled
|
|
219
|
+
* but performing no password comparison of its own; the framework's own login route calls `authenticate` first.
|
|
220
|
+
* Calling this directly without that preceding step would mint a session for any known username.
|
|
203
221
|
*
|
|
204
222
|
* @method
|
|
205
223
|
* @param {TiAuthMethod} authMethod
|
|
@@ -70,6 +70,13 @@ declare class AuthManager {
|
|
|
70
70
|
authenticate(authMethod: TiAuthMethod, authDetails: Object): Promise<Object>;
|
|
71
71
|
/**
|
|
72
72
|
* Used to set up user authorization according to the specified authentication method.
|
|
73
|
+
* <br/>
|
|
74
|
+
* NOTE: This presupposes a successful, immediately preceding {@link AuthManager#authenticate} call for the
|
|
75
|
+
* same credentials and is NOT an independent authentication check on its own — for `LOCAL` it performs no
|
|
76
|
+
* password verification. It refuses an absent, disabled, or (for `LOCAL`) not-yet-usable-directory record,
|
|
77
|
+
* but a caller that invokes it without having just authenticated bypasses password verification entirely.
|
|
78
|
+
* The framework's own login route always calls `authenticate()` first (see `web-handlers.js`); this method
|
|
79
|
+
* is public on both `AuthManager` and `TiWebServer`, so any other caller must preserve that ordering itself.
|
|
73
80
|
*
|
|
74
81
|
* @method
|
|
75
82
|
* @param {TiAuthMethod} authMethod
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
declare const _exports: {
|
|
2
|
+
ALGORITHM: string;
|
|
3
|
+
CACHE_KEY: string;
|
|
4
|
+
HASH_DEFAULTS: Readonly<{
|
|
5
|
+
N: 16384;
|
|
6
|
+
r: 8;
|
|
7
|
+
p: 1;
|
|
8
|
+
saltBytes: 16;
|
|
9
|
+
keyBytes: 64;
|
|
10
|
+
}>;
|
|
11
|
+
hashPassword: typeof hashPassword;
|
|
12
|
+
verifyPassword: typeof verifyPassword;
|
|
13
|
+
parseRecords: typeof parseRecords;
|
|
14
|
+
reconcile: typeof reconcile;
|
|
15
|
+
findByUsername: typeof findByUsername;
|
|
16
|
+
};
|
|
17
|
+
export = _exports;
|
|
18
|
+
export type LocalUserRecord = {
|
|
19
|
+
userID: string;
|
|
20
|
+
username: string;
|
|
21
|
+
email: string;
|
|
22
|
+
name: string;
|
|
23
|
+
passwordHash: string;
|
|
24
|
+
disabled: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Hashes a password for storage in a local-users file. Synchronous because its only caller is the one-shot CLI,
|
|
28
|
+
* where blocking is free — never call it on a request path.
|
|
29
|
+
*
|
|
30
|
+
* @method
|
|
31
|
+
* @param {string} password
|
|
32
|
+
* @returns {string} The encoded hash: `scrypt$N$r$p$salt$hash`, base64 salt and key.
|
|
33
|
+
* @throws {TypeError} If `password` is empty or not a string — `verifyPassword` refuses empty passwords, so
|
|
34
|
+
* hashing one here would only mint a hash that can never be logged into.
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
declare function hashPassword(password: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Verifies a password against an encoded hash. The cost parameters come from the stored string rather than the
|
|
40
|
+
* current defaults, so raising the defaults never invalidates an existing hash.
|
|
41
|
+
*
|
|
42
|
+
* @method
|
|
43
|
+
* @param {string} password
|
|
44
|
+
* @param {string} encoded
|
|
45
|
+
* @returns {Promise<boolean>} `false` for a malformed encoding or an absent password — never a throw, because a
|
|
46
|
+
* bad stored value must read as "does not match", not as a server error on the login path.
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
declare function verifyPassword(password: string, encoded: string): Promise<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Validates raw file content into records, reporting why any entry was excluded. Never throws: a malformed row is
|
|
52
|
+
* data, not a crash, so one bad entry cannot take an instance down.
|
|
53
|
+
*
|
|
54
|
+
* @method
|
|
55
|
+
* @param {*} raw
|
|
56
|
+
* @returns {{records: LocalUserRecord[], problems: string[]}}
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
declare function parseRecords(raw: any): {
|
|
60
|
+
records: LocalUserRecord[];
|
|
61
|
+
problems: string[];
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Writes the records as the complete directory, keyed by username, and reports what changed.
|
|
65
|
+
* <br/>
|
|
66
|
+
* The whole set is written rather than patched because the file is the source of truth: a username absent from
|
|
67
|
+
* `records` must disappear, which is what makes revocation-by-file-edit work. `@ti-engine/core/cache` exposes no
|
|
68
|
+
* delete, so a whole-object write is also the only way to remove a key.
|
|
69
|
+
* <br/>
|
|
70
|
+
* Usernames are attacker-influenceable (the local sign-in handler resolves them from request input), so both the write
|
|
71
|
+
* and every read below are guarded against `Object.prototype`'s reserved names rather than trusting plain bracket
|
|
72
|
+
* access:
|
|
73
|
+
* <br/>
|
|
74
|
+
* - `incoming` is built with a null prototype (`Object.create( null )`) so it inherits nothing. On an ordinary
|
|
75
|
+
* `{}`, `incoming[ "__proto__" ] = record` would not create an own key at all — it would invoke the inherited
|
|
76
|
+
* `__proto__` setter and silently repoint the object's own prototype to `record`, so the record never shows up
|
|
77
|
+
* in `Object.keys`/`JSON.stringify` and is never persisted, without error. On a null-prototype object that
|
|
78
|
+
* setter does not exist anywhere on the (empty) prototype chain, so the assignment falls back to creating a
|
|
79
|
+
* perfectly ordinary own data property instead — confirmed empirically (see the test file) that this still
|
|
80
|
+
* `JSON.stringify`s and round-trips normally.
|
|
81
|
+
* - Every classification read below checks ownership with `Object.prototype.hasOwnProperty.call(...)` rather than
|
|
82
|
+
* relying on truthiness, because `stored` comes back from `readStored()` — ultimately a `JSON.parse` result —
|
|
83
|
+
* with the ordinary `Object.prototype` chain. An unguarded `stored[ "constructor" ]` would resolve to the
|
|
84
|
+
* inherited `Object` constructor function (always truthy) rather than "not present", misclassifying a
|
|
85
|
+
* first-time `constructor`-named user as `updated` instead of `added`, and hiding its removal from `removed`.
|
|
86
|
+
*
|
|
87
|
+
* @method
|
|
88
|
+
* @param {LocalUserRecord[]} records
|
|
89
|
+
* @returns {Promise<{added: string[], updated: string[], removed: string[]}>}
|
|
90
|
+
* @public
|
|
91
|
+
*/
|
|
92
|
+
declare function reconcile(records: LocalUserRecord[]): Promise<{
|
|
93
|
+
added: string[];
|
|
94
|
+
updated: string[];
|
|
95
|
+
removed: string[];
|
|
96
|
+
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Looks a user up by exact username.
|
|
99
|
+
* <br/>
|
|
100
|
+
* `username` here is attacker-influenceable — this is the function the local sign-in handler calls with the
|
|
101
|
+
* value a client typed into the username field. Checked with `Object.prototype.hasOwnProperty.call(...)` rather than
|
|
102
|
+
* `stored[ username ] || null`, because `stored` carries the ordinary `Object.prototype` chain and an unguarded
|
|
103
|
+
* bracket read would resolve `findByUsername( "constructor" )` to the inherited `Object` constructor function
|
|
104
|
+
* instead of `null`, violating the declared return type for nearly every real query.
|
|
105
|
+
*
|
|
106
|
+
* @method
|
|
107
|
+
* @param {string} username
|
|
108
|
+
* @returns {Promise<LocalUserRecord|null>}
|
|
109
|
+
* @public
|
|
110
|
+
*/
|
|
111
|
+
declare function findByUsername(username: string): Promise<LocalUserRecord | null>;
|
|
@@ -4,7 +4,7 @@ export = applyWebConfigEnvOverrides;
|
|
|
4
4
|
* Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
|
|
5
5
|
* configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
|
|
6
6
|
* container-friendly control over network binding, TLS, the session cookie secret, the enabled authentication
|
|
7
|
-
* methods, the admin allowlist, the trusted request origins, and the `/static` cache policy without editing config files. Note `TI_WEB_AUTH_METHODS`,
|
|
7
|
+
* methods, the admin allowlist, the local auth users file path, the trusted request origins, and the `/static` cache policy without editing config files. Note `TI_WEB_AUTH_METHODS`,
|
|
8
8
|
* `TI_WEB_AUTH_ADMINS`, `TI_WEB_TRUSTED_ORIGINS`, and `TI_WEB_STATIC_IMMUTABLE_PATHS` fully REPLACE their config arrays (`auth.enabledMethods` / `auth.admins` / `trustedOrigins` / `staticCache.immutablePaths`) rather than
|
|
9
9
|
* merging — the config-file merge is by-index and cannot cleanly override an array.
|
|
10
10
|
*
|