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.
- package/.github/workflows/deploy.yml +8 -8
- package/.github/workflows/release.yaml +9 -9
- package/.github/workflows/test.yml +2 -2
- package/CHANGELOG.md +53 -0
- package/bin/emailengine.js +3 -0
- package/data/google-crawlers.json +7 -1
- package/lib/account.js +35 -29
- package/lib/consts.js +5 -0
- package/lib/email-client/gmail-client.js +23 -27
- package/lib/email-client/imap/mailbox.js +46 -19
- package/lib/email-client/imap/sync-operations.js +51 -19
- package/lib/email-client/imap-client.js +28 -5
- package/lib/email-client/outlook-client.js +155 -1
- package/lib/oauth/gmail.js +52 -1
- package/lib/passkeys.js +206 -0
- package/lib/routes-ui.js +522 -21
- package/lib/ui-routes/oauth-routes.js +6 -1
- package/package.json +13 -11
- package/sbom.json +1 -1
- package/static/js/login-passkey.js +75 -0
- package/static/js/passkey-register.js +107 -0
- package/static/licenses.html +238 -38
- package/static/vendor/handlebars/handlebars.min-v4.7.9.js +29 -0
- package/static/vendor/simplewebauthn/browser.min.js +2 -0
- package/translations/de.mo +0 -0
- package/translations/de.po +91 -53
- package/translations/en.mo +0 -0
- package/translations/en.po +84 -52
- package/translations/et.mo +0 -0
- package/translations/et.po +95 -60
- package/translations/fr.mo +0 -0
- package/translations/fr.po +102 -65
- package/translations/ja.mo +0 -0
- package/translations/ja.po +93 -57
- package/translations/messages.pot +101 -76
- package/translations/nl.mo +0 -0
- package/translations/nl.po +92 -56
- package/translations/pl.mo +0 -0
- package/translations/pl.po +106 -70
- package/views/account/login.hbs +35 -25
- package/views/account/password.hbs +4 -4
- package/views/account/security.hbs +101 -12
- package/views/account/totp.hbs +3 -3
- package/views/config/oauth/app.hbs +25 -0
- package/views/layout/app.hbs +2 -2
- package/views/layout/login.hbs +6 -1
- package/views/oauth-scope-error.hbs +29 -0
- package/workers/api.js +81 -3
- package/workers/imap.js +4 -0
- 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: '
|
|
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()
|
|
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
|
|
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 || `
|
|
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: `
|
|
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()
|
|
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()
|
|
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 || `
|
|
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({
|
|
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: `
|
|
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()
|
|
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: `
|
|
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: `
|
|
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
|
|
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
|
|