fhirsmith 0.16.1 → 0.18.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
@@ -0,0 +1,872 @@
1
+ // registry/auth.js
2
+ // Securing the registry's API. Three ways, any of which can be configured, together or
3
+ // alone, under modules.registry.security; with none of them the API is open, as it has
4
+ // always been:
5
+ //
6
+ // apiToken - one token the administrator sets, that every API call must carry
7
+ // users - users the administrator (or they themselves) manage on the registry's
8
+ // pages, each with their own tokens (stored hashed, shown once)
9
+ // oidc - JWTs issued by an enterprise identity provider (OAuth2 client credentials
10
+ // for software, the usual login flow for people), verified against the
11
+ // issuer's published keys: issuer, audience, expiry and, if configured, a
12
+ // scope or role
13
+ //
14
+ // Only the JSON API (/ and /resolve answering JSON) is protected. The HTML pages, the
15
+ // OpenAPI description and the login page stay open, so that a client can find out how
16
+ // to authenticate. A token is sent as `Authorization: Bearer {token}` or `Api-Key:
17
+ // {token}` (the two headers the HL7 Java tooling sends for a server in fhir-settings.json);
18
+ // `X-API-Key` is accepted too.
19
+ //
20
+ // Users are mostly software, so a user's token is the credential that matters; the
21
+ // password is for the user's own page (see their tokens and usage, rotate a token).
22
+
23
+ const crypto = require('crypto');
24
+ const path = require('path');
25
+ const express = require('express');
26
+ const session = require('express-session');
27
+ const lusca = require('lusca');
28
+ const rateLimit = require('express-rate-limit');
29
+ const { SqliteSessionStore, betterSqlite } = require('../library/sqlite-session-store');
30
+
31
+ // the requests already authenticated, and who they are (a promise)
32
+ const AUTHENTICATED = new WeakMap();
33
+
34
+ /** What a session keeps of the password it logged in with: enough to tell it changed, no more */
35
+ function passwordStamp(hash) {
36
+ return typeof hash === 'string' && hash ? crypto.createHash('sha256').update(hash).digest('base64url').substring(0, 22) : null;
37
+ }
38
+
39
+ // failed authentications allowed per address per 15 minutes, before 429s
40
+ const DEFAULT_FAILED_AUTH_PER_WINDOW = 60;
41
+ const bcrypt = require('bcrypt');
42
+ const escape = require('escape-html');
43
+ const folders = require('../library/folder-setup');
44
+ const { tokenMatches, tokenConfigured } = require('../library/request-token');
45
+ const { requireSameOrigin } = require('../library/same-origin');
46
+ const { AuthStore } = require('./auth-store');
47
+ const { RegistryDocuments } = require('./documents');
48
+
49
+ const DEFAULT_DATABASE = 'registry-auth.db';
50
+ const DEFAULT_ALGORITHMS = ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512', 'EdDSA'];
51
+ const LOGIN_RE = /^[a-z0-9][a-z0-9._@-]{1,63}$/i;
52
+ const USAGE_FLUSH_MS = 60 * 1000;
53
+
54
+ /**
55
+ * The token a request carries, if any: Authorization: Bearer, Api-Key or X-API-Key.
56
+ * A repeated header (an array) is refused
57
+ */
58
+ function presentedToken(req) {
59
+ const auth = req.headers.authorization;
60
+ if (typeof auth === 'string') {
61
+ const m = /^\s*Bearer\s+(\S+)\s*$/i.exec(auth);
62
+ if (m) {
63
+ return { token: m[1], scheme: 'bearer' };
64
+ }
65
+ }
66
+ for (const name of ['api-key', 'x-api-key']) {
67
+ const v = req.headers[name];
68
+ if (typeof v === 'string' && v.trim()) {
69
+ return { token: v.trim(), scheme: 'api-key' };
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function looksLikeJwt(token) {
76
+ return typeof token === 'string' && /^[\w-]+\.[\w-]+\.[\w-]*$/.test(token);
77
+ }
78
+
79
+ class RegistryAuth {
80
+ /**
81
+ * @param {object} config modules.registry.security
82
+ * @param {object} options
83
+ * @param {object} options.logger
84
+ * @param {function} options.renderPage (req, res, title, content, status) => void - the
85
+ * registry's page rendering, so these pages look like the rest
86
+ */
87
+ constructor(config, { logger, renderPage }) {
88
+ this.config = config || {};
89
+ this.log = logger || console;
90
+ this.renderPage = renderPage;
91
+ this.apiToken = tokenConfigured(this.config.apiToken) ? this.config.apiToken : null;
92
+ this.usersConfig = this.config.users && typeof this.config.users === 'object' ? this.config.users : null;
93
+ this.oidcConfig = this.config.oidc && typeof this.config.oidc === 'object' ? this.config.oidc : null;
94
+ this.store = null;
95
+ this.jwks = null;
96
+ this.jose = null;
97
+ this.flushTimer = null;
98
+ }
99
+
100
+ /** Is the API secured at all? */
101
+ get enabled() {
102
+ return !!(this.apiToken || this.usersConfig || this.oidcConfig);
103
+ }
104
+
105
+ /** The configured methods, for the log and the pages */
106
+ get methods() {
107
+ const m = [];
108
+ if (this.apiToken) m.push('api token');
109
+ if (this.usersConfig) m.push('users');
110
+ if (this.oidcConfig) m.push(`oidc (${this.oidcConfig.issuer})`);
111
+ return m;
112
+ }
113
+
114
+ async initialize() {
115
+ if (this.usersConfig) {
116
+ const db = this.usersConfig.database || DEFAULT_DATABASE;
117
+ const dbPath = db === ':memory:' || path.isAbsolute(db) ? db : path.join(folders.databasesDir(), db);
118
+ if (dbPath !== ':memory:') {
119
+ require('fs').mkdirSync(path.dirname(dbPath), { recursive: true });
120
+ }
121
+ this.store = new AuthStore(dbPath);
122
+ this.adminUsername = typeof this.usersConfig.adminUsername === 'string' && this.usersConfig.adminUsername ? this.usersConfig.adminUsername : 'admin';
123
+ this.adminHash = tokenConfigured(this.usersConfig.adminPassword) ? bcrypt.hashSync(this.usersConfig.adminPassword, 10) : null;
124
+ // compared against when a login names no user, so that takes as long as a real one
125
+ this.dummyHash = bcrypt.hashSync(crypto.randomBytes(16).toString('hex'), 10);
126
+ if (!this.adminHash) {
127
+ this.log.warn('Registry security: users are configured but there is no adminPassword - nobody can create users');
128
+ }
129
+ this.setupSession();
130
+ this.flushTimer = setInterval(() => {
131
+ try {
132
+ this.store.flush();
133
+ } catch (e) {
134
+ this.log.error(`Registry security: could not write usage: ${e.message}`);
135
+ }
136
+ }, USAGE_FLUSH_MS);
137
+ this.flushTimer.unref();
138
+ }
139
+ if (this.oidcConfig) {
140
+ this.setupOidc();
141
+ }
142
+ if (this.enabled) {
143
+ this.log.info(`Registry security: API requires authentication (${this.methods.join(', ')})`);
144
+ }
145
+ }
146
+
147
+ shutdown() {
148
+ if (this.sessionStore) {
149
+ this.sessionStore.close();
150
+ }
151
+ if (this.flushTimer) {
152
+ clearInterval(this.flushTimer);
153
+ this.flushTimer = null;
154
+ }
155
+ if (this.store) {
156
+ this.store.close();
157
+ this.store = null;
158
+ }
159
+ }
160
+
161
+ // ---- authenticating API requests ----
162
+
163
+ /**
164
+ * Who is making this request, by any of the configured methods
165
+ * @returns {Promise<{kind: string, principal: string, name: string}|null>}
166
+ */
167
+ /**
168
+ * Who the request's token is, or null - worked out once per request (the limiter and the
169
+ * guard both ask)
170
+ */
171
+ authenticate(req) {
172
+ if (!AUTHENTICATED.has(req)) {
173
+ AUTHENTICATED.set(req, this.authenticateToken(req));
174
+ }
175
+ return AUTHENTICATED.get(req);
176
+ }
177
+
178
+ async authenticateToken(req) {
179
+ const presented = presentedToken(req);
180
+ if (!presented) {
181
+ return null;
182
+ }
183
+ const { token } = presented;
184
+ if (this.oidcConfig && looksLikeJwt(token)) {
185
+ return this.verifyJwt(token);
186
+ }
187
+ if (this.apiToken && tokenMatches(this.apiToken, token)) {
188
+ return { kind: 'api-token', principal: 'api-token', name: 'API token' };
189
+ }
190
+ if (this.store) {
191
+ const t = this.store.authenticateToken(token);
192
+ if (t) {
193
+ return { kind: 'token', principal: `token:${t.tokenId}`, name: t.login, userId: t.userId };
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * The session middleware where users are configured (so a page can say who is logged
201
+ * in), and nothing otherwise. It makes no session for a visitor without one
202
+ * (saveUninitialized is false).
203
+ */
204
+ optionalSession() {
205
+ return (req, res, next) => (this.session ? this.session(req, res, next) : next());
206
+ }
207
+
208
+ /**
209
+ * Express middleware for the JSON API, before apiGuard(): a limit on failed
210
+ * authentications - only the requests answered 401 count, and a request with a valid token
211
+ * is never held back, so a busy client with a good token is never slowed - per address, `failedAuthPerWindow` (default 60) in 15 minutes;
212
+ * past it, the address gets 429 for the rest of the window. Guessing tokens, and making
213
+ * the server check one bad JWT after another, is what it stops; general volume is the
214
+ * proxy's business. Does nothing when no security is configured, or for the HTML pages
215
+ * (which apiGuard lets through), or when failedAuthPerWindow is 0.
216
+ */
217
+ apiLimiter() {
218
+ const limit = Number.isInteger(this.config.failedAuthPerWindow) && this.config.failedAuthPerWindow >= 0 ? this.config.failedAuthPerWindow : DEFAULT_FAILED_AUTH_PER_WINDOW;
219
+ if (limit === 0) {
220
+ return (req, res, next) => next();
221
+ }
222
+ return rateLimit({
223
+ windowMs: 15 * 60 * 1000,
224
+ limit,
225
+ standardHeaders: 'draft-7',
226
+ legacyHeaders: false,
227
+ // the token first: a valid one is never limited, even from an address over the limit
228
+ // (a NAT with one client retrying a bad token doesn't lock out the others behind it)
229
+ skip: async (req) => !this.enabled || (req.headers.accept || '').includes('text/html') || (await this.authenticate(req).catch(() => null)) !== null,
230
+ requestWasSuccessful: (req, res) => res.statusCode !== 401,
231
+ skipSuccessfulRequests: true,
232
+ handler: (req, res) => res.status(429).json({ error: 'too-many-requests', message: 'Too many requests with a token that is not valid for this registry; try again later' })
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Express middleware for the JSON API: lets an authenticated request through (and
238
+ * counts it), and answers 401 otherwise. Does nothing when no security is configured
239
+ */
240
+ apiGuard() {
241
+ return (req, res, next) => {
242
+ if (!this.enabled) {
243
+ return next();
244
+ }
245
+ this.authenticate(req).then(who => {
246
+ if (!who) {
247
+ return this.unauthorized(req, res);
248
+ }
249
+ req.registryUser = who;
250
+ if (this.store) {
251
+ this.store.countRequest(who.principal, who.kind);
252
+ }
253
+ next();
254
+ }).catch(e => {
255
+ this.log.error(`Registry security: ${e.message}`);
256
+ this.unauthorized(req, res);
257
+ });
258
+ };
259
+ }
260
+
261
+ unauthorized(req, res) {
262
+ const challenge = [];
263
+ if (this.oidcConfig || this.apiToken || this.store) {
264
+ challenge.push('Bearer realm="tx-reg"');
265
+ }
266
+ res.setHeader('WWW-Authenticate', challenge.join(', ') || 'Bearer');
267
+ const presented = presentedToken(req);
268
+ res.status(401).json({
269
+ error: 'unauthorized',
270
+ message: presented ? 'The token is not valid for this registry' :
271
+ 'This registry requires a token: send it as Authorization: Bearer {token} or Api-Key: {token}',
272
+ documentation: `${req.baseUrl || ''}/openapi`
273
+ });
274
+ }
275
+
276
+ // ---- OIDC / JWT ----
277
+
278
+ setupOidc() {
279
+ const c = this.oidcConfig;
280
+ if (typeof c.issuer !== 'string' || !/^https:\/\//.test(c.issuer)) {
281
+ throw new Error('modules.registry.security.oidc.issuer must be an https URL');
282
+ }
283
+ if (!c.audience || (typeof c.audience !== 'string' && !Array.isArray(c.audience))) {
284
+ throw new Error('modules.registry.security.oidc.audience is required (a string or list of strings)');
285
+ }
286
+ // https, except on loopback (tests, and a key server on the same box)
287
+ if (c.jwksUri !== undefined && !/^https:\/\//.test(String(c.jwksUri)) && !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\//.test(String(c.jwksUri))) {
288
+ throw new Error('modules.registry.security.oidc.jwksUri must be an https URL');
289
+ }
290
+ try {
291
+ this.jose = require('jose');
292
+ } catch (e) {
293
+ throw new Error('OIDC security needs the jose package: npm install jose');
294
+ }
295
+ // asymmetric only: a shared HMAC secret would defeat the point of an identity provider
296
+ this.algorithms = (Array.isArray(c.algorithms) && c.algorithms.length > 0 ? c.algorithms : DEFAULT_ALGORITHMS)
297
+ .filter(a => typeof a === 'string' && !/^HS/i.test(a));
298
+ if (this.algorithms.length === 0) {
299
+ throw new Error('modules.registry.security.oidc.algorithms must name at least one asymmetric algorithm (RS256, ES256, ...)');
300
+ }
301
+ this.requiredScope = typeof c.requiredScope === 'string' && c.requiredScope ? c.requiredScope : null;
302
+ this.jwks = c.jwksUri ? this.makeJwks(c.jwksUri) : null; // else discovered on first use
303
+ this.discovering = null;
304
+ }
305
+
306
+ makeJwks(uri) {
307
+ return this.jose.createRemoteJWKSet(new URL(uri), { cooldownDuration: 30000, timeoutDuration: 10000 });
308
+ }
309
+
310
+ /** The JWKS, from the issuer's discovery document when no jwksUri is configured */
311
+ async getJwks() {
312
+ if (this.jwks) {
313
+ return this.jwks;
314
+ }
315
+ if (!this.discovering) {
316
+ const url = this.oidcConfig.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
317
+ this.discovering = (async () => {
318
+ const res = await fetch(url, { signal: AbortSignal.timeout(10000), headers: { accept: 'application/json' } });
319
+ if (!res.ok) {
320
+ throw new Error(`HTTP ${res.status} from ${url}`);
321
+ }
322
+ const doc = await res.json();
323
+ if (typeof doc.jwks_uri !== 'string' || !/^https:\/\//.test(doc.jwks_uri)) {
324
+ throw new Error(`${url} has no https jwks_uri`);
325
+ }
326
+ this.jwks = this.makeJwks(doc.jwks_uri);
327
+ this.log.info(`Registry security: OIDC keys from ${doc.jwks_uri}`);
328
+ return this.jwks;
329
+ })().catch(e => {
330
+ this.discovering = null; // try again next time
331
+ throw new Error(`OIDC discovery failed: ${e.message}`);
332
+ });
333
+ }
334
+ return this.discovering;
335
+ }
336
+
337
+ /**
338
+ * Verify a JWT against the issuer: signature, issuer, audience, time, and the
339
+ * required scope (in scp, scope or roles) if there is one
340
+ */
341
+ async verifyJwt(token) {
342
+ let payload;
343
+ try {
344
+ const jwks = await this.getJwks();
345
+ ({ payload } = await this.jose.jwtVerify(token, jwks, {
346
+ issuer: this.oidcConfig.issuer,
347
+ audience: this.oidcConfig.audience,
348
+ algorithms: this.algorithms,
349
+ // jose checks exp only when the claim is there: a token without one would never expire
350
+ requiredClaims: ['exp'],
351
+ clockTolerance: 60
352
+ }));
353
+ } catch (e) {
354
+ this.log.info(`Registry security: JWT rejected: ${e.code || e.name}: ${e.message}`);
355
+ return null;
356
+ }
357
+ if (this.requiredScope && !this.hasScope(payload, this.requiredScope)) {
358
+ this.log.info(`Registry security: JWT for ${payload.sub} lacks ${this.requiredScope}`);
359
+ return null;
360
+ }
361
+ const client = payload.azp || payload.client_id || payload.appid || payload.sub || '?';
362
+ return { kind: 'oidc', principal: `oidc:${client}`, name: String(client), claims: payload };
363
+ }
364
+
365
+ hasScope(payload, wanted) {
366
+ const values = [];
367
+ for (const claim of ['scp', 'scope']) {
368
+ const v = payload[claim];
369
+ if (typeof v === 'string') {
370
+ values.push(...v.split(/\s+/));
371
+ } else if (Array.isArray(v)) {
372
+ values.push(...v);
373
+ }
374
+ }
375
+ if (Array.isArray(payload.roles)) {
376
+ values.push(...payload.roles);
377
+ }
378
+ return values.includes(wanted);
379
+ }
380
+
381
+ // ---- the pages: login, users, account ----
382
+
383
+ setupSession() {
384
+ if (!this.usersConfig.sessionSecret) {
385
+ this.log.warn('Registry security: no users.sessionSecret configured - using a random one, so logins will not survive a restart');
386
+ }
387
+ // sessions live in the auth database, so logins survive a restart (with a configured
388
+ // sessionSecret); expired ones are cleaned up daily
389
+ this.sessionStore = new SqliteSessionStore({ db: betterSqlite(this.store.db), table: 'sessions' });
390
+ this.session = session({
391
+ name: 'txreg.sid',
392
+ store: this.sessionStore,
393
+ secret: this.usersConfig.sessionSecret || crypto.randomBytes(64).toString('hex'),
394
+ resave: false,
395
+ saveUninitialized: false,
396
+ cookie: {
397
+ secure: this.usersConfig.cookieSecure ?? true,
398
+ sameSite: 'lax',
399
+ httpOnly: true,
400
+ maxAge: 24 * 60 * 60 * 1000
401
+ }
402
+ });
403
+ this.csrf = lusca.csrf({ key: '_csrf' });
404
+ // for the pages a visitor reads but posts nothing from: the CSRF token (and with it a
405
+ // session) only for a logged in user, who may have forms there
406
+ this.csrfIfLoggedIn = (req, res, next) => (this.currentUser(req) ? this.csrf(req, res, next) : next());
407
+ this.form = express.urlencoded({ extended: false, limit: '64kb' });
408
+ this.sameOrigin = requireSameOrigin((req, res) => this.sendError(req, res, 403, 'Cross-site form posts are not accepted'));
409
+ }
410
+
411
+ /**
412
+ * Add the login, administration and account routes to the registry's router. Only
413
+ * when users are configured - the other methods have no pages
414
+ */
415
+ setupRoutes(router) {
416
+ if (!this.store) {
417
+ return;
418
+ }
419
+ const r = router;
420
+ const loginLimiter = rateLimit({
421
+ windowMs: 15 * 60 * 1000,
422
+ limit: this.usersConfig.loginAttemptsPerWindow ?? 20,
423
+ standardHeaders: 'draft-7',
424
+ legacyHeaders: false,
425
+ handler: (req, res) => this.sendLogin(req, res, 429, 'Too many login attempts - try again later')
426
+ });
427
+ const admin = (req, res, next) => {
428
+ const user = this.currentUser(req);
429
+ if (!user) {
430
+ return res.redirect(req.baseUrl + '/login');
431
+ }
432
+ if (!user.isAdmin) {
433
+ return this.sendError(req, res, 403, 'Only the administrator can do that');
434
+ }
435
+ req.user = user;
436
+ next();
437
+ };
438
+ // a user's own page: any user with a record (an administrator too); the configured
439
+ // administrator has no record, and so no tokens of its own
440
+ const self = (req, res, next) => {
441
+ const user = this.currentUser(req);
442
+ if (!user) {
443
+ return res.redirect(req.baseUrl + '/login');
444
+ }
445
+ if (user.id === 0) {
446
+ return res.redirect(req.baseUrl + '/admin/users');
447
+ }
448
+ req.user = user;
449
+ next();
450
+ };
451
+ const h = fn => (req, res) => Promise.resolve().then(() => fn(req, res)).catch(e => {
452
+ this.log.error(`Registry security page: ${e.message}`);
453
+ this.sendError(req, res, 500, e.message);
454
+ });
455
+
456
+ // Every page with a form has this.session, this.csrf; every form post has sameOrigin,
457
+ // this.session, form, this.csrf - written out per route so the CSRF middleware is
458
+ // visible on every cookie-using route
459
+ r.get('/login', this.session, this.csrf, h((req, res) => this.sendLogin(req, res, 200)));
460
+ r.post('/login', this.sameOrigin, loginLimiter, this.session, this.form, this.csrf, h((req, res) => this.login(req, res)));
461
+ r.post('/logout', this.sameOrigin, this.session, this.form, this.csrf, h((req, res) => {
462
+ req.session.destroy(() => res.redirect(req.baseUrl || '/'));
463
+ }));
464
+
465
+ r.get('/admin/users', this.session, this.csrf, admin, h((req, res) => this.sendUsers(req, res)));
466
+ r.post('/admin/users', this.sameOrigin, this.session, this.form, this.csrf, admin, h((req, res) => this.createUser(req, res)));
467
+ r.get('/admin/users/:id', this.session, this.csrf, admin, h((req, res) => this.sendUser(req, res)));
468
+ r.post('/admin/users/:id', this.sameOrigin, this.session, this.form, this.csrf, admin, h((req, res) => this.updateUser(req, res)));
469
+ r.post('/admin/users/:id/password', this.sameOrigin, this.session, this.form, this.csrf, admin, h((req, res) => this.adminSetPassword(req, res)));
470
+ r.post('/admin/users/:id/delete', this.sameOrigin, this.session, this.form, this.csrf, admin, h((req, res) => this.deleteUser(req, res)));
471
+ r.post('/admin/users/:id/tokens', this.sameOrigin, this.session, this.form, this.csrf, admin, h((req, res) => this.issueToken(req, res, this.userParam(req), `/admin/users/${this.userParam(req)}`)));
472
+ r.post('/admin/users/:id/tokens/:tid/revoke', this.sameOrigin, this.session, this.form, this.csrf, admin, h((req, res) => this.revokeToken(req, res, this.userParam(req), `/admin/users/${this.userParam(req)}`)));
473
+
474
+ r.get('/account', this.session, this.csrf, self, h((req, res) => this.sendAccount(req, res)));
475
+ r.post('/account/tokens', this.sameOrigin, this.session, this.form, this.csrf, self, h((req, res) => this.issueToken(req, res, req.user.id, '/account')));
476
+ r.post('/account/tokens/:tid/revoke', this.sameOrigin, this.session, this.form, this.csrf, self, h((req, res) => this.revokeToken(req, res, req.user.id, '/account')));
477
+ r.post('/account/password', this.sameOrigin, this.session, this.form, this.csrf, self, h((req, res) => this.changePassword(req, res)));
478
+
479
+ // the documents live with the users: same database, same sessions, same administrators
480
+ this.documents = new RegistryDocuments(this, this.log);
481
+ this.documents.setupRoutes(r);
482
+ }
483
+
484
+ /** The navbar links for these pages; the registry puts them in its template */
485
+ navLinks(req) {
486
+ if (!this.store) {
487
+ return '';
488
+ }
489
+ // pages rendered without a request (the registry's own) link as the template does
490
+ const base = escape(typeof req.baseUrl === 'string' ? req.baseUrl : '/tx-reg');
491
+ const user = req.session ? this.currentUser(req) : null;
492
+ let h = ` &nbsp;|&nbsp; <a href="${base}/docs" style="color: gold">Documents</a>`;
493
+ if (user && user.isAdmin) {
494
+ h += ` &nbsp;|&nbsp; <a href="${base}/admin/users" style="color: gold">Users</a>`;
495
+ }
496
+ if (user && user.id > 0) {
497
+ h += ` &nbsp;|&nbsp; <a href="${base}/account" style="color: gold">${escape(user.login)}</a>`;
498
+ } else if (!user) {
499
+ h += ` &nbsp;|&nbsp; <a href="${base}/login" style="color: gold">Login</a>`;
500
+ }
501
+ return h;
502
+ }
503
+
504
+ currentUser(req) {
505
+ const s = req.session;
506
+ if (!s) {
507
+ return null;
508
+ }
509
+ if (s.regAdmin) {
510
+ return { id: 0, login: this.adminUsername, isAdmin: true };
511
+ }
512
+ if (s.regUserId) {
513
+ const u = this.store.userById(s.regUserId);
514
+ // the password the session logged in with must still be the user's: a changed or
515
+ // removed password ends the user's other sessions
516
+ if (u && u.enabled && s.regPwd === passwordStamp(u.password_hash)) {
517
+ return { id: u.id, login: u.login, name: u.name, isAdmin: u.admin };
518
+ }
519
+ }
520
+ return null;
521
+ }
522
+
523
+ /** The logged in user (session), for other parts of the registry; null if none */
524
+ sessionUser(req) {
525
+ return this.store ? this.currentUser(req) : null;
526
+ }
527
+
528
+ userParam(req) {
529
+ const id = Number(req.params.id);
530
+ return Number.isInteger(id) && id > 0 ? id : 0;
531
+ }
532
+
533
+ // ---- login ----
534
+
535
+ sendLogin(req, res, status, error) {
536
+ const base = escape(req.baseUrl || '');
537
+ let h = alert(error, 'danger');
538
+ h += `<form method="post" action="${base}/login" style="max-width: 360px">${csrfField(res)}` +
539
+ '<div class="mb-3"><label for="login" class="form-label">Username</label>' +
540
+ '<input type="text" class="form-control" id="login" name="login" required autocomplete="username"/></div>' +
541
+ '<div class="mb-3"><label for="password" class="form-label">Password</label>' +
542
+ '<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password"/></div>' +
543
+ '<button type="submit" class="btn btn-primary">Login</button></form>';
544
+ h += '<p class="text-muted" style="margin-top: 1em"><small>Software clients don\'t log in: they send their token as ' +
545
+ '<code>Authorization: Bearer {token}</code> or <code>Api-Key: {token}</code>.</small></p>';
546
+ this.renderPage(req, res, 'Login', h, status);
547
+ }
548
+
549
+ async login(req, res) {
550
+ const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
551
+ const password = typeof req.body.password === 'string' ? req.body.password : '';
552
+ const shown = login.replace(/[^\x20-\x7e]/g, '?').substring(0, 64);
553
+ let ok = false;
554
+ let target = '/account';
555
+ const regenerate = () => new Promise((resolve, reject) => req.session.regenerate(e => e ? reject(e) : resolve()));
556
+ if (login === this.adminUsername) {
557
+ if (this.adminHash && await bcrypt.compare(password, this.adminHash)) {
558
+ await regenerate();
559
+ req.session.regAdmin = true;
560
+ ok = true;
561
+ target = '/admin/users';
562
+ }
563
+ } else {
564
+ const user = login ? this.store.userByLogin(login) : null;
565
+ // the comparison is made whether or not there is such a user, so the time taken
566
+ // doesn't say which usernames exist
567
+ const hash = user && user.password_hash ? user.password_hash : this.dummyHash;
568
+ if (await bcrypt.compare(password, hash) && user && user.password_hash && user.enabled) {
569
+ await regenerate();
570
+ req.session.regUserId = user.id;
571
+ req.session.regPwd = passwordStamp(user.password_hash);
572
+ ok = true;
573
+ if (user.admin) {
574
+ target = '/admin/users';
575
+ }
576
+ }
577
+ }
578
+ if (!ok) {
579
+ this.log.info(`Registry security: failed login for '${shown}' from ${req.ip}`);
580
+ return this.sendLogin(req, res, 401, 'Unknown username or wrong password');
581
+ }
582
+ this.log.info(`Registry security: '${shown}' logged in from ${req.ip}`);
583
+ res.redirect((req.baseUrl || '') + target);
584
+ }
585
+
586
+ // ---- administration ----
587
+
588
+ sendUsers(req, res, message, status = 200) {
589
+ const base = escape(req.baseUrl || '');
590
+ const users = this.store.users();
591
+ let h = userBar(req, res) + alert(message);
592
+ h += '<p>Each user is a client of the API - usually a piece of software - with its own tokens. ' +
593
+ 'A user with a password can log in here to see its tokens and usage and rotate them; one without can\'t. ' +
594
+ 'An administrator can also manage users and edit documents.</p>';
595
+ h += '<table class="grid"><thead><tr><th>Login</th><th>Name</th><th>Enabled</th><th>Admin</th><th>Tokens</th><th>Requests</th><th>Last used</th><th>Notes</th></tr></thead><tbody>';
596
+ for (const u of users) {
597
+ const tokens = this.store.tokensForUser(u.id);
598
+ const active = tokens.filter(t => t.status === 'active');
599
+ const requests = tokens.reduce((n, t) => n + (t.usage ? t.usage.requests : 0), 0);
600
+ const last = tokens.map(t => t.usage ? t.usage.lastSeen : '').filter(x => x).sort().pop() || '';
601
+ h += `<tr><td><a href="${base}/admin/users/${u.id}">${escape(u.login)}</a></td><td>${escape(u.name)}</td>` +
602
+ `<td>${u.enabled ? 'yes' : '<span class="text-danger">no</span>'}</td><td>${u.admin ? 'yes' : ''}</td>` +
603
+ `<td>${active.length} active${tokens.length > active.length ? ` (${tokens.length - active.length} revoked/expired)` : ''}</td>` +
604
+ `<td>${requests}</td><td>${escape(ago(last))}</td><td>${escape(u.notes)}</td></tr>`;
605
+ }
606
+ if (users.length === 0) {
607
+ h += '<tr><td colspan="8"><i>no users yet</i></td></tr>';
608
+ }
609
+ h += '</tbody></table>';
610
+
611
+ h += `<h4 style="margin-top: 1.5em">New user</h4><form method="post" action="${base}/admin/users" style="max-width: 480px">${csrfField(res)}` +
612
+ field('login', 'Login', 'text', 'Letters, digits, . _ @ -; 2-64 characters', true) +
613
+ field('name', 'Name', 'text', 'Who or what this is') +
614
+ field('password', 'Password', 'password', 'Optional: lets the user log in here') +
615
+ field('notes', 'Notes', 'text') +
616
+ '<div class="mb-3"><label><input type="checkbox" name="admin" value="1"/> Administrator (can manage users and edit documents)</label></div>' +
617
+ '<button type="submit" class="btn btn-primary">Create user</button></form>';
618
+
619
+ const other = this.store.otherUsage();
620
+ if (other.length > 0 || this.apiToken || this.oidcConfig) {
621
+ h += '<h4 style="margin-top: 1.5em">Other clients</h4>';
622
+ h += '<p class="text-muted"><small>Requests made with the configured API token, and by OIDC clients (by their client id).</small></p>';
623
+ h += '<table class="grid"><thead><tr><th>Client</th><th>Kind</th><th>Requests</th><th>First seen</th><th>Last seen</th></tr></thead><tbody>';
624
+ for (const u of other) {
625
+ h += `<tr><td>${escape(u.principal.replace(/^oidc:/, ''))}</td><td>${escape(u.kind)}</td><td>${u.requests}</td>` +
626
+ `<td>${escape(day(u.first_seen))}</td><td>${escape(ago(u.last_seen))}</td></tr>`;
627
+ }
628
+ if (other.length === 0) {
629
+ h += '<tr><td colspan="5"><i>none yet</i></td></tr>';
630
+ }
631
+ h += '</tbody></table>';
632
+ }
633
+ res.status(status);
634
+ this.renderPage(req, res, 'Registry Users', h, status);
635
+ }
636
+
637
+ async createUser(req, res) {
638
+ const login = str(req.body.login).trim();
639
+ const name = str(req.body.name).trim().substring(0, 200);
640
+ const password = str(req.body.password);
641
+ const notes = str(req.body.notes).trim().substring(0, 1000);
642
+ if (!LOGIN_RE.test(login) || login === this.adminUsername) {
643
+ return this.sendUsers(req, res, { text: 'The login must be 2-64 letters, digits, dots, underscores, @ or hyphens, and not the administrator\'s', level: 'danger' }, 400);
644
+ }
645
+ if (password && password.length < 8) {
646
+ return this.sendUsers(req, res, { text: 'A password must be at least 8 characters', level: 'danger' }, 400);
647
+ }
648
+ if (this.store.userByLogin(login)) {
649
+ return this.sendUsers(req, res, { text: `There is already a user '${login}'`, level: 'danger' }, 400);
650
+ }
651
+ const id = await this.store.createUser({ login, name, password: password || null, notes, admin: req.body.admin === '1' });
652
+ this.log.info(`Registry security: user '${login}' created by ${req.user.login}`);
653
+ res.redirect(`${req.baseUrl || ''}/admin/users/${id}`);
654
+ }
655
+
656
+ sendUser(req, res, message, status = 200) {
657
+ const base = escape(req.baseUrl || '');
658
+ const user = this.store.userById(this.userParam(req));
659
+ if (!user) {
660
+ return this.sendError(req, res, 404, 'No such user');
661
+ }
662
+ let h = userBar(req, res) + `<p><a href="${base}/admin/users">&laquo; all users</a></p>` + alert(message) + this.newTokenBanner(req);
663
+ h += `<form method="post" action="${base}/admin/users/${user.id}" style="max-width: 480px">${csrfField(res)}` +
664
+ `<p><b>Login:</b> ${escape(user.login)} &nbsp; <b>Created:</b> ${escape(day(user.created_at))}</p>` +
665
+ field('name', 'Name', 'text', '', false, user.name) +
666
+ field('notes', 'Notes', 'text', '', false, user.notes) +
667
+ `<div class="mb-3"><label><input type="checkbox" name="enabled" value="1"${user.enabled ? ' checked' : ''}/> Enabled (a disabled user's tokens don't work, and it can't log in)</label></div>` +
668
+ `<div class="mb-3"><label><input type="checkbox" name="admin" value="1"${user.admin ? ' checked' : ''}/> Administrator (can manage users and edit documents)</label></div>` +
669
+ '<button type="submit" class="btn btn-primary">Save</button></form>';
670
+
671
+ h += this.tokensSection(res, this.store.tokensForUser(user.id), `${base}/admin/users/${user.id}/tokens`, true);
672
+
673
+ h += `<h4 style="margin-top: 1.5em">Password</h4><p class="text-muted"><small>${user.hasPassword ? 'The user has a password and can log in here.' : 'The user has no password, so it can\'t log in here; its tokens still work.'}</small></p>` +
674
+ `<form method="post" action="${base}/admin/users/${user.id}/password" style="max-width: 480px">${csrfField(res)}` +
675
+ field('password', 'New password', 'password', 'At least 8 characters; empty removes the password') +
676
+ '<button type="submit" class="btn btn-secondary">Set password</button></form>';
677
+
678
+ h += `<h4 style="margin-top: 1.5em">Delete</h4><form method="post" action="${base}/admin/users/${user.id}/delete" onsubmit="return confirm('Delete this user and all its tokens?')">${csrfField(res)}` +
679
+ '<button type="submit" class="btn btn-danger">Delete user</button></form>';
680
+ res.status(status);
681
+ this.renderPage(req, res, `User ${user.login}`, h, status);
682
+ }
683
+
684
+ updateUser(req, res) {
685
+ const id = this.userParam(req);
686
+ const ok = this.store.updateUser(id, {
687
+ name: str(req.body.name).trim().substring(0, 200),
688
+ notes: str(req.body.notes).trim().substring(0, 1000),
689
+ enabled: req.body.enabled === '1',
690
+ admin: req.body.admin === '1'
691
+ });
692
+ if (!ok) {
693
+ return this.sendError(req, res, 404, 'No such user');
694
+ }
695
+ return this.sendUser(req, res, { text: 'Saved' });
696
+ }
697
+
698
+ async adminSetPassword(req, res) {
699
+ const id = this.userParam(req);
700
+ const password = str(req.body.password);
701
+ if (password && password.length < 8) {
702
+ return this.sendUser(req, res, { text: 'A password must be at least 8 characters', level: 'danger' }, 400);
703
+ }
704
+ if (!(await this.store.setPassword(id, password || null))) {
705
+ return this.sendError(req, res, 404, 'No such user');
706
+ }
707
+ return this.sendUser(req, res, { text: password ? 'Password set' : 'Password removed' });
708
+ }
709
+
710
+ deleteUser(req, res) {
711
+ const user = this.store.userById(this.userParam(req));
712
+ if (!user) {
713
+ return this.sendError(req, res, 404, 'No such user');
714
+ }
715
+ this.store.deleteUser(user.id);
716
+ this.log.info(`Registry security: user '${user.login}' deleted by ${req.user.login}`);
717
+ return this.sendUsers(req, res, { text: `User '${user.login}' deleted` });
718
+ }
719
+
720
+ // ---- tokens (admin and self) ----
721
+
722
+ issueToken(req, res, userId, back) {
723
+ const user = this.store.userById(userId);
724
+ if (!user) {
725
+ return this.sendError(req, res, 404, 'No such user');
726
+ }
727
+ const label = str(req.body.label).trim().substring(0, 100);
728
+ const days = Number(str(req.body.expires));
729
+ const expiresInDays = Number.isFinite(days) && days > 0 ? Math.min(Math.floor(days), 3650) : null;
730
+ const t = this.store.issueToken(user.id, { label, expiresInDays });
731
+ this.log.info(`Registry security: token ${t.prefix} issued for '${user.login}' by ${req.user.login}`);
732
+ // shown once, on the next page
733
+ req.session.newToken = t.token;
734
+ res.redirect((req.baseUrl || '') + back);
735
+ }
736
+
737
+ revokeToken(req, res, userId, back) {
738
+ const tid = Number(req.params.tid);
739
+ if (this.store.revokeToken(userId, tid)) {
740
+ const user = this.store.userById(userId);
741
+ this.log.info(`Registry security: token ${tid} of '${user ? user.login : userId}' revoked by ${req.user.login}`);
742
+ }
743
+ res.redirect((req.baseUrl || '') + back);
744
+ }
745
+
746
+ newTokenBanner(req) {
747
+ const token = req.session.newToken;
748
+ if (!token) {
749
+ return '';
750
+ }
751
+ delete req.session.newToken;
752
+ return '<div class="alert alert-success"><b>New token:</b> <code style="font-size: 110%">' + escape(token) + '</code>' +
753
+ '<br/><small>Copy it now - it is not stored and cannot be shown again. Send it as <code>Authorization: Bearer ' +
754
+ escape(token.substring(0, 12)) + '...</code></small></div>';
755
+ }
756
+
757
+ tokensSection(res, tokens, issueUrl, forAdmin) {
758
+ let h = '<h4 style="margin-top: 1.5em">Tokens</h4>';
759
+ h += '<table class="grid"><thead><tr><th>Token</th><th>Label</th><th>Status</th><th>Created</th><th>Expires</th><th>Requests</th><th>Last used</th><th></th></tr></thead><tbody>';
760
+ for (const t of tokens) {
761
+ const cls = t.status === 'active' ? 'text-success' : 'text-muted';
762
+ h += `<tr><td><code>${escape(t.prefix)}...</code></td><td>${escape(t.label)}</td><td><span class="${cls}">${t.status}</span></td>` +
763
+ `<td>${escape(day(t.created_at))}</td><td>${escape(day(t.expires_at) || 'never')}</td>` +
764
+ `<td>${t.usage ? t.usage.requests : 0}</td><td>${escape(ago(t.usage ? t.usage.lastSeen : ''))}</td><td>`;
765
+ if (t.status === 'active') {
766
+ h += `<form method="post" action="${issueUrl}/${t.id}/revoke" style="display: inline">${csrfField(res)}<button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button></form>`;
767
+ }
768
+ h += '</td></tr>';
769
+ }
770
+ if (tokens.length === 0) {
771
+ h += '<tr><td colspan="8"><i>no tokens</i></td></tr>';
772
+ }
773
+ h += '</tbody></table>';
774
+ h += `<form method="post" action="${issueUrl}" class="row g-2" style="max-width: 640px; margin-top: 0.5em">${csrfField(res)}` +
775
+ '<div class="col"><input type="text" class="form-control" name="label" placeholder="Label (optional)" maxlength="100"/></div>' +
776
+ '<div class="col"><input type="number" class="form-control" name="expires" placeholder="Expires in days (optional)" min="1" max="3650"/></div>' +
777
+ `<div class="col-auto"><button type="submit" class="btn btn-primary">${forAdmin ? 'Issue token' : 'New token'}</button></div></form>`;
778
+ h += '<p class="text-muted"><small>To rotate a token: issue a new one, move the client to it, then revoke the old one.</small></p>';
779
+ return h;
780
+ }
781
+
782
+ // ---- a user's own page ----
783
+
784
+ sendAccount(req, res, message, status = 200) {
785
+ const base = escape(req.baseUrl || '');
786
+ let h = userBar(req, res) + alert(message) + this.newTokenBanner(req);
787
+ h += `<p>Logged in as <b>${escape(req.user.login)}</b>${req.user.name ? ` (${escape(req.user.name)})` : ''}.</p>`;
788
+ h += this.tokensSection(res, this.store.tokensForUser(req.user.id), `${base}/account/tokens`, false);
789
+ h += `<h4 style="margin-top: 1.5em">Change password</h4><form method="post" action="${base}/account/password" style="max-width: 480px">${csrfField(res)}` +
790
+ field('current', 'Current password', 'password', '', true) +
791
+ field('password', 'New password', 'password', 'At least 8 characters', true) +
792
+ '<button type="submit" class="btn btn-secondary">Change password</button></form>';
793
+ res.status(status);
794
+ this.renderPage(req, res, 'Your Account', h, status);
795
+ }
796
+
797
+ async changePassword(req, res) {
798
+ const user = this.store.userById(req.user.id);
799
+ const current = str(req.body.current);
800
+ const password = str(req.body.password);
801
+ if (!(await this.store.checkPassword(user, current))) {
802
+ return this.sendAccount(req, res, { text: 'The current password is wrong', level: 'danger' }, 400);
803
+ }
804
+ if (password.length < 8) {
805
+ return this.sendAccount(req, res, { text: 'A password must be at least 8 characters', level: 'danger' }, 400);
806
+ }
807
+ await this.store.setPassword(user.id, password);
808
+ // this session goes on (with the new password); the user's others end
809
+ req.session.regPwd = passwordStamp(this.store.userById(user.id).password_hash);
810
+ return this.sendAccount(req, res, { text: 'Password changed' });
811
+ }
812
+
813
+ sendError(req, res, status, message) {
814
+ res.status(status);
815
+ this.renderPage(req, res, 'Error', `<div class="alert alert-danger">${escape(message)}</div>`, status);
816
+ }
817
+ }
818
+
819
+ // ---- html bits ----
820
+
821
+ function str(v) {
822
+ return typeof v === 'string' ? v : '';
823
+ }
824
+
825
+ function csrfField(res) {
826
+ return `<input type="hidden" name="_csrf" value="${escape(res.locals._csrf || '')}"/>`;
827
+ }
828
+
829
+ function alert(message, level) {
830
+ if (!message) {
831
+ return '';
832
+ }
833
+ const text = typeof message === 'string' ? message : message.text;
834
+ const cls = typeof message === 'string' ? (level || 'info') : (message.level || 'info');
835
+ return `<div class="alert alert-${cls}">${escape(text)}</div>`;
836
+ }
837
+
838
+ function field(name, label, type, help, required, value) {
839
+ return `<div class="mb-3"><label for="f-${name}" class="form-label">${escape(label)}</label>` +
840
+ `<input type="${type}" class="form-control" id="f-${name}" name="${name}"${required ? ' required' : ''}` +
841
+ `${value !== undefined ? ` value="${escape(value)}"` : ''}${type === 'password' ? ' autocomplete="new-password"' : ''}/>` +
842
+ (help ? `<div class="form-text">${escape(help)}</div>` : '') + '</div>';
843
+ }
844
+
845
+ function userBar(req, res) {
846
+ const base = escape(req.baseUrl || '');
847
+ const login = req.user ? escape(req.user.login) : '';
848
+ return `<form method="post" action="${base}/logout" style="float: right">${csrfField(res)}` +
849
+ `<span class="text-muted">${login}</span> <button type="submit" class="btn btn-sm btn-outline-secondary">Logout</button></form>`;
850
+ }
851
+
852
+ function day(iso) {
853
+ return iso ? String(iso).substring(0, 10) : '';
854
+ }
855
+
856
+ function ago(iso, now = Date.now()) {
857
+ if (!iso) {
858
+ return 'never';
859
+ }
860
+ const ms = now - new Date(iso).getTime();
861
+ if (!(ms >= 0)) {
862
+ return day(iso);
863
+ }
864
+ const min = Math.floor(ms / 60000);
865
+ if (min < 1) return 'just now';
866
+ if (min < 60) return `${min} min ago`;
867
+ const hours = Math.floor(min / 60);
868
+ if (hours < 48) return `${hours} h ago`;
869
+ return `${Math.floor(hours / 24)} days ago`;
870
+ }
871
+
872
+ module.exports = { RegistryAuth, presentedToken, looksLikeJwt };