emailengine-app 2.65.0 → 2.67.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 (50) hide show
  1. package/.github/workflows/deploy.yml +8 -8
  2. package/.github/workflows/release.yaml +9 -9
  3. package/.github/workflows/test.yml +2 -2
  4. package/CHANGELOG.md +53 -0
  5. package/bin/emailengine.js +3 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/lib/account.js +35 -29
  8. package/lib/consts.js +5 -0
  9. package/lib/email-client/gmail-client.js +23 -27
  10. package/lib/email-client/imap/mailbox.js +46 -19
  11. package/lib/email-client/imap/sync-operations.js +51 -19
  12. package/lib/email-client/imap-client.js +28 -5
  13. package/lib/email-client/outlook-client.js +155 -1
  14. package/lib/oauth/gmail.js +52 -1
  15. package/lib/passkeys.js +206 -0
  16. package/lib/routes-ui.js +522 -21
  17. package/lib/ui-routes/oauth-routes.js +6 -1
  18. package/package.json +13 -11
  19. package/sbom.json +1 -1
  20. package/static/js/login-passkey.js +75 -0
  21. package/static/js/passkey-register.js +107 -0
  22. package/static/licenses.html +238 -38
  23. package/static/vendor/handlebars/handlebars.min-v4.7.9.js +29 -0
  24. package/static/vendor/simplewebauthn/browser.min.js +2 -0
  25. package/translations/de.mo +0 -0
  26. package/translations/de.po +91 -53
  27. package/translations/en.mo +0 -0
  28. package/translations/en.po +84 -52
  29. package/translations/et.mo +0 -0
  30. package/translations/et.po +95 -60
  31. package/translations/fr.mo +0 -0
  32. package/translations/fr.po +102 -65
  33. package/translations/ja.mo +0 -0
  34. package/translations/ja.po +93 -57
  35. package/translations/messages.pot +101 -76
  36. package/translations/nl.mo +0 -0
  37. package/translations/nl.po +92 -56
  38. package/translations/pl.mo +0 -0
  39. package/translations/pl.po +106 -70
  40. package/views/account/login.hbs +35 -25
  41. package/views/account/password.hbs +4 -4
  42. package/views/account/security.hbs +101 -12
  43. package/views/account/totp.hbs +3 -3
  44. package/views/config/oauth/app.hbs +25 -0
  45. package/views/layout/app.hbs +2 -2
  46. package/views/layout/login.hbs +6 -1
  47. package/views/oauth-scope-error.hbs +29 -0
  48. package/workers/api.js +81 -3
  49. package/workers/imap.js +4 -0
  50. package/static/vendor/handlebars/handlebars.min-v4.7.7.js +0 -29
package/lib/routes-ui.js CHANGED
@@ -69,6 +69,13 @@ const libmime = require('libmime');
69
69
 
70
70
  const adminEntitiesRoutes = require('./ui-routes/admin-entities-routes');
71
71
  const { Export } = require('./export');
72
+ const passkeys = require('./passkeys');
73
+ const {
74
+ generateRegistrationOptions,
75
+ verifyRegistrationResponse,
76
+ generateAuthenticationOptions,
77
+ verifyAuthenticationResponse
78
+ } = require('@simplewebauthn/server');
72
79
 
73
80
  const {
74
81
  DEFAULT_MAX_LOG_LINES,
@@ -3524,18 +3531,21 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3524
3531
  return h.redirect(request.query.next || '/admin');
3525
3532
  }
3526
3533
 
3534
+ let passkeysAvailable = await passkeys.hasPasskeys();
3535
+
3527
3536
  return h.view(
3528
3537
  'account/login',
3529
3538
  {
3530
3539
  pageTitle: 'Login',
3531
3540
  menuLogin: true,
3532
3541
  values: {
3533
- username: 'admin',
3542
+ username: '',
3534
3543
  next: request.query.next
3535
3544
  },
3536
3545
  providers: {
3537
3546
  okta: USE_OKTA_AUTH && (await h.validateOktaConfig())
3538
- }
3547
+ },
3548
+ passkeysAvailable
3539
3549
  },
3540
3550
  {
3541
3551
  layout: 'login'
@@ -3566,7 +3576,11 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3566
3576
  },
3567
3577
 
