emailengine-app 2.68.0 → 2.69.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/codeql/codeql-config.yml +16 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/deploy.yml +8 -0
- package/.github/workflows/release.yaml +4 -0
- package/.github/workflows/test.yml +3 -0
- package/CHANGELOG.md +49 -0
- package/SECURITY.md +80 -0
- package/SECURITY.txt +27 -0
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +13 -1
- package/lib/account.js +62 -25
- package/lib/api-routes/account-routes.js +493 -75
- package/lib/api-routes/blocklist-routes.js +337 -0
- package/lib/api-routes/delivery-test-routes.js +321 -0
- package/lib/api-routes/export-routes.js +1 -12
- package/lib/api-routes/gateway-routes.js +376 -0
- package/lib/api-routes/license-routes.js +142 -0
- package/lib/api-routes/mailbox-routes.js +318 -0
- package/lib/api-routes/message-routes.js +21 -129
- package/lib/api-routes/oauth2-app-routes.js +631 -0
- package/lib/api-routes/outbox-routes.js +173 -0
- package/lib/api-routes/pubsub-routes.js +98 -0
- package/lib/api-routes/route-helpers.js +45 -0
- package/lib/api-routes/settings-routes.js +331 -0
- package/lib/api-routes/stats-routes.js +77 -0
- package/lib/api-routes/submit-routes.js +472 -0
- package/lib/api-routes/template-routes.js +7 -55
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +152 -0
- package/lib/email-client/gmail-client.js +14 -0
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -12
- package/lib/email-client/imap/sync-operations.js +130 -2
- package/lib/email-client/imap-client.js +116 -58
- package/lib/email-client/outlook-client.js +85 -13
- package/lib/export.js +60 -19
- package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
- package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
- package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
- package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/imapproxy/imap-server.js +92 -29
- package/lib/message-port-stream.js +113 -16
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +26 -1
- package/lib/tools.js +73 -0
- package/lib/ui-routes/account-routes.js +40 -210
- package/lib/ui-routes/admin-config-routes.js +913 -487
- package/lib/ui-routes/admin-entities-routes.js +1 -0
- package/lib/ui-routes/auth-routes.js +1339 -0
- package/lib/ui-routes/dashboard-routes.js +188 -0
- package/lib/ui-routes/document-store-routes.js +800 -0
- package/lib/ui-routes/export-routes.js +217 -0
- package/lib/ui-routes/internals-routes.js +354 -0
- package/lib/ui-routes/network-config-routes.js +759 -0
- package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
- package/lib/ui-routes/route-helpers.js +316 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +234 -0
- package/lib/webhook-request.js +36 -0
- package/package.json +17 -17
- package/sbom.json +1 -1
- package/server.js +217 -19
- package/static/licenses.html +52 -182
- package/translations/messages.pot +131 -151
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +198 -4401
- package/workers/export.js +87 -54
- package/workers/imap.js +29 -13
- package/workers/submit.js +20 -11
- package/workers/webhooks.js +6 -20
|
@@ -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;
|