emailengine-app 2.68.1 → 2.70.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 (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. package/workers/webhooks.js +9 -43
@@ -0,0 +1,1339 @@
1
+ 'use strict';
2
+
3
+ // Admin UI auth + user-profile routes: /admin/login (password + OKTA OAuth), /admin/logout,
4
+ // /admin/totp (TOTP two-factor), /admin/account/{security,tfa/{enable,disable},logout-all,
5
+ // passkeys/{register/options,register/verify,delete},password}, and /admin/passkey/auth/*
6
+ // (WebAuthn). Extracted verbatim from lib/routes-ui.js. The OKTA_OAUTH2_* env consts and
7
+ // USE_OKTA_AUTH are kept local to this module (only the auth routes use them).
8
+
9
+ const Joi = require('joi');
10
+ const crypto = require('crypto');
11
+ const pbkdf2 = require('@phc/pbkdf2');
12
+ const QRCode = require('qrcode');
13
+ const speakeasy = require('speakeasy');
14
+ const base32 = require('base32.js');
15
+ const {
16
+ generateRegistrationOptions,
17
+ verifyRegistrationResponse,
18
+ generateAuthenticationOptions,
19
+ verifyAuthenticationResponse
20
+ } = require('@simplewebauthn/server');
21
+
22
+ const settings = require('../settings');
23
+ const consts = require('../consts');
24
+ const { readEnvValue } = require('../tools');
25
+ const passkeys = require('../passkeys');
26
+
27
+ const { LOGIN_PERIOD_TTL, TOTP_WINDOW_SIZE, PDKDF2_ITERATIONS, PDKDF2_SALT_SIZE, PDKDF2_DIGEST } = consts;
28
+
29
+ const OKTA_OAUTH2_ISSUER = readEnvValue('OKTA_OAUTH2_ISSUER');
30
+ const OKTA_OAUTH2_CLIENT_ID = readEnvValue('OKTA_OAUTH2_CLIENT_ID');
31
+ const OKTA_OAUTH2_CLIENT_SECRET = readEnvValue('OKTA_OAUTH2_CLIENT_SECRET');
32
+ const USE_OKTA_AUTH = !!(OKTA_OAUTH2_ISSUER && OKTA_OAUTH2_CLIENT_ID && OKTA_OAUTH2_CLIENT_SECRET);
33
+
34
+ function init(args) {
35
+ const { server } = args;
36
+
37
+ server.route({
38
+ method: 'GET',
39
+ path: '/admin/login',
40
+ async handler(request, h) {
41
+ if (request.query.next && request.query.next.indexOf('/admin/login') === 0) {
42
+ // prevent loops where successful login ends up back in the login page
43
+ request.query.next = false;
44
+ }
45
+
46
+ // if authenticated and do not have to ask for TOTP, redirect directly to the admin page
47
+ if (request.auth.isAuthenticated && !(request.auth.artifacts && request.auth.artifacts.requireTotp)) {
48
+ return h.redirect(request.query.next || '/admin');
49
+ }
50
+
51
+ let passkeysAvailable = await passkeys.hasPasskeys();
52
+
53
+ return h.view(
54
+ 'account/login',
55
+ {
56
+ pageTitle: 'Login',
57
+ menuLogin: true,
58
+ values: {
59
+ username: '',
60
+ next: request.query.next
61
+ },
62
+ providers: {
63
+ okta: USE_OKTA_AUTH && (await h.validateOktaConfig())
64
+ },
65
+ passkeysAvailable
66
+ },
67
+ {
68
+ layout: 'login'
69
+ }
70
+ );
71
+ },
72
+ options: {
73
+ auth: {
74
+ strategy: 'session',
75
+ mode: 'try'
76
+ },
77
+ plugins: {
78
+ cookie: {
79
+ redirectTo: false
80
+ }
81
+ },
82
+
83
+ validate: {
84
+ options: {
85
+ stripUnknown: true,
86
+ abortEarly: false,
87
+ convert: true
88
+ },
89
+
90
+ async failAction(request, h, err) {
91
+ request.logger.error({ msg: 'Failed to validate login arguments', err });
92
+ return h.redirect('/admin/login').takeover();
93
+ },
94
+
95
+ query: Joi.object({
96
+ next: Joi.string()
97
+ .empty('')
98
+ .uri({ relativeOnly: true })
99
+ .pattern(/^\/(?!\/)/)
100
+ .label('NextUrl')
101
+ })
102
+ }
103
+ }
104
+ });
105
+
106
+ server.route({
107
+ method: 'GET',
108
+ path: '/admin/logout',
109
+ async handler(request, h) {
110
+ let user = request.auth && request.auth.credentials && request.auth.credentials.user;
111
+ if (user) {
112
+ request.logger.info({ msg: 'Admin logout', user, method: 'session', remoteAddress: request.app.ip });
113
+ }
114
+ if (request.cookieAuth) {
115
+ request.cookieAuth.clear();
116
+ }
117
+ await request.flash({ type: 'info', message: `User logged out` });
118
+ return h.redirect('/');
119
+ }
120
+ });
121
+
122
+ server.route({
123
+ method: 'POST',
124
+ path: '/admin/login',
125
+ async handler(request, h) {
126
+ try {
127
+ let ipRateLimit = await h.checkRateLimit(`login:ip:${request.app.ip}`, 1, 30, 60);
128
+ if (!ipRateLimit.success) {
129
+ request.logger.error({ msg: 'Rate limited', ipRateLimit });
130
+ let err = new Error('Rate limited, please wait and try again');
131
+ err.responseText = err.message;
132
+ throw err;
133
+ }
134
+
135
+ let rateLimit = await h.checkRateLimit(`login:${request.payload.username}`, 1, 10, 60);
136
+ if (!rateLimit.success) {
137
+ request.logger.error({ msg: 'Rate limited', rateLimit });
138
+ let err = new Error('Rate limited, please wait and try again');
139
+ err.responseText = err.message;
140
+ throw err;
141
+ }
142
+
143
+ let authData = await settings.get('authData');
144
+ let totpEnabled = (await settings.get('totpEnabled')) || false;
145
+
146
+ if (authData && authData.user && authData.user !== request.payload.username) {
147
+ request.logger.error({ msg: 'Invalid username', username: request.payload.username });
148
+ let err = new Error('Failed to authenticate');
149
+ err.details = { password: err.message };
150
+ throw err;
151
+ }
152
+
153
+ if (authData && authData.password) {
154
+ try {
155
+ let valid = await pbkdf2.verify(authData.password, request.payload.password);
156
+ if (!valid) {
157
+ throw new Error('Invalid password');
158
+ }
159
+ } catch (E) {
160
+ request.logger.error({ msg: 'Failed to verify password hash', err: E });
161
+ let err = new Error('Failed to authenticate');
162
+ err.details = { password: err.message };
163
+ throw err;
164
+ }
165
+
166
+ request.cookieAuth.set({
167
+ user: authData.user,
168
+ requireTotp: totpEnabled,
169
+ passwordVersion: authData.passwordVersion || 0,
170
+ remember: request.payload.remember,
171
+ sid: crypto.randomBytes(32).toString('hex')
172
+ });
173
+
174
+ if (request.payload.remember) {
175
+ request.cookieAuth.ttl(LOGIN_PERIOD_TTL);
176
+ }
177
+ }
178
+
179
+ request.logger.info({ msg: 'Admin login successful', user: authData.user, method: 'password', remoteAddress: request.app.ip });
180
+
181
+ if (totpEnabled) {
182
+ let url = new URL(`admin/totp`, 'http://localhost');
183
+
184
+ if (request.payload.next) {
185
+ url.searchParams.append('next', request.payload.next);
186
+ }
187
+
188
+ return h.redirect(url.pathname + url.search);
189
+ }
190
+
191
+ await request.flash({ type: 'info', message: `Authentication successful` });
192
+
193
+ if (request.payload.next) {
194
+ return h.redirect(request.payload.next);
195
+ } else {
196
+ return h.redirect('/admin');
197
+ }
198
+ } catch (err) {
199
+ await request.flash({ type: 'danger', message: err.responseText || `Could not sign in. Check your password and try again.` });
200
+ request.logger.error({ msg: 'Failed to authenticate', err, user: request.payload.username, method: 'password', remoteAddress: request.app.ip });
201
+
202
+ let errors = err.details;
203
+
204
+ return h.view(
205
+ 'account/login',
206
+ {
207
+ pageTitle: 'Login',
208
+ menuLogin: true,
209
+ errors,
210
+ values: {
211
+ username: request.payload.username,
212
+ next: request.payload.next
213
+ },
214
+ providers: {
215
+ okta: USE_OKTA_AUTH && (await h.validateOktaConfig())
216
+ },
217
+ passkeysAvailable: await passkeys.hasPasskeys()
218
+ },
219
+ {
220
+ layout: 'login'
221
+ }
222
+ );
223
+ }
224
+ },
225
+ options: {
226
+ validate: {
227
+ options: {
228
+ stripUnknown: true,
229
+ abortEarly: false,
230
+ convert: true
231
+ },
232
+
233
+ async failAction(request, h, err) {
234
+ let errors = {};
235
+
236
+ if (err.details) {
237
+ err.details.forEach(detail => {
238
+ if (!errors[detail.path]) {
239
+ errors[detail.path] = detail.message;
240
+ }
241
+ });
242
+ }
243
+
244
+ await request.flash({ type: 'danger', message: `Could not sign in. Check your password and try again.` });
245
+ request.logger.error({ msg: 'Failed to authenticate', err, method: 'password', remoteAddress: request.app.ip });
246
+
247
+ return h
248
+ .view(
249
+ 'account/login',
250
+ {
251
+ pageTitle: 'Login',
252
+ menuLogin: true,
253
+ errors,
254
+ values: {
255
+ username: request.payload.username,
256
+ next: request.payload.next
257
+ },
258
+ providers: {
259
+ okta: USE_OKTA_AUTH && (await h.validateOktaConfig())
260
+ },
261
+ passkeysAvailable: await passkeys.hasPasskeys()
262
+ },
263
+ {
264
+ layout: 'login'
265
+ }
266
+ )
267
+ .takeover();
268
+ },
269
+
270
+ payload: Joi.object({
271
+ username: Joi.string().max(256).example('user').label('Username').description('Your account username'),
272
+ password: Joi.string().max(256).min(8).required().example('secret').label('Password').description('Your account password'),
273
+ remember: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false).description('Remember me'),
274
+ next: Joi.string()
275
+ .empty('')
276
+ .uri({ relativeOnly: true })
277
+ .pattern(/^\/(?!\/)/)
278
+ .label('NextUrl')
279
+ })
280
+ },
281
+
282
+ auth: {
283
+ strategy: 'session',
284
+ mode: 'try'
285
+ },
286
+ plugins: {
287
+ cookie: {
288
+ redirectTo: false
289
+ }
290
+ }
291
+ }
292
+ });
293
+
294
+ server.route({
295
+ method: 'GET',
296
+ path: '/admin/totp',
297
+ async handler(request, h) {
298
+ // No partial-auth (password-stage) session -> nothing to two-factor; send back to login
299
+ // instead of dereferencing null credentials (which previously returned a 500).
300
+ if (!(request.auth && request.auth.credentials && request.auth.credentials.user)) {
301
+ return h.redirect('/admin/login');
302
+ }
303
+
304
+ return h.view(
305
+ 'account/totp',
306
+ {
307
+ pageTitle: 'Login',
308
+ menuLogin: true,
309
+ values: {
310
+ username: request.auth.credentials.user,
311
+ next: request.query.next
312
+ }
313
+ },
314
+ {
315
+ layout: 'login'
316
+ }
317
+ );
318
+ },
319
+ options: {
320
+ validate: {
321
+ options: {
322
+ stripUnknown: true,
323
+ abortEarly: false,
324
+ convert: true
325
+ },
326
+
327
+ async failAction(request, h, err) {
328
+ request.logger.error({ msg: 'Failed to validate login arguments', err });
329
+ return h.redirect('/admin/login').takeover();
330
+ },
331
+
332
+ query: Joi.object({
333
+ next: Joi.string()
334
+ .empty('')
335
+ .uri({ relativeOnly: true })
336
+ .pattern(/^\/(?!\/)/)
337
+ .label('NextUrl')
338
+ })
339
+ }
340
+ }
341
+ });
342
+
343
+ server.route({
344
+ method: 'POST',
345
+ path: '/admin/totp',
346
+ async handler(request, h) {
347
+ try {
348
+ if (!request.auth || !request.auth.artifacts || !request.auth.artifacts.requireTotp) {
349
+ // TOTP not needed
350
+ let url = new URL(`admin/login`, 'http://localhost');
351
+
352
+ if (request.payload.next) {
353
+ url.searchParams.append('next', request.payload.next);
354
+ }
355
+
356
+ return h.redirect(url.pathname + url.search);
357
+ }
358
+
359
+ if (request.auth && request.auth.credentials && request.auth.credentials.user) {
360
+ // attempt limiter
361
+ let rateLimit = await h.checkRateLimit(`totp:attempt:${request.auth.credentials.user}`, 1, 10, 60);
362
+ if (!rateLimit.success) {
363
+ request.logger.error({ msg: 'Rate limited', rateLimit });
364
+ let err = new Error('Rate limited, please wait and try again');
365
+ err.responseText = err.message;
366
+ throw err;
367
+ }
368
+ }
369
+
370
+ let totpSeed = await settings.get('totpSeed');
371
+ if (!totpSeed) {
372
+ await request.flash({ type: 'danger', message: `Start two-factor auth setup first` });
373
+ return h.redirect(`/admin/login`);
374
+ }
375
+
376
+ let verified = speakeasy.totp.verify({
377
+ secret: base32.encode(Buffer.from(totpSeed)),
378
+ encoding: 'base32',
379
+ token: request.payload.code,
380
+ window: TOTP_WINDOW_SIZE
381
+ });
382
+
383
+ if (!verified) {
384
+ let err = new Error('Failed to verify login');
385
+ err.details = { code: 'Invalid or expired code' };
386
+ throw err;
387
+ }
388
+
389
+ // code re-use limiter
390
+ let reUseLimit = await h.checkRateLimit(`totp:code:${request.payload.code}`, 1, 1, 12 * 60);
391
+ if (!reUseLimit.success) {
392
+ request.logger.error({ msg: 'TOTP code recently used', reUseLimit });
393
+ let err = new Error('This code has been already used, please wait and try another code');
394
+ err.responseText = err.message;
395
+ throw err;
396
+ }
397
+
398
+ request.cookieAuth.clear('requireTotp');
399
+
400
+ if (request.auth && request.auth.artifacts && request.auth.artifacts.remember) {
401
+ request.cookieAuth.ttl(LOGIN_PERIOD_TTL);
402
+ }
403
+
404
+ request.logger.info({
405
+ msg: 'TOTP verification successful',
406
+ user: request.auth.credentials.user,
407
+ method: 'totp',
408
+ remoteAddress: request.app.ip
409
+ });
410
+
411
+ if (request.payload.next) {
412
+ return h.redirect(request.payload.next);
413
+ } else {
414
+ return h.redirect('/admin');
415
+ }
416
+ } catch (err) {
417
+ if (!err.details || !err.details.code) {
418
+ // skip error message if code is invalid
419
+ await request.flash({ type: 'danger', message: err.responseText || `Could not verify. Check your code and try again.` });
420
+ }
421
+
422
+ request.logger.error({
423
+ msg: 'Failed to verify TOTP',
424
+ err,
425
+ user: request.auth && request.auth.credentials && request.auth.credentials.user,
426
+ method: 'totp',
427
+ remoteAddress: request.app.ip
428
+ });
429
+
430
+ let errors = err.details;
431
+
432
+ return h.view(
433
+ 'account/totp',
434
+ {
435
+ pageTitle: 'Login',
436
+ menuLogin: true,
437
+ errors
438
+ },
439
+ {
440
+ layout: 'login'
441
+ }
442
+ );
443
+ }
444
+ },
445
+ options: {
446
+ validate: {
447
+ options: {
448
+ stripUnknown: true,
449
+ abortEarly: false,
450
+ convert: true
451
+ },
452
+
453
+ async failAction(request, h, err) {
454
+ let errors = {};
455
+
456
+ if (err.details) {
457
+ err.details.forEach(detail => {
458
+ if (!errors[detail.path]) {
459
+ errors[detail.path] = detail.message;
460
+ }
461
+ });
462
+ }
463
+
464
+ await request.flash({ type: 'danger', message: `Could not verify. Check your code and try again.` });
465
+ request.logger.error({ msg: 'Failed to verify login', err });
466
+
467
+ return h
468
+ .view(
469
+ 'account/totp',
470
+ {
471
+ pageTitle: 'Login',
472
+ menuLogin: true,
473
+ errors
474
+ },
475
+ {
476
+ layout: 'login'
477
+ }
478
+ )
479
+ .takeover();
480
+ },
481
+
482
+ payload: Joi.object({
483
+ type: Joi.string().valid('totp').description('The type of the two-factor authentication method').required(),
484
+ code: Joi.string().min(6).max(6).description('6-digit TOTP code').required(),
485
+ next: Joi.string()
486
+ .empty('')
487
+ .uri({ relativeOnly: true })
488
+ .pattern(/^\/(?!\/)/)
489
+ .label('NextUrl')
490
+ })
491
+ },
492
+
493
+ auth: {
494
+ strategy: 'session',
495
+ mode: 'try'
496
+ },
497
+ plugins: {
498
+ cookie: {
499
+ redirectTo: false
500
+ }
501
+ }
502
+ }
503
+ });
504
+
505
+ server.route({
506
+ method: 'GET',
507
+ path: '/admin/account/security',
508
+ async handler(request, h) {
509
+ if (request.auth.artifacts && request.auth.artifacts.provider) {
510
+ return h.redirect(`/admin`);
511
+ }
512
+
513
+ let totp = {
514
+ enabled: (await settings.get('totpEnabled')) || false
515
+ };
516
+
517
+ let username = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
518
+
519
+ let totpSeed = await settings.get('totpSeed');
520
+ if (!totpSeed) {
521
+ let secret = speakeasy.generateSecret({
522
+ length: 20,
523
+ name: username
524
+ });
525
+
526
+ totpSeed = secret.ascii;
527
+ await settings.set('totpSeed', totpSeed);
528
+ }
529
+
530
+ if (!totp.enabled) {
531
+ // create QR code
532
+ const serviceUrl = (await settings.get('serviceUrl')) || '';
533
+
534
+ let otpauth_url = speakeasy.otpauthURL({
535
+ secret: totpSeed,
536
+ // label is part of URL and speakeasy as of v2.0.0 does not encode special characters
537
+ label: encodeURIComponent(serviceUrl.replace(/^https?:\/\/|\/$/g, '')),
538
+ issuer: 'EmailEngine'
539
+ });
540
+
541
+ try {
542
+ totp.dataUrl = await QRCode.toDataURL(otpauth_url);
543
+ } catch (err) {
544
+ request.logger.error({ msg: 'QR code generation failed', err });
545
+ }
546
+ }
547
+
548
+ let registeredPasskeys = await passkeys.listCredentials(username);
549
+ for (let pk of registeredPasskeys) {
550
+ try {
551
+ pk.createdAtFormatted = new Date(pk.createdAt).toLocaleDateString('en-US', {
552
+ year: 'numeric',
553
+ month: 'short',
554
+ day: 'numeric'
555
+ });
556
+ } catch (err) {
557
+ pk.createdAtFormatted = pk.createdAt;
558
+ }
559
+ }
560
+
561
+ let serviceUrl = await settings.get('serviceUrl');
562
+
563
+ return h.view(
564
+ 'account/security',
565
+ {
566
+ pageTitle: 'Security',
567
+ menuAccountSecurity: true,
568
+ activePassword: false,
569
+ disableAuthWarning: true,
570
+
571
+ username,
572
+
573
+ totp,
574
+ passkeys: registeredPasskeys,
575
+ serviceUrl,
576
+ providers: {
577
+ okta: USE_OKTA_AUTH && (await h.validateOktaConfig())
578
+ },
579
+ okta: {
580
+ OKTA_OAUTH2_ISSUER,
581
+ OKTA_OAUTH2_CLIENT_ID,
582
+ OKTA_OAUTH2_CLIENT_SECRET: OKTA_OAUTH2_CLIENT_SECRET ? OKTA_OAUTH2_CLIENT_SECRET.substring(0, 6) + '…' : null
583
+ }
584
+ },
585
+ {
586
+ layout: 'app'
587
+ }
588
+ );
589
+ }
590
+ });
591
+
592
+ server.route({
593
+ method: 'POST',
594
+ path: '/admin/account/tfa/enable',
595
+ async handler(request, h) {
596
+ if (request.auth.artifacts && request.auth.artifacts.provider) {
597
+ return h.redirect(`/admin`);
598
+ }
599
+
600
+ try {
601
+ let totpSeed = await settings.get('totpSeed');
602
+ if (!totpSeed) {
603
+ await request.flash({ type: 'danger', message: `Start two-factor auth setup first` });
604
+ return h.redirect(`/admin/account/security`);
605
+ }
606
+
607
+ let verified = speakeasy.totp.verify({
608
+ secret: base32.encode(Buffer.from(totpSeed)),
609
+ encoding: 'base32',
610
+ token: request.payload.code,
611
+ window: TOTP_WINDOW_SIZE
612
+ });
613
+
614
+ if (!verified) {
615
+ await request.flash({ type: 'danger', message: `Invalid verification code` });
616
+ return h.redirect(`/admin/account/security`);
617
+ }
618
+
619
+ await settings.set('totpEnabled', true);
620
+
621
+ let authData = await settings.get('authData');
622
+ if (authData) {
623
+ authData.passwordVersion = Date.now();
624
+ await settings.set('authData', authData);
625
+ request.cookieAuth.set('passwordVersion', authData.passwordVersion);
626
+ if (request.auth && request.auth.artifacts && request.auth.artifacts.remember) {
627
+ request.cookieAuth.ttl(LOGIN_PERIOD_TTL);
628
+ }
629
+ }
630
+
631
+ await request.flash({ type: 'success', message: `Two-factor auth enabled` });
632
+ return h.redirect(`/admin/account/security`);
633
+ } catch (err) {
634
+ await request.flash({ type: 'danger', message: `Couldn't enable two-factor auth. Try again.` });
635
+ request.logger.error({ msg: 'Failed to enable 2FA', err, remoteAddress: request.app.ip });
636
+ return h.redirect(`/admin/account/security`);
637
+ }
638
+ },
639
+ options: {
640
+ validate: {
641
+ options: {
642
+ stripUnknown: true,
643
+ abortEarly: false,
644
+ convert: true
645
+ },
646
+
647
+ async failAction(request, h, err) {
648
+ await request.flash({ type: 'danger', message: `Couldn't enable two-factor auth. Try again.` });
649
+ request.logger.error({ msg: 'Failed to enable 2FA', err });
650
+
651
+ return h.redirect('/admin').takeover();
652
+ },
653
+
654
+ payload: Joi.object({
655
+ type: Joi.string().valid('totp').description('The type of the two-factor authentication method').required(),
656
+ code: Joi.string().min(6).max(6).description('6-digit TOTP code').required()
657
+ })
658
+ }
659
+ }
660
+ });
661
+
662
+ server.route({
663
+ method: 'POST',
664
+ path: '/admin/account/tfa/disable',
665
+ async handler(request, h) {
666
+ if (request.auth.artifacts && request.auth.artifacts.provider) {
667
+ return h.redirect(`/admin`);
668
+ }
669
+
670
+ try {
671
+ await settings.set('totpEnabled', false);
672
+ await settings.set('totpSeed', false);
673
+
674
+ await request.flash({ type: 'info', message: `Two-factor auth disabled` });
675
+ return h.redirect(`/admin/account/security`);
676
+ } catch (err) {
677
+ await request.flash({ type: 'danger', message: `Couldn't disable two-factor auth. Try again.` });
678
+ request.logger.error({ msg: 'Failed to enable 2FA', err, remoteAddress: request.app.ip });
679
+ return h.redirect(`/admin/account/security`);
680
+ }
681
+ },
682
+ options: {
683
+ validate: {
684
+ options: {
685
+ stripUnknown: true,
686
+ abortEarly: false,
687
+ convert: true
688
+ },
689
+
690
+ async failAction(request, h, err) {
691
+ await request.flash({ type: 'danger', message: `Couldn't disable two-factor auth. Try again.` });
692
+ request.logger.error({ msg: 'Failed to disable 2FA', err });
693
+
694
+ return h.redirect('/admin').takeover();
695
+ },
696
+
697
+ payload: Joi.object({
698
+ type: Joi.string().valid('totp').description('The type of the two-factor authentication method').required()
699
+ })
700
+ }
701
+ }
702
+ });
703
+
704
+ server.route({
705
+ method: 'POST',
706
+ path: '/admin/account/logout-all',
707
+ async handler(request, h) {
708
+ if (request.auth.artifacts && request.auth.artifacts.provider) {
709
+ return h.redirect(`/admin`);
710
+ }
711
+
712
+ try {
713
+ let authData = await settings.get('authData');
714
+ if (authData) {
715
+ authData.passwordVersion = Date.now();
716
+ await settings.set('authData', authData);
717
+ }
718
+ if (request.cookieAuth) {
719
+ request.cookieAuth.clear();
720
+ }
721
+ await request.flash({ type: 'info', message: `User logged out` });
722
+ return h.redirect('/');
723
+ } catch (err) {
724
+ await request.flash({ type: 'danger', message: `Could not sign out sessions. Try again.` });
725
+ request.logger.error({ msg: 'Failed to log out user sessions', err, remoteAddress: request.app.ip });
726
+ return h.redirect(`/admin/account/security`);
727
+ }
728
+ },
729
+ options: {
730
+ validate: {
731
+ options: {
732
+ stripUnknown: true,
733
+ abortEarly: false,
734
+ convert: true
735
+ },
736
+
737
+ async failAction(request, h, err) {
738
+ await request.flash({ type: 'danger', message: `Could not sign out sessions. Try again.` });
739
+ request.logger.error({ msg: 'Failed to log out user sessions', err });
740
+
741
+ return h.redirect('/admin').takeover();
742
+ }
743
+ }
744
+ }
745
+ });
746
+
747
+ // --- Passkey (WebAuthn) routes ---
748
+
749
+ const passkeyCredentialSchema = Joi.object({
750
+ id: Joi.string()
751
+ .max(512)
752
+ .pattern(/^[A-Za-z0-9_-]+$/)
753
+ .required(),
754
+ rawId: Joi.string()
755
+ .max(512)
756
+ .pattern(/^[A-Za-z0-9_-]+$/)
757
+ .required(),
758
+ response: Joi.object({
759
+ clientDataJSON: Joi.string().max(16384).required(),
760
+ attestationObject: Joi.string().max(65536),
761
+ authenticatorData: Joi.string().max(8192),
762
+ signature: Joi.string().max(2048),
763
+ userHandle: Joi.string().max(512).allow(''),
764
+ publicKey: Joi.string().max(4096),
765
+ publicKeyAlgorithm: Joi.number().integer()
766
+ }).required(),
767
+ type: Joi.string().valid('public-key').required(),
768
+ authenticatorAttachment: Joi.string().optional(),
769
+ clientExtensionResults: Joi.object().optional()
770
+ }).required();
771
+
772
+ // Registration: generate options (authenticated)
773
+ server.route({
774
+ method: 'POST',
775
+ path: '/admin/account/passkeys/register/options',
776
+ async handler(request, h) {
777
+ try {
778
+ let rateLimit = await h.checkRateLimit(`passkey:register:${request.app.ip}`, 1, 10, 60);
779
+ if (!rateLimit.success) {
780
+ return h.response({ error: 'Rate limited, please wait and try again' }).code(429);
781
+ }
782
+
783
+ let authData = await settings.get('authData');
784
+ if (!authData || !authData.password) {
785
+ return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
786
+ }
787
+
788
+ if (!request.payload.password) {
789
+ return h.response({ error: 'Current password is required' }).code(403);
790
+ }
791
+ let valid;
792
+ try {
793
+ valid = await pbkdf2.verify(authData.password, request.payload.password);
794
+ } catch (err) {
795
+ request.logger.error({ msg: 'Failed to verify password for passkey registration', err });
796
+ valid = false;
797
+ }
798
+ if (!valid) {
799
+ return h.response({ error: 'Invalid password' }).code(403);
800
+ }
801
+
802
+ let { rpId, origin } = await passkeys.getRpConfig();
803
+ if (!rpId || !origin) {
804
+ return h.response({ error: 'Service URL must be configured before registering passkeys' }).code(400);
805
+ }
806
+
807
+ let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
808
+
809
+ let existingCredentials = await passkeys.listCredentials(user);
810
+ if (existingCredentials.length >= consts.MAX_PASSKEYS_PER_USER) {
811
+ return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
812
+ }
813
+
814
+ let options = await generateRegistrationOptions({
815
+ rpName: 'EmailEngine',
816
+ rpID: rpId,
817
+ userName: user,
818
+ userID: Buffer.from(crypto.createHash('sha256').update(user).digest()),
819
+ attestationType: 'none',
820
+ excludeCredentials: existingCredentials.map(c => ({
821
+ id: c.id,
822
+ transports: c.transports
823
+ })),
824
+ authenticatorSelection: {
825
+ residentKey: 'preferred',
826
+ userVerification: 'required'
827
+ }
828
+ });
829
+
830
+ let challengeId = await passkeys.storeChallenge(options.challenge);
831
+
832
+ return h.response({ challengeId, options }).code(200);
833
+ } catch (err) {
834
+ request.logger.error({ msg: 'Failed to generate passkey registration options', err });
835
+ return h.response({ error: 'Failed to generate registration options' }).code(500);
836
+ }
837
+ },
838
+ options: {
839
+ validate: {
840
+ options: {
841
+ stripUnknown: true,
842
+ abortEarly: false,
843
+ convert: true
844
+ },
845
+ payload: Joi.object({
846
+ password: Joi.string().max(256).allow('', null).optional().label('Current password')
847
+ })
848
+ }
849
+ }
850
+ });
851
+
852
+ // Registration: verify response (authenticated)
853
+ server.route({
854
+ method: 'POST',
855
+ path: '/admin/account/passkeys/register/verify',
856
+ async handler(request, h) {
857
+ try {
858
+ let rateLimit = await h.checkRateLimit(`passkey:register:${request.app.ip}`, 1, 10, 60);
859
+ if (!rateLimit.success) {
860
+ return h.response({ error: 'Rate limited, please wait and try again' }).code(429);
861
+ }
862
+
863
+ let { rpId, origin } = await passkeys.getRpConfig();
864
+ if (!rpId || !origin) {
865
+ return h.response({ error: 'Service URL must be configured' }).code(400);
866
+ }
867
+
868
+ let challenge = await passkeys.consumeChallenge(request.payload.challengeId);
869
+ if (!challenge) {
870
+ return h.response({ error: 'Challenge expired or invalid. Please try again.' }).code(400);
871
+ }
872
+
873
+ let verification = await verifyRegistrationResponse({
874
+ response: request.payload.credential,
875
+ expectedChallenge: challenge,
876
+ expectedOrigin: origin,
877
+ expectedRPID: rpId
878
+ });
879
+
880
+ if (!verification.verified || !verification.registrationInfo) {
881
+ return h.response({ error: 'Registration verification failed' }).code(400);
882
+ }
883
+
884
+ let { credential } = verification.registrationInfo;
885
+ let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
886
+
887
+ let authData = await settings.get('authData');
888
+ if (!authData || !authData.password) {
889
+ return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
890
+ }
891
+
892
+ let saved = await passkeys.saveCredentialIfUnderLimit(
893
+ {
894
+ id: credential.id,
895
+ publicKey: Buffer.from(credential.publicKey).toString('base64url'),
896
+ counter: credential.counter,
897
+ transports: credential.transports || [],
898
+ name: request.payload.name,
899
+ user
900
+ },
901
+ consts.MAX_PASSKEYS_PER_USER
902
+ );
903
+
904
+ if (!saved) {
905
+ return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
906
+ }
907
+
908
+ request.logger.info({
909
+ msg: 'Passkey registered',
910
+ user,
911
+ name: request.payload.name,
912
+ method: 'passkey',
913
+ remoteAddress: request.app.ip
914
+ });
915
+
916
+ return h.response({ success: true }).code(200);
917
+ } catch (err) {
918
+ request.logger.error({ msg: 'Failed to verify passkey registration', err });
919
+ return h.response({ error: 'Registration failed' }).code(500);
920
+ }
921
+ },
922
+ options: {
923
+ validate: {
924
+ options: {
925
+ stripUnknown: true,
926
+ abortEarly: false,
927
+ convert: true
928
+ },
929
+ payload: Joi.object({
930
+ challengeId: Joi.string().hex().length(64).required(),
931
+ name: Joi.string().max(100).empty('').default('Unnamed passkey'),
932
+ credential: passkeyCredentialSchema
933
+ })
934
+ }
935
+ }
936
+ });
937
+
938
+ // Delete passkey (authenticated)
939
+ server.route({
940
+ method: 'POST',
941
+ path: '/admin/account/passkeys/delete',
942
+ async handler(request, h) {
943
+ if (request.auth.artifacts && request.auth.artifacts.provider) {
944
+ return h.redirect('/admin');
945
+ }
946
+
947
+ try {
948
+ let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
949
+ let deleted = await passkeys.deleteCredential(request.payload.credentialId, user);
950
+ if (deleted) {
951
+ request.logger.info({
952
+ msg: 'Passkey deleted',
953
+ user,
954
+ credentialId: request.payload.credentialId,
955
+ method: 'passkey',
956
+ remoteAddress: request.app.ip
957
+ });
958
+ await request.flash({ type: 'info', message: 'Passkey removed' });
959
+ } else {
960
+ await request.flash({ type: 'danger', message: 'Passkey not found' });
961
+ }
962
+ } catch (err) {
963
+ await request.flash({ type: 'danger', message: 'Failed to remove passkey' });
964
+ request.logger.error({ msg: 'Failed to delete passkey', err });
965
+ }
966
+ return h.redirect('/admin/account/security');
967
+ },
968
+ options: {
969
+ validate: {
970
+ options: {
971
+ stripUnknown: true,
972
+ abortEarly: false,
973
+ convert: true
974
+ },
975
+
976
+ async failAction(request, h, err) {
977
+ await request.flash({ type: 'danger', message: 'Failed to remove passkey' });
978
+ request.logger.error({ msg: 'Failed to delete passkey', err });
979
+ return h.redirect('/admin/account/security').takeover();
980
+ },
981
+
982
+ payload: Joi.object({
983
+ credentialId: Joi.string()
984
+ .max(512)
985
+ .pattern(/^[A-Za-z0-9_-]+$/)
986
+ .required()
987
+ .description('Credential ID to delete')
988
+ })
989
+ }
990
+ }
991
+ });
992
+
993
+ // Authentication: generate options (unauthenticated)
994
+ server.route({
995
+ method: 'POST',
996
+ path: '/admin/passkey/auth/options',
997
+ async handler(request, h) {
998
+ try {
999
+ let rateLimit = await h.checkRateLimit(`passkey:auth:options:${request.app.ip}`, 1, 10, 60);
1000
+ if (!rateLimit.success) {
1001
+ return h.response({ error: 'Rate limited, please wait and try again' }).code(429);
1002
+ }
1003
+
1004
+ let { rpId } = await passkeys.getRpConfig();
1005
+ if (!rpId) {
1006
+ return h.response({ error: 'no_passkeys' }).code(400);
1007
+ }
1008
+
1009
+ let allCredentials = await passkeys.getAllCredentials();
1010
+ if (!allCredentials.length) {
1011
+ return h.response({ error: 'no_passkeys' }).code(400);
1012
+ }
1013
+
1014
+ let options = await generateAuthenticationOptions({
1015
+ rpID: rpId,
1016
+ allowCredentials: allCredentials.map(c => ({
1017
+ id: c.id,
1018
+ transports: c.transports
1019
+ })),
1020
+ userVerification: 'required'
1021
+ });
1022
+
1023
+ let challengeId = await passkeys.storeChallenge(options.challenge);
1024
+
1025
+ return h.response({ challengeId, options }).code(200);
1026
+ } catch (err) {
1027
+ request.logger.error({ msg: 'Failed to generate passkey auth options', err });
1028
+ return h.response({ error: 'Failed to generate authentication options' }).code(500);
1029
+ }
1030
+ },
1031
+ options: {
1032
+ auth: {
1033
+ strategy: 'session',
1034
+ mode: 'try'
1035
+ },
1036
+ plugins: {
1037
+ cookie: {
1038
+ redirectTo: false
1039
+ }
1040
+ },
1041
+ validate: {
1042
+ options: {
1043
+ stripUnknown: true,
1044
+ abortEarly: false,
1045
+ convert: true
1046
+ },
1047
+ payload: Joi.object({})
1048
+ }
1049
+ }
1050
+ });
1051
+
1052
+ // Authentication: verify response (unauthenticated)
1053
+ server.route({
1054
+ method: 'POST',
1055
+ path: '/admin/passkey/auth/verify',
1056
+ async handler(request, h) {
1057
+ try {
1058
+ let rateLimit = await h.checkRateLimit(`passkey:auth:verify:${request.app.ip}`, 1, 10, 60);
1059
+ if (!rateLimit.success) {
1060
+ return h.response({ error: 'Rate limited, please wait and try again' }).code(429);
1061
+ }
1062
+
1063
+ let { rpId, origin } = await passkeys.getRpConfig();
1064
+ if (!rpId || !origin) {
1065
+ request.logger.warn({ msg: 'Passkey auth failed: missing RP config', method: 'passkey', remoteAddress: request.app.ip });
1066
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
1067
+ }
1068
+
1069
+ let challenge = await passkeys.consumeChallenge(request.payload.challengeId);
1070
+ if (!challenge) {
1071
+ request.logger.warn({ msg: 'Passkey auth failed: challenge expired or invalid', method: 'passkey', remoteAddress: request.app.ip });
1072
+ return h.response({ success: false, error: 'Challenge expired or invalid. Please try again.' }).code(400);
1073
+ }
1074
+
1075
+ let credentialId = request.payload.credential && request.payload.credential.id;
1076
+ let storedCredential = await passkeys.getCredential(credentialId);
1077
+ if (!storedCredential) {
1078
+ request.logger.warn({ msg: 'Passkey auth failed: unknown credential', method: 'passkey', remoteAddress: request.app.ip });
1079
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
1080
+ }
1081
+
1082
+ let verification = await verifyAuthenticationResponse({
1083
+ response: request.payload.credential,
1084
+ expectedChallenge: challenge,
1085
+ expectedOrigin: origin,
1086
+ expectedRPID: rpId,
1087
+ credential: {
1088
+ id: storedCredential.id,
1089
+ publicKey: Buffer.from(storedCredential.publicKey, 'base64url'),
1090
+ counter: storedCredential.counter,
1091
+ transports: storedCredential.transports
1092
+ }
1093
+ });
1094
+
1095
+ if (!verification.verified) {
1096
+ request.logger.warn({ msg: 'Passkey auth failed: verification failed', method: 'passkey', remoteAddress: request.app.ip });
1097
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
1098
+ }
1099
+
1100
+ let user = storedCredential.user;
1101
+ let authData = await settings.get('authData');
1102
+
1103
+ if (!authData || !authData.user || authData.user !== user) {
1104
+ request.logger.warn({ msg: 'Passkey auth failed: user mismatch', method: 'passkey', remoteAddress: request.app.ip });
1105
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
1106
+ }
1107
+
1108
+ await passkeys.updateCounter(storedCredential.id, verification.authenticationInfo.newCounter);
1109
+
1110
+ request.cookieAuth.set({
1111
+ user,
1112
+ requireTotp: false,
1113
+ passwordVersion: (authData && authData.passwordVersion) || 0,
1114
+ remember: request.payload.remember || false,
1115
+ sid: crypto.randomBytes(32).toString('hex')
1116
+ });
1117
+
1118
+ if (request.payload.remember) {
1119
+ request.cookieAuth.ttl(LOGIN_PERIOD_TTL);
1120
+ }
1121
+
1122
+ request.logger.info({ msg: 'Passkey authentication successful', user, method: 'passkey', remoteAddress: request.app.ip });
1123
+
1124
+ return h.response({ success: true, redirect: request.payload.next || '/admin' }).code(200);
1125
+ } catch (err) {
1126
+ request.logger.error({ msg: 'Failed to verify passkey authentication', err, method: 'passkey', remoteAddress: request.app.ip });
1127
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
1128
+ }
1129
+ },
1130
+ options: {
1131
+ auth: {
1132
+ strategy: 'session',
1133
+ mode: 'try'
1134
+ },
1135
+ plugins: {
1136
+ cookie: {
1137
+ redirectTo: false
1138
+ }
1139
+ },
1140
+ validate: {
1141
+ options: {
1142
+ stripUnknown: true,
1143
+ abortEarly: false,
1144
+ convert: true
1145
+ },
1146
+ payload: Joi.object({
1147
+ challengeId: Joi.string().hex().length(64).required(),
1148
+ credential: passkeyCredentialSchema,
1149
+ next: Joi.string()
1150
+ .empty('')
1151
+ .uri({ relativeOnly: true })
1152
+ .pattern(/^\/(?!\/)/)
1153
+ .label('NextUrl'),
1154
+ remember: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false)
1155
+ })
1156
+ }
1157
+ }
1158
+ });
1159
+
1160
+ // --- End of Passkey routes ---
1161
+
1162
+ server.route({
1163
+ method: 'GET',
1164
+ path: '/admin/account/password',
1165
+ async handler(request, h) {
1166
+ if (request.auth.artifacts && request.auth.artifacts.provider) {
1167
+ return h.redirect(`/admin`);
1168
+ }
1169
+
1170
+ let username = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
1171
+
1172
+ return h.view(
1173
+ 'account/password',
1174
+ {
1175
+ pageTitle: 'Security',
1176
+ menuAccountSecurity: true,
1177
+ activePassword: true,
1178
+ disableAuthWarning: true,
1179
+
1180
+ username
1181
+ },
1182
+ {
1183
+ layout: 'app'
1184
+ }
1185
+ );
1186
+ }
1187
+ });
1188
+
1189
+ server.route({
1190
+ method: 'POST',
1191
+ path: '/admin/account/password',
1192
+ async handler(request, h) {
1193
+ if (request.auth.artifacts && request.auth.artifacts.provider) {
1194
+ return h.redirect(`/admin`);
1195
+ }
1196
+
1197
+ try {
1198
+ let authData = await settings.get('authData');
1199
+ let hasExistingPassword = !!(authData && authData.password);
1200
+ if (hasExistingPassword) {
1201
+ try {
1202
+ let valid = await pbkdf2.verify(authData.password, request.payload.password0);
1203
+ if (!valid) {
1204
+ throw new Error('Invalid current password');
1205
+ }
1206
+ } catch (E) {
1207
+ request.logger.error({ msg: 'Failed to verify password hash', err: E });
1208
+ let err = new Error('Failed to verify current password');
1209
+ err.details = { password0: err.message };
1210
+ throw err;
1211
+ }
1212
+ }
1213
+
1214
+ const passwordHash = await pbkdf2.hash(request.payload.password, {
1215
+ iterations: PDKDF2_ITERATIONS,
1216
+ saltSize: PDKDF2_SALT_SIZE,
1217
+ digest: PDKDF2_DIGEST
1218
+ });
1219
+
1220
+ authData = authData || {};
1221
+ authData.user = authData.user || 'admin';
1222
+ authData.password = passwordHash;
1223
+ authData.passwordVersion = Date.now();
1224
+
1225
+ await settings.set('authData', authData);
1226
+
1227
+ try {
1228
+ await passkeys.deleteAllCredentials(authData.user || 'admin');
1229
+ request.logger.info({ msg: 'All passkeys cleared after password change', user: authData.user || 'admin' });
1230
+ } catch (passkeyErr) {
1231
+ request.logger.error({ msg: 'Failed to clear passkeys after password change', err: passkeyErr });
1232
+ }
1233
+
1234
+ if (!server.auth.settings.default) {
1235
+ server.auth.default('session');
1236
+ request.cookieAuth.set({
1237
+ user: authData.user,
1238
+ passwordVersion: authData.passwordVersion
1239
+ });
1240
+ } else {
1241
+ request.cookieAuth.set('passwordVersion', authData.passwordVersion);
1242
+ }
1243
+
1244
+ if (request.auth && request.auth.artifacts && request.auth.artifacts.remember) {
1245
+ request.cookieAuth.ttl(LOGIN_PERIOD_TTL);
1246
+ }
1247
+
1248
+ if (!hasExistingPassword) {
1249
+ await request.flash({ type: 'info', message: `Password saved` });
1250
+
1251
+ return h.redirect('/admin');
1252
+ }
1253
+
1254
+ await request.flash({ type: 'info', message: `Password updated` });
1255
+
1256
+ return h.redirect('/admin/account/password');
1257
+ } catch (err) {
1258
+ await request.flash({ type: 'danger', message: `Couldn't update password. Try again.` });
1259
+ request.logger.error({ msg: 'Failed to update password', err });
1260
+
1261
+ let username = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
1262
+
1263
+ return h.view(
1264
+ 'account/password',
1265
+ {
1266
+ pageTitle: 'Security',
1267
+ menuAccountSecurity: true,
1268
+ activePassword: true,
1269
+ disableAuthWarning: true,
1270
+ errors: err.details,
1271
+
1272
+ username
1273
+ },
1274
+ {
1275
+ layout: 'app'
1276
+ }
1277
+ );
1278
+ }
1279
+ },
1280
+ options: {
1281
+ validate: {
1282
+ options: {
1283
+ stripUnknown: true,
1284
+ abortEarly: false,
1285
+ convert: true
1286
+ },
1287
+
1288
+ async failAction(request, h, err) {
1289
+ let errors = {};
1290
+
1291
+ if (err.details) {
1292
+ err.details.forEach(detail => {
1293
+ if (!errors[detail.path]) {
1294
+ errors[detail.path] = detail.message;
1295
+ }
1296
+ });
1297
+ }
1298
+
1299
+ await request.flash({ type: 'danger', message: `Couldn't update password. Try again.` });
1300
+ request.logger.error({ msg: 'Failed to update account password', err });
1301
+
1302
+ let username = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
1303
+
1304
+ return h
1305
+ .view(
1306
+ 'account/password',
1307
+ {
1308
+ pageTitle: 'Security',
1309
+ menuAccountSecurity: true,
1310
+ activePassword: true,
1311
+ disableAuthWarning: true,
1312
+ errors,
1313
+
1314
+ username
1315
+ },
1316
+ {
1317
+ layout: 'app'
1318
+ }
1319
+ )
1320
+ .takeover();
1321
+ },
1322
+
1323
+ payload: Joi.object({
1324
+ password0: Joi.string().max(256).min(8).example('secret').label('Current password').description('Current password'),
1325
+ password: Joi.string().max(256).min(8).required().example('secret').label('New password').description('New password'),
1326
+ password2: Joi.string()
1327
+ .max(256)
1328
+ .required()
1329
+ .example('secret')
1330
+ .label('Repeat password')
1331
+ .description('Repeat password')
1332
+ .valid(Joi.ref('password'))
1333
+ })
1334
+ }
1335
+ }
1336
+ });
1337
+ }
1338
+
1339
+ module.exports = init;