3568
3578
  query: Joi.object({
3569
- next: Joi.string().empty('').uri({ relativeOnly: true }).label('NextUrl')
3579
+ next: Joi.string()
3580
+ .empty('')
3581
+ .uri({ relativeOnly: true })
3582
+ .pattern(/^\/(?!\/)/)
3583
+ .label('NextUrl')
3570
3584
  })
3571
3585
  }
3572
3586
  }
@@ -3576,6 +3590,10 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3576
3590
  method: 'GET',
3577
3591
  path: '/admin/logout',
3578
3592
  async handler(request, h) {
3593
+ let user = request.auth && request.auth.credentials && request.auth.credentials.user;
3594
+ if (user) {
3595
+ request.logger.info({ msg: 'Admin logout', user, method: 'session', remoteAddress: request.app.ip });
3596
+ }
3579
3597
  if (request.cookieAuth) {
3580
3598
  request.cookieAuth.clear();
3581
3599
  }
@@ -3589,6 +3607,14 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3589
3607
  path: '/admin/login',
3590
3608
  async handler(request, h) {
3591
3609
  try {
3610
+ let ipRateLimit = await h.checkRateLimit(`login:ip:${request.app.ip}`, 1, 30, 60);
3611
+ if (!ipRateLimit.success) {
3612
+ request.logger.error({ msg: 'Rate limited', ipRateLimit });
3613
+ let err = new Error('Rate limited, please wait and try again');
3614
+ err.responseText = err.message;
3615
+ throw err;
3616
+ }
3617
+
3592
3618
  let rateLimit = await h.checkRateLimit(`login:${request.payload.username}`, 1, 10, 60);
3593
3619
  if (!rateLimit.success) {
3594
3620
  request.logger.error({ msg: 'Rate limited', rateLimit });
@@ -3614,7 +3640,7 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3614
3640
  throw new Error('Invalid password');
3615
3641
  }
3616
3642
  } catch (E) {
3617
- request.logger.error({ msg: 'Failed to verify password hash', err: E, hash: authData.password });
3643
+ request.logger.error({ msg: 'Failed to verify password hash', err: E });
3618
3644
  let err = new Error('Failed to authenticate');
3619
3645
  err.details = { password: err.message };
3620
3646
  throw err;
@@ -3633,6 +3659,8 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3633
3659
  }
3634
3660
  }
3635
3661
 
3662
+ request.logger.info({ msg: 'Admin login successful', user: authData.user, method: 'password', remoteAddress: request.app.ip });
3663
+
3636
3664
  if (totpEnabled) {
3637
3665
  let url = new URL(`admin/totp`, 'http://localhost');
3638
3666
 
@@ -3651,8 +3679,8 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3651
3679
  return h.redirect('/admin');
3652
3680
  }
3653
3681
  } catch (err) {
3654
- await request.flash({ type: 'danger', message: err.responseText || `Sign-in failed. Check your password and try again.` });
3655
- request.logger.error({ msg: 'Failed to authenticate', err });
3682
+ await request.flash({ type: 'danger', message: err.responseText || `Could not sign in. Check your password and try again.` });
3683
+ request.logger.error({ msg: 'Failed to authenticate', err, user: request.payload.username, method: 'password', remoteAddress: request.app.ip });
3656
3684
 
3657
3685
  let errors = err.details;
3658
3686
 
@@ -3662,9 +3690,14 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3662
3690
  pageTitle: 'Login',
3663
3691
  menuLogin: true,
3664
3692
  errors,
3693
+ values: {
3694
+ username: request.payload.username,
3695
+ next: request.payload.next
3696
+ },
3665
3697
  providers: {
3666
3698
  okta: USE_OKTA_AUTH && (await h.validateOktaConfig())
3667
- }
3699
+ },
3700
+ passkeysAvailable: await passkeys.hasPasskeys()
3668
3701
  },
3669
3702
  {
3670
3703
  layout: 'login'
@@ -3691,8 +3724,8 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3691
3724
  });
3692
3725
  }
3693
3726
 
3694
- await request.flash({ type: 'danger', message: `Sign-in failed. Check your password and try again.` });
3695
- request.logger.error({ msg: 'Failed to authenticate', err });
3727
+ await request.flash({ type: 'danger', message: `Could not sign in. Check your password and try again.` });
3728
+ request.logger.error({ msg: 'Failed to authenticate', err, method: 'password', remoteAddress: request.app.ip });
3696
3729
 
