@ti-engine/web-framework 1.20.1 → 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 +92 -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 +165 -1
- package/bin/static/fragments/components/component-sidebar.html +25 -0
- package/bin/static/fragments/frame-about.html +96 -0
- package/bin/static/fragments/frame-login.html +6 -1
- package/bin/static/fragments/frame-profile.html +103 -3
- package/bin/static/scripts/ti-framework.css +143 -0
- package/bin/static/scripts/ti-framework.js +339 -0
- package/bin/web-app-manager.js +235 -2
- package/bin/web-server.js +18 -1
- package/bin/web-server.json +3 -0
- package/components/application-info.js +190 -0
- package/components/auth-manager.js +159 -18
- package/components/definitions.types.js +65 -0
- 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 +15 -2
- package/types/bin/web-app-manager.d.ts +95 -1
- package/types/bin/web-server.d.ts +19 -1
- package/types/components/application-info.d.ts +53 -0
- package/types/components/auth-manager.d.ts +7 -0
- package/types/components/definitions.types.d.ts +166 -0
- package/types/components/local-user-directory.d.ts +111 -0
- package/types/components/web-config-env.d.ts +1 -1
|
@@ -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
|
|
|
@@ -26,3 +26,68 @@
|
|
|
26
26
|
* @property {(callback: TiSessionCallback) => TiSession} destroy
|
|
27
27
|
* @property {(callback?: TiSessionCallback) => TiSession} save
|
|
28
28
|
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One label/value pair inside a {@link TiInfoSection}. Both strings are display-ready — already localized and
|
|
32
|
+
* already formatted by the server, since that is where the session language and the label catalogue live. The
|
|
33
|
+
* three flags are purely presentational; an empty `value` renders the screen's placeholder.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {Object} TiInfoItem
|
|
36
|
+
* @property {string} label
|
|
37
|
+
* @property {string} [value]
|
|
38
|
+
* @property {string} [href] Renders the value as a link to this target. Only `http:`, `https:` and `mailto:` are
|
|
39
|
+
* honoured — any other scheme is dropped client-side and the item degrades to plain text.
|
|
40
|
+
* @property {boolean} [wide] Span the full width of the section grid instead of one column.
|
|
41
|
+
* @property {boolean} [mono] Render the value in the monospaced face (IDs, versions, hashes).
|
|
42
|
+
* @property {boolean} [muted] Render the value as a dimmed hint rather than primary text.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A titled group of label/value pairs. The framework's Profile and About screens render an array of these
|
|
47
|
+
* generically, so an application contributes content without contributing layout.
|
|
48
|
+
*
|
|
49
|
+
* @typedef {Object} TiInfoSection
|
|
50
|
+
* @property {string} title
|
|
51
|
+
* @property {string} [description] Optional intro line under the section title.
|
|
52
|
+
* @property {string} [icon] Optional `ti-icon` variant name for the section head.
|
|
53
|
+
* @property {boolean} [wide] Claim the full row of the two-up section grid instead of one column.
|
|
54
|
+
* @property {TiInfoItem[]} items A section with no items is dropped rather than rendered empty.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The identity header of the Profile screen — the avatar/name block and the pills beside it.
|
|
59
|
+
*
|
|
60
|
+
* @typedef {Object} TiProfileIdentity
|
|
61
|
+
* @property {string} name
|
|
62
|
+
* @property {string} [subtitle] Meta line under the name (e.g. `role family · specialization · unit`).
|
|
63
|
+
* @property {string} [caption] Secondary line under the subtitle (e.g. the corporate e-mail).
|
|
64
|
+
* @property {string} [avatarSeed] Stable seed for the deterministic avatar colour; defaults to the name.
|
|
65
|
+
* @property {{text: string, tone?: string}} [badge] Small qualifier rendered inside the meta line.
|
|
66
|
+
* @property {Array<{text: string, tone?: string, dot?: boolean, mono?: boolean}>} [tags] Pills beside the name.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The descriptor backing the framework Profile screen.
|
|
71
|
+
*
|
|
72
|
+
* @typedef {Object} TiProfileInfo
|
|
73
|
+
* @property {TiProfileIdentity} identity
|
|
74
|
+
* @property {TiInfoSection[]} sections
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The descriptor backing the framework About screen. Produced by `buildApplicationInfo` and optionally extended by
|
|
79
|
+
* the application through {@link TiWebAppManager#getApplicationInfo}.
|
|
80
|
+
*
|
|
81
|
+
* @typedef {Object} TiApplicationInfo
|
|
82
|
+
* @property {string} name Display name of the application.
|
|
83
|
+
* @property {string} packageName The npm package name it was resolved from.
|
|
84
|
+
* @property {string} version
|
|
85
|
+
* @property {string} releaseDate
|
|
86
|
+
* @property {string} description
|
|
87
|
+
* @property {string} license
|
|
88
|
+
* @property {string} homepage
|
|
89
|
+
* @property {string} author
|
|
90
|
+
* @property {Array<{name: string, version: string}>} components Framework component versions.
|
|
91
|
+
* @property {Object|null} runtime Runtime facts (node/platform/instance), or `null` when withheld.
|
|
92
|
+
* @property {TiInfoSection[]} sections Application-contributed extra sections.
|
|
93
|
+
*/
|