3697
3730
  return h
3698
3731
  .view(
@@ -3701,9 +3734,14 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3701
3734
  pageTitle: 'Login',
3702
3735
  menuLogin: true,
3703
3736
  errors,
3737
+ values: {
3738
+ username: request.payload.username,
3739
+ next: request.payload.next
3740
+ },
3704
3741
  providers: {
3705
3742
  okta: USE_OKTA_AUTH && (await h.validateOktaConfig())
3706
- }
3743
+ },
3744
+ passkeysAvailable: await passkeys.hasPasskeys()
3707
3745
  },
3708
3746
  {
3709
3747
  layout: 'login'
@@ -3716,7 +3754,11 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3716
3754
  username: Joi.string().max(256).example('user').label('Username').description('Your account username'),
3717
3755
  password: Joi.string().max(256).min(8).required().example('secret').label('Password').description('Your account password'),
3718
3756
  remember: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false).description('Remember me'),
3719
- next: Joi.string().empty('').uri({ relativeOnly: true }).label('NextUrl')
3757
+ next: Joi.string()
3758
+ .empty('')
3759
+ .uri({ relativeOnly: true })
3760
+ .pattern(/^\/(?!\/)/)
3761
+ .label('NextUrl')
3720
3762
  })
3721
3763
  },
3722
3764
 
@@ -3765,7 +3807,11 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3765
3807
  },
3766
3808
 
3767
3809
  query: Joi.object({
3768
- next: Joi.string().empty('').uri({ relativeOnly: true }).label('NextUrl')
3810
+ next: Joi.string()
3811
+ .empty('')
3812
+ .uri({ relativeOnly: true })
3813
+ .pattern(/^\/(?!\/)/)
3814
+ .label('NextUrl')
3769
3815
  })
3770
3816
  }
3771
3817
  }
@@ -3832,6 +3878,13 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3832
3878
  request.cookieAuth.ttl(LOGIN_PERIOD_TTL);
3833
3879
  }
3834
3880
 
3881
+ request.logger.info({
3882
+ msg: 'TOTP verification successful',
3883
+ user: request.auth.credentials.user,
3884
+ method: 'totp',
3885
+ remoteAddress: request.app.ip
3886
+ });
3887
+
3835
3888
  if (request.payload.next) {
3836
3889
  return h.redirect(request.payload.next);
3837
3890
  } else {
@@ -3840,10 +3893,16 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3840
3893
  } catch (err) {
3841
3894
  if (!err.details || !err.details.code) {
3842
3895
  // skip error message if code is invalid
3843
- await request.flash({ type: 'danger', message: err.responseText || `Verification failed. Check your code and try again.` });
3896
+ await request.flash({ type: 'danger', message: err.responseText || `Could not verify. Check your code and try again.` });
3844
3897
  }
3845
3898
 
3846
- request.logger.error({ msg: 'Failed to verify login', err });
3899
+ request.logger.error({
3900
+ msg: 'Failed to verify TOTP',
3901
+ err,
3902
+ user: request.auth && request.auth.credentials && request.auth.credentials.user,
3903
+ method: 'totp',
3904
+ remoteAddress: request.app.ip
3905
+ });
3847
3906
 
3848
3907
  let errors = err.details;
3849
3908
 
@@ -3879,7 +3938,7 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3879
3938
  });
3880
3939
  }
3881
3940
 
3882
- await request.flash({ type: 'danger', message: `Verification failed. Check your code and try again.` });
3941
+ await request.flash({ type: 'danger', message: `Could not verify. Check your code and try again.` });
3883
3942
  request.logger.error({ msg: 'Failed to verify login', err });
3884
3943
 
3885
3944
  return h
@@ -3900,7 +3959,11 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3900
3959
  payload: Joi.object({
3901
3960
  type: Joi.string().valid('totp').description('The type of the two-factor authentication method').required(),
3902
3961
  code: Joi.string().min(6).max(6).description('6-digit TOTP code').required(),
3903
- next: Joi.string().empty('').uri({ relativeOnly: true }).label('NextUrl')
3962
+ next: Joi.string()
3963
+ .empty('')
3964
+ .uri({ relativeOnly: true })
3965
+ .pattern(/^\/(?!\/)/)
3966
+ .label('NextUrl')
3904
3967
  })
3905
3968
  },
3906
3969
 
@@ -3959,6 +4022,21 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3959
4022
  }
3960
4023
  }
3961
4024
 
4025
+ let registeredPasskeys = await passkeys.listCredentials(username);
4026
+ for (let pk of registeredPasskeys) {
4027
+ try {
4028
+ pk.createdAtFormatted = new Date(pk.createdAt).toLocaleDateString('en-US', {
4029
+ year: 'numeric',
4030
+ month: 'short',
4031
+ day: 'numeric'
4032
+ });
4033
+ } catch (err) {
4034
+ pk.createdAtFormatted = pk.createdAt;
4035
+ }
4036
+ }
4037
+
4038
+ let serviceUrl = await settings.get('serviceUrl');
4039
+
3962
4040
  return h.view(
3963
4041
  'account/security',
3964
4042
  {
@@ -3970,10 +4048,11 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
3970
4048
  username,
3971
4049
 
3972
4050
  totp,
4051
+ passkeys: registeredPasskeys,
4052
+ serviceUrl,
3973
4053
  providers: {
3974
4054
  okta: USE_OKTA_AUTH && (await h.validateOktaConfig())
3975
4055
  },
3976
- serviceUrl: await settings.get('serviceUrl'),
3977
4056
  okta: {
3978
4057
  OKTA_OAUTH2_ISSUER,
3979
4058
  OKTA_OAUTH2_CLIENT_ID,
@@ -4119,7 +4198,7 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4119
4198
  await request.flash({ type: 'info', message: `User logged out` });
4120
4199
  return h.redirect('/');
4121
4200
  } catch (err) {
4122
- await request.flash({ type: 'danger', message: `Couldn't log out sessions. Try again.` });
4201
+ await request.flash({ type: 'danger', message: `Could not sign out sessions. Try again.` });
4123
4202
  request.logger.error({ msg: 'Failed to log out user sessions', err, remoteAddress: request.app.ip });
4124
4203
  return h.redirect(`/admin/account/security`);
4125
4204
  }
@@ -4133,7 +4212,7 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4133
4212
  },
4134
4213
 
4135
4214
  async failAction(request, h, err) {
4136
- await request.flash({ type: 'danger', message: `Couldn't log out sessions. Try again.` });
4215
+ await request.flash({ type: 'danger', message: `Could not sign out sessions. Try again.` });
4137
4216
  request.logger.error({ msg: 'Failed to log out user sessions', err });
4138
4217
 
4139
4218
  return h.redirect('/admin').takeover();
@@ -4142,6 +4221,421 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4142
4221
  }
4143
4222
  });
4144
4223
 
4224
+ // --- Passkey (WebAuthn) routes ---
4225
+
4226
+ const passkeyCredentialSchema = Joi.object({
4227
+ id: Joi.string()
4228
+ .max(512)
4229
+ .pattern(/^[A-Za-z0-9_-]+$/)
4230
+ .required(),
4231
+ rawId: Joi.string()
4232
+ .max(512)
4233
+ .pattern(/^[A-Za-z0-9_-]+$/)
4234
+ .required(),
4235
+ response: Joi.object({
4236
+ clientDataJSON: Joi.string().max(16384).required(),
4237
+ attestationObject: Joi.string().max(65536),
4238
+ authenticatorData: Joi.string().max(8192),
4239
+ signature: Joi.string().max(2048),
4240
+ userHandle: Joi.string().max(512).allow(''),
4241
+ publicKey: Joi.string().max(4096),
4242
+ publicKeyAlgorithm: Joi.number().integer()
4243
+ }).required(),
4244
+ type: Joi.string().valid('public-key').required(),
4245
+ authenticatorAttachment: Joi.string().optional(),
4246
+ clientExtensionResults: Joi.object().optional()
4247
+ }).required();
4248
+
4249
+ // Registration: generate options (authenticated)
4250
+ server.route({
4251
+ method: 'POST',
4252
+ path: '/admin/account/passkeys/register/options',
4253
+ async handler(request, h) {
4254
+ try {
4255
+ let rateLimit = await h.checkRateLimit(`passkey:register:${request.app.ip}`, 1, 10, 60);
4256
+ if (!rateLimit.success) {
4257
+ return h.response({ error: 'Rate limited, please wait and try again' }).code(429);
4258
+ }
4259
+
4260
+ let authData = await settings.get('authData');
4261
+ if (!authData || !authData.password) {
4262
+ return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
4263
+ }
4264
+
4265
+ if (!request.payload.password) {
4266
+ return h.response({ error: 'Current password is required' }).code(403);
4267
+ }
4268
+ let valid;
4269
+ try {
4270
+ valid = await pbkdf2.verify(authData.password, request.payload.password);
4271
+ } catch (err) {
4272
+ request.logger.error({ msg: 'Failed to verify password for passkey registration', err });
4273
+ valid = false;
4274
+ }
4275
+ if (!valid) {
4276
+ return h.response({ error: 'Invalid password' }).code(403);
4277
+ }
4278
+
4279
+ let { rpId, origin } = await passkeys.getRpConfig();
4280
+ if (!rpId || !origin) {
4281
+ return h.response({ error: 'Service URL must be configured before registering passkeys' }).code(400);
4282
+ }
4283
+
4284
+ let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
4285
+
4286
+ let existingCredentials = await passkeys.listCredentials(user);
4287
+ if (existingCredentials.length >= consts.MAX_PASSKEYS_PER_USER) {
4288
+ return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
4289
+ }
4290
+
4291
+ let options = await generateRegistrationOptions({
4292
+ rpName: 'EmailEngine',
4293
+ rpID: rpId,
4294
+ userName: user,
4295
+ userID: Buffer.from(crypto.createHash('sha256').update(user).digest()),
4296
+ attestationType: 'none',
4297
+ excludeCredentials: existingCredentials.map(c => ({
4298
+ id: c.id,
4299
+ transports: c.transports
4300
+ })),
4301
+ authenticatorSelection: {
4302
+ residentKey: 'preferred',
4303
+ userVerification: 'required'
4304
+ }
4305
+ });
4306
+
4307
+ let challengeId = await passkeys.storeChallenge(options.challenge);
4308
+
4309
+ return h.response({ challengeId, options }).code(200);
4310
+ } catch (err) {
4311
+ request.logger.error({ msg: 'Failed to generate passkey registration options', err });
4312
+ return h.response({ error: 'Failed to generate registration options' }).code(500);
4313
+ }
4314
+ },
4315
+ options: {
4316
+ validate: {
4317
+ options: {
4318
+ stripUnknown: true,
4319
+ abortEarly: false,
4320
+ convert: true
4321
+ },
4322
+ payload: Joi.object({
4323
+ password: Joi.string().max(256).allow('', null).optional().label('Current password')
4324
+ })
4325
+ }
4326
+ }
4327
+ });
4328
+
4329
+ // Registration: verify response (authenticated)
4330
+ server.route({
4331
+ method: 'POST',
4332
+ path: '/admin/account/passkeys/register/verify',
4333
+ async handler(request, h) {
4334
+ try {
4335
+ let rateLimit = await h.checkRateLimit(`passkey:register:${request.app.ip}`, 1, 10, 60);
4336
+ if (!rateLimit.success) {
4337
+ return h.response({ error: 'Rate limited, please wait and try again' }).code(429);
4338
+ }
4339
+
4340
+ let { rpId, origin } = await passkeys.getRpConfig();
4341
+ if (!rpId || !origin) {
4342
+ return h.response({ error: 'Service URL must be configured' }).code(400);
4343
+ }
4344
+
4345
+ let challenge = await passkeys.consumeChallenge(request.payload.challengeId);
4346
+ if (!challenge) {
4347
+ return h.response({ error: 'Challenge expired or invalid. Please try again.' }).code(400);
4348
+ }
4349
+
4350
+ let verification = await verifyRegistrationResponse({
4351
+ response: request.payload.credential,
4352
+ expectedChallenge: challenge,
4353
+ expectedOrigin: origin,
4354
+ expectedRPID: rpId
4355
+ });
4356
+
4357
+ if (!verification.verified || !verification.registrationInfo) {
4358
+ return h.response({ error: 'Registration verification failed' }).code(400);
4359
+ }
4360
+
4361
+ let { credential } = verification.registrationInfo;
4362
+ let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
4363
+
4364
+ let authData = await settings.get('authData');
4365
+ if (!authData || !authData.password) {
4366
+ return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
4367
+ }
4368
+
4369
+ let saved = await passkeys.saveCredentialIfUnderLimit(
4370
+ {
4371
+ id: credential.id,
4372
+ publicKey: Buffer.from(credential.publicKey).toString('base64url'),
4373
+ counter: credential.counter,
4374
+ transports: credential.transports || [],
4375
+ name: request.payload.name,
4376
+ user
4377
+ },
4378
+ consts.MAX_PASSKEYS_PER_USER
4379
+ );
4380
+
4381
+ if (!saved) {
4382
+ return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
4383
+ }
4384
+
4385
+ request.logger.info({
4386
+ msg: 'Passkey registered',
4387
+ user,
4388
+ name: request.payload.name,
4389
+ method: 'passkey',
4390
+ remoteAddress: request.app.ip
4391
+ });
4392
+
4393
+ return h.response({ success: true }).code(200);
4394
+ } catch (err) {
4395
+ request.logger.error({ msg: 'Failed to verify passkey registration', err });
4396
+ return h.response({ error: 'Registration failed' }).code(500);
4397
+ }
4398
+ },
4399
+ options: {
4400
+ validate: {
4401
+ options: {
4402
+ stripUnknown: true,
4403
+ abortEarly: false,
4404
+ convert: true
4405
+ },
4406
+ payload: Joi.object({
4407
+ challengeId: Joi.string().hex().length(64).required(),
4408
+ name: Joi.string().max(100).empty('').default('Unnamed passkey'),
4409
+ credential: passkeyCredentialSchema
4410
+ })
4411
+ }
4412
+ }
4413
+ });
4414
+
4415
+ // Delete passkey (authenticated)
4416
+ server.route({
4417
+ method: 'POST',
4418
+ path: '/admin/account/passkeys/delete',
4419
+ async handler(request, h) {
4420
+ if (request.auth.artifacts && request.auth.artifacts.provider) {
4421
+ return h.redirect('/admin');
4422
+ }
4423
+
4424
+ try {
4425
+ let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
4426
+ let deleted = await passkeys.deleteCredential(request.payload.credentialId, user);
4427
+ if (deleted) {
4428
+ request.logger.info({
4429
+ msg: 'Passkey deleted',
4430
+ user,
4431
+ credentialId: request.payload.credentialId,
4432
+ method: 'passkey',
4433
+ remoteAddress: request.app.ip
4434
+ });
4435
+ await request.flash({ type: 'info', message: 'Passkey removed' });
4436
+ } else {
4437
+ await request.flash({ type: 'danger', message: 'Passkey not found' });
4438
+ }
4439
+ } catch (err) {
4440
+ await request.flash({ type: 'danger', message: 'Failed to remove passkey' });
4441
+ request.logger.error({ msg: 'Failed to delete passkey', err });
4442
+ }
4443
+ return h.redirect('/admin/account/security');
4444
+ },
4445
+ options: {
4446
+ validate: {
4447
+ options: {
4448
+ stripUnknown: true,
4449
+ abortEarly: false,
4450
+ convert: true
4451
+ },
4452
+
4453
+ async failAction(request, h, err) {
4454
+ await request.flash({ type: 'danger', message: 'Failed to remove passkey' });
4455
+ request.logger.error({ msg: 'Failed to delete passkey', err });
4456
+ return h.redirect('/admin/account/security').takeover();
4457
+ },
4458
+
4459
+ payload: Joi.object({
4460
+ credentialId: Joi.string()
4461
+ .max(512)
4462
+ .pattern(/^[A-Za-z0-9_-]+$/)
4463
+ .required()
4464
+ .description('Credential ID to delete')
4465
+ })
4466
+ }
4467
+ }
4468
+ });
4469
+
4470
+ // Authentication: generate options (unauthenticated)
4471
+ server.route({
4472
+ method: 'POST',
4473
+ path: '/admin/passkey/auth/options',
4474
+ async handler(request, h) {
4475
+ try {
4476
+ let rateLimit = await h.checkRateLimit(`passkey:auth:options:${request.app.ip}`, 1, 10, 60);
4477
+ if (!rateLimit.success) {
4478
+ return h.response({ error: 'Rate limited, please wait and try again' }).code(429);
4479
+ }
4480
+
4481
+ let { rpId } = await passkeys.getRpConfig();
4482
+ if (!rpId) {
4483
+ return h.response({ error: 'no_passkeys' }).code(400);
4484
+ }
4485
+
4486
+ let allCredentials = await passkeys.getAllCredentials();
4487
+ if (!allCredentials.length) {
4488
+ return h.response({ error: 'no_passkeys' }).code(400);
4489
+ }
4490
+
4491
+ let options = await generateAuthenticationOptions({
4492
+ rpID: rpId,
4493
+ allowCredentials: allCredentials.map(c => ({
4494
+ id: c.id,
4495
+ transports: c.transports
4496
+ })),
4497
+ userVerification: 'required'
4498
+ });
4499
+
4500
+ let challengeId = await passkeys.storeChallenge(options.challenge);
4501
+
4502
+ return h.response({ challengeId, options }).code(200);
4503
+ } catch (err) {
4504
+ request.logger.error({ msg: 'Failed to generate passkey auth options', err });
4505
+ return h.response({ error: 'Failed to generate authentication options' }).code(500);
4506
+ }
4507
+ },
4508
+ options: {
4509
+ auth: {
4510
+ strategy: 'session',
4511
+ mode: 'try'
4512
+ },
4513
+ plugins: {
4514
+ cookie: {
4515
+ redirectTo: false
4516
+ }
4517
+ },
4518
+ validate: {
4519
+ options: {
4520
+ stripUnknown: true,
4521
+ abortEarly: false,
4522
+ convert: true
4523
+ },
4524
+ payload: Joi.object({})
4525
+ }
4526
+ }
4527
+ });
4528
+
4529
+ // Authentication: verify response (unauthenticated)
4530
+ server.route({
4531
+ method: 'POST',
4532
+ path: '/admin/passkey/auth/verify',
4533
+ async handler(request, h) {
4534
+ try {
4535
+ let rateLimit = await h.checkRateLimit(`passkey:auth:verify:${request.app.ip}`, 1, 10, 60);
4536
+ if (!rateLimit.success) {
4537
+ return h.response({ error: 'Rate limited, please wait and try again' }).code(429);
4538
+ }
4539
+
4540
+ let { rpId, origin } = await passkeys.getRpConfig();
4541
+ if (!rpId || !origin) {
4542
+ request.logger.warn({ msg: 'Passkey auth failed: missing RP config', method: 'passkey', remoteAddress: request.app.ip });
4543
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
4544
+ }
4545
+
4546
+ let challenge = await passkeys.consumeChallenge(request.payload.challengeId);
4547
+ if (!challenge) {
4548
+ request.logger.warn({ msg: 'Passkey auth failed: challenge expired or invalid', method: 'passkey', remoteAddress: request.app.ip });
4549
+ return h.response({ success: false, error: 'Challenge expired or invalid. Please try again.' }).code(400);
4550
+ }
4551
+
4552
+ let credentialId = request.payload.credential && request.payload.credential.id;
4553
+ let storedCredential = await passkeys.getCredential(credentialId);
4554
+ if (!storedCredential) {
4555
+ request.logger.warn({ msg: 'Passkey auth failed: unknown credential', method: 'passkey', remoteAddress: request.app.ip });
4556
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
4557
+ }
4558
+
4559
+ let verification = await verifyAuthenticationResponse({
4560
+ response: request.payload.credential,
4561
+ expectedChallenge: challenge,
4562
+ expectedOrigin: origin,
4563
+ expectedRPID: rpId,
4564
+ credential: {
4565
+ id: storedCredential.id,
4566
+ publicKey: Buffer.from(storedCredential.publicKey, 'base64url'),
4567
+ counter: storedCredential.counter,
4568
+ transports: storedCredential.transports
4569
+ }
4570
+ });
4571
+
4572
+ if (!verification.verified) {
4573
+ request.logger.warn({ msg: 'Passkey auth failed: verification failed', method: 'passkey', remoteAddress: request.app.ip });
4574
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
4575
+ }
4576
+
4577
+ let user = storedCredential.user;
4578
+ let authData = await settings.get('authData');
4579
+
4580
+ if (!authData || !authData.user || authData.user !== user) {
4581
+ request.logger.warn({ msg: 'Passkey auth failed: user mismatch', method: 'passkey', remoteAddress: request.app.ip });
4582
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
4583
+ }
4584
+
4585
+ await passkeys.updateCounter(storedCredential.id, verification.authenticationInfo.newCounter);
4586
+
4587
+ request.cookieAuth.set({
4588
+ user,
4589
+ requireTotp: false,
4590
+ passwordVersion: (authData && authData.passwordVersion) || 0,
4591
+ remember: request.payload.remember || false,
4592
+ sid: crypto.randomBytes(32).toString('hex')
4593
+ });
4594
+
4595
+ if (request.payload.remember) {
4596
+ request.cookieAuth.ttl(LOGIN_PERIOD_TTL);
4597
+ }
4598
+
4599
+ request.logger.info({ msg: 'Passkey authentication successful', user, method: 'passkey', remoteAddress: request.app.ip });
4600
+
4601
+ return h.response({ success: true, redirect: request.payload.next || '/admin' }).code(200);
4602
+ } catch (err) {
4603
+ request.logger.error({ msg: 'Failed to verify passkey authentication', err, method: 'passkey', remoteAddress: request.app.ip });
4604
+ return h.response({ success: false, error: 'Authentication failed' }).code(400);
4605
+ }
4606
+ },
4607
+ options: {
4608
+ auth: {
4609
+ strategy: 'session',
4610
+ mode: 'try'
4611
+ },
4612
+ plugins: {
4613
+ cookie: {
4614
+ redirectTo: false
4615
+ }
4616
+ },
4617
+ validate: {
4618
+ options: {
4619
+ stripUnknown: true,
4620
+ abortEarly: false,
4621
+ convert: true
4622
+ },
4623
+ payload: Joi.object({
4624
+ challengeId: Joi.string().hex().length(64).required(),
4625
+ credential: passkeyCredentialSchema,
4626
+ next: Joi.string()
4627
+ .empty('')
4628
+ .uri({ relativeOnly: true })
4629
+ .pattern(/^\/(?!\/)/)
4630
+ .label('NextUrl'),
4631
+ remember: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false)
4632
+ })
4633
+ }
4634
+ }
4635
+ });
4636
+
4637
+ // --- End of Passkey routes ---
4638
+
4145
4639
  server.route({
4146
4640
  method: 'GET',
4147
4641
  path: '/admin/account/password',
@@ -4187,7 +4681,7 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4187
4681
  throw new Error('Invalid current password');
4188
4682
  }
4189
4683
  } catch (E) {
4190
- request.logger.error({ msg: 'Failed to verify password hash', err: E, hash: authData.password });
4684
+ request.logger.error({ msg: 'Failed to verify password hash', err: E });
4191
4685
  let err = new Error('Failed to verify current password');
4192
4686
  err.details = { password0: err.message };
4193
4687
  throw err;
@@ -4207,6 +4701,13 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4207
4701
 
4208
4702
  await settings.set('authData', authData);
4209
4703
 
4704
+ try {
4705
+ await passkeys.deleteAllCredentials(authData.user || 'admin');
4706
+ request.logger.info({ msg: 'All passkeys cleared after password change', user: authData.user || 'admin' });
4707
+ } catch (passkeyErr) {
4708
+ request.logger.error({ msg: 'Failed to clear passkeys after password change', err: passkeyErr });
4709
+ }
4710
+
4210
4711
  if (!server.auth.settings.default) {
4211
4712
  server.auth.default('session');
4212
4713
  request.cookieAuth.set({
@@ -5638,7 +6139,7 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
5638
6139
  if (request.cookieAuth) {
5639
6140
  request.cookieAuth.clear();
5640
6141
  }
5641
- await request.flash({ type: 'info', message: `Sign in again to continue` });
6142
+ await request.flash({ type: 'info', message: `Sign in again to continue.` });
5642
6143
  return h.redirect('/admin/login?next=' + encodeURIComponent('/admin/accounts/{account}/browse'));
5643
6144
  }
5644
6145