@adminforth/two-factors-auth 1.9.2 → 1.10.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/build.log +3 -2
- package/custom/TwoFactorsConfirmation.vue +241 -55
- package/custom/TwoFactorsPasskeysSettings.vue +334 -0
- package/custom/TwoFactorsSetup.vue +15 -5
- package/custom/package-lock.json +7 -0
- package/custom/package.json +1 -0
- package/dist/custom/TwoFactorsConfirmation.vue +241 -55
- package/dist/custom/TwoFactorsPasskeysSettings.vue +334 -0
- package/dist/custom/TwoFactorsSetup.vue +15 -5
- package/dist/custom/package-lock.json +7 -0
- package/dist/custom/package.json +1 -0
- package/dist/index.js +449 -35
- package/index.ts +463 -37
- package/package.json +3 -1
- package/types.ts +62 -1
package/index.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { AdminForthPlugin, Filters } from "adminforth";
|
|
|
2
2
|
import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth, BeforeLoginConfirmationFunction, IAdminForthHttpResponse } from "adminforth";
|
|
3
3
|
import twofactor from 'node-2fa';
|
|
4
4
|
import { PluginOptions } from "./types.js"
|
|
5
|
+
import {
|
|
6
|
+
generateRegistrationOptions,
|
|
7
|
+
verifyRegistrationResponse,
|
|
8
|
+
generateAuthenticationOptions,
|
|
9
|
+
verifyAuthenticationResponse
|
|
10
|
+
} from '@simplewebauthn/server';
|
|
11
|
+
import { isoUint8Array, isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
5
12
|
|
|
6
13
|
export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
7
14
|
options: PluginOptions;
|
|
@@ -47,23 +54,161 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
47
54
|
return verified ? { ok: true } : { error: "Wrong or expired OTP code" };
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
public parsePeriod(period?: string): number {
|
|
58
|
+
if (!period) return 0;
|
|
59
|
+
|
|
60
|
+
const match = /^(\d+)([dhms])$/.exec(period.trim());
|
|
61
|
+
if (!match) throw new Error(`Invalid suggestionPeriod format: ${period}`);
|
|
62
|
+
|
|
63
|
+
const value = parseInt(match[1], 10);
|
|
64
|
+
const unit = match[2];
|
|
65
|
+
|
|
66
|
+
switch (unit) {
|
|
67
|
+
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
68
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
69
|
+
case 'm': return value * 60 * 1000;
|
|
70
|
+
case 's': return value * 1000;
|
|
71
|
+
default: return value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public async verifyPasskeyResponse(body: any, user_id: string, cookies: any) {
|
|
76
|
+
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
77
|
+
const expectedOrigin = body.origin;
|
|
78
|
+
const expectedChallenge = cookies.challenge;
|
|
79
|
+
const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname;
|
|
80
|
+
const response = JSON.parse(body.response);
|
|
81
|
+
try {
|
|
82
|
+
if (settingsOrigin !== expectedOrigin) {
|
|
83
|
+
throw new Error('Invalid origin');
|
|
84
|
+
}
|
|
85
|
+
const cred = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, response.id)]);
|
|
86
|
+
if (!cred) {
|
|
87
|
+
throw new Error('Credential not found.');
|
|
88
|
+
}
|
|
89
|
+
const credMeta = JSON.parse(cred[this.options.passkeys.credentialMetaFieldName]);
|
|
90
|
+
if (!credMeta || !credMeta.public_key) {
|
|
91
|
+
throw new Error('Credential public key not found.');
|
|
92
|
+
}
|
|
93
|
+
const userResourceId = this.adminforth.config.auth.usersResourceId;
|
|
94
|
+
const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
|
|
95
|
+
const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
|
|
96
|
+
const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
|
|
97
|
+
const user = await this.adminforth.resource(userResourceId).get([Filters.EQ(usersPrimaryKeyFieldName, cred[this.options.passkeys.credentialUserIdFieldName])]);
|
|
98
|
+
if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) {
|
|
99
|
+
throw new Error('User not found.');
|
|
100
|
+
}
|
|
101
|
+
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
|
|
102
|
+
response,
|
|
103
|
+
expectedChallenge,
|
|
104
|
+
expectedOrigin,
|
|
105
|
+
expectedRPID,
|
|
106
|
+
credential: {
|
|
107
|
+
id: cred[this.options.passkeys.credentialIdFieldName],
|
|
108
|
+
publicKey: isoBase64URL.toBuffer(credMeta.public_key),
|
|
109
|
+
counter: credMeta.counter,
|
|
110
|
+
transports: credMeta.transports,
|
|
111
|
+
},
|
|
112
|
+
requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!verified) {
|
|
116
|
+
return { ok: false, error: 'User verification failed.' };
|
|
117
|
+
}
|
|
118
|
+
credMeta.counter = authenticationInfo.newCounter;
|
|
119
|
+
credMeta.last_used_at = new Date().toISOString();
|
|
120
|
+
await this.adminforth.resource(this.options.passkeys.credentialResourceID).update(cred[this.options.passkeys.credentialIdFieldName], { [this.options.passkeys.credentialMetaFieldName]: JSON.stringify(credMeta) });
|
|
121
|
+
return { ok: true, passkeyConfirmed: true };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { ok: false, error: 'Error authenticating passkey: ' + e };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
50
127
|
modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
51
128
|
super.modifyResourceConfig(adminforth, resourceConfig);
|
|
52
129
|
this.adminforth = adminforth;
|
|
53
130
|
this.adminForthAuth = adminforth.auth;
|
|
131
|
+
const suggestionPeriod = this.parsePeriod(this.options.passkeys?.suggestionPeriod || "5d");
|
|
132
|
+
const isPasskeysEnabled = this.options.passkeys ? true : false;
|
|
54
133
|
|
|
55
134
|
const customPages = this.adminforth.config.customization.customPages
|
|
56
135
|
customPages.push({
|
|
57
136
|
path:'/confirm2fa',
|
|
58
|
-
component: { file: this.componentPath('TwoFactorsConfirmation.vue'), meta: { customLayout: true }}
|
|
137
|
+
component: { file: this.componentPath('TwoFactorsConfirmation.vue'), meta: { customLayout: true, suggestionPeriod: suggestionPeriod, isPasskeysEnabled: isPasskeysEnabled } }
|
|
59
138
|
})
|
|
60
139
|
customPages.push({
|
|
61
140
|
path:'/setup2fa',
|
|
62
|
-
component: { file: this.componentPath('TwoFactorsSetup.vue'), meta: { title: 'Setup 2FA', customLayout: true }}
|
|
141
|
+
component: { file: this.componentPath('TwoFactorsSetup.vue'), meta: { title: 'Setup 2FA', customLayout: true, suggestionPeriod: suggestionPeriod, isPasskeysEnabled: isPasskeysEnabled } }
|
|
63
142
|
})
|
|
64
143
|
const everyPageBottomInjections = this.adminforth.config.customization.globalInjections.everyPageBottom || []
|
|
65
144
|
everyPageBottomInjections.push({ file: this.componentPath('TwoFAModal.vue'), meta: {} })
|
|
66
145
|
this.activate( resourceConfig, adminforth )
|
|
146
|
+
|
|
147
|
+
if ( this.options.passkeys ) {
|
|
148
|
+
if( !this.adminforth.config.auth.userMenuSettingsPages ){
|
|
149
|
+
this.adminforth.config.auth.userMenuSettingsPages = [];
|
|
150
|
+
}
|
|
151
|
+
this.adminforth.config.auth.userMenuSettingsPages.push({
|
|
152
|
+
icon: 'flowbite:lock-solid',
|
|
153
|
+
pageLabel: 'Passkeys',
|
|
154
|
+
slug: 'passkeys',
|
|
155
|
+
component: this.componentPath('TwoFactorsPasskeysSettings.vue'),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
161
|
+
if (this.options.passkeys) {
|
|
162
|
+
|
|
163
|
+
const adminForthResources = [];
|
|
164
|
+
for (const res of adminforth.config.resources) {
|
|
165
|
+
adminForthResources.push(res.resourceId);
|
|
166
|
+
}
|
|
167
|
+
if (!this.options.passkeys.credentialResourceID) {
|
|
168
|
+
throw new Error('Passkeys credentialResourceID is required');
|
|
169
|
+
}
|
|
170
|
+
if ( !(adminForthResources.includes(this.options.passkeys.credentialResourceID)) ) {
|
|
171
|
+
throw new Error('Passkeys credentialResourceID is not valid');
|
|
172
|
+
}
|
|
173
|
+
if (!this.options.passkeys.credentialIdFieldName) {
|
|
174
|
+
throw new Error('Passkeys credentialIdFieldName is required');
|
|
175
|
+
}
|
|
176
|
+
if (!this.options.passkeys.credentialMetaFieldName) {
|
|
177
|
+
throw new Error('Passkeys credentialMetaFieldName is required');
|
|
178
|
+
}
|
|
179
|
+
if (!this.options.passkeys.credentialUserIdFieldName) {
|
|
180
|
+
throw new Error('Passkeys credentialUserIdFieldName is required');
|
|
181
|
+
}
|
|
182
|
+
if (!this.options.passkeys.settings) {
|
|
183
|
+
throw new Error('Passkeys settings are required when passkeys option is enabled');
|
|
184
|
+
}
|
|
185
|
+
if (!this.options.passkeys.settings.expectedOrigin) {
|
|
186
|
+
throw new Error('Passkeys settings.expectedOrigin is required');
|
|
187
|
+
}
|
|
188
|
+
const origin = new URL( this.options.passkeys.settings.expectedOrigin ).origin;
|
|
189
|
+
if ( origin !== this.options.passkeys.settings.expectedOrigin ) {
|
|
190
|
+
throw new Error('Passkeys settings.expectedOrigin is not valid');
|
|
191
|
+
}
|
|
192
|
+
if (this.options.passkeys.settings.authenticatorSelection) {
|
|
193
|
+
if (this.options.passkeys.settings.authenticatorSelection.authenticatorAttachment) {
|
|
194
|
+
if ( !['platform', 'cross-platform', 'both'].includes(this.options.passkeys.settings.authenticatorSelection.authenticatorAttachment) ) {
|
|
195
|
+
throw new Error('Passkeys settings.authenticatorSelection.authenticatorAttachment is not valid');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (this.options.passkeys.settings.authenticatorSelection.userVerification) {
|
|
199
|
+
if ( !['required', 'discouraged'].includes(this.options.passkeys.settings.authenticatorSelection.userVerification) ) {
|
|
200
|
+
throw new Error('Passkeys settings.authenticatorSelection.userVerification is not valid');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!this.options.passkeys.settings.user) {
|
|
206
|
+
throw new Error('Passkeys settings.user is required');
|
|
207
|
+
}
|
|
208
|
+
if (!this.options.passkeys.settings.user.nameField) {
|
|
209
|
+
throw new Error('Passkeys settings.user.nameField is required');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
67
212
|
}
|
|
68
213
|
|
|
69
214
|
activate ( resourceConfig: AdminForthResource, adminforth: IAdminForth ){
|
|
@@ -93,8 +238,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
93
238
|
const userName = adminUser.dbUser[adminforth.config.auth.usernameField]
|
|
94
239
|
const brandName = adminforth.config.customization.brandName;
|
|
95
240
|
const brandNameSlug = adminforth.config.customization.brandNameSlug;
|
|
96
|
-
const issuerName = (this.options.
|
|
97
|
-
? this.options.
|
|
241
|
+
const issuerName = (this.options.customBrandPrefix && this.options.customBrandPrefix.trim())
|
|
242
|
+
? this.options.customBrandPrefix.trim()
|
|
98
243
|
: brandName;
|
|
99
244
|
const authResource = adminforth.config.resources.find((res)=>res.resourceId === adminforth.config.auth.usersResourceId )
|
|
100
245
|
const authPk = authResource.columns.find((col)=>col.primaryKey).name
|
|
@@ -112,10 +257,22 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
112
257
|
|
|
113
258
|
if (!secret){
|
|
114
259
|
const tempSecret = twofactor.generateSecret({name: issuerName,account: userName})
|
|
115
|
-
newSecret = tempSecret.secret
|
|
260
|
+
newSecret = tempSecret.secret;
|
|
261
|
+
|
|
262
|
+
const totpTemporaryJWT = this.adminforth.auth.issueJWT({userName, newSecret, issuer:issuerName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'temp2FA', '2h');
|
|
263
|
+
this.adminforth.auth.setCustomCookie({response, payload: {name: "2FaTemporaryJWT", value: totpTemporaryJWT, expiry: 1 * 60 * 60 * 1000, httpOnly: true}});
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
body:{
|
|
267
|
+
loginAllowed: false,
|
|
268
|
+
redirectTo: secret ? '/confirm2fa' : '/setup2fa',
|
|
269
|
+
},
|
|
270
|
+
ok: true
|
|
271
|
+
}
|
|
272
|
+
|
|
116
273
|
} else {
|
|
117
|
-
const value = this.adminforth.auth.issueJWT({userName, issuer:issuerName, pk:userPk, userCanSkipSetup, rememberMeDays }, '
|
|
118
|
-
|
|
274
|
+
const value = this.adminforth.auth.issueJWT({userName, issuer:issuerName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'temp2FA', '2h');
|
|
275
|
+
this.adminforth.auth.setCustomCookie({response, payload: {name: "2FaTemporaryJWT", value: value, expiry: 1 * 60 * 60 * 1000, httpOnly: true}});
|
|
119
276
|
|
|
120
277
|
return {
|
|
121
278
|
body:{
|
|
@@ -125,16 +282,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
125
282
|
ok: true
|
|
126
283
|
}
|
|
127
284
|
}
|
|
128
|
-
|
|
129
|
-
response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${totpTemporaryJWT}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=${new Date(Date.now() + '1h').toUTCString() } `);
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
body:{
|
|
133
|
-
loginAllowed: false,
|
|
134
|
-
redirectTo: secret ? '/confirm2fa' : '/setup2fa',
|
|
135
|
-
},
|
|
136
|
-
ok: true
|
|
137
|
-
}
|
|
285
|
+
|
|
138
286
|
})
|
|
139
287
|
}
|
|
140
288
|
|
|
@@ -145,27 +293,29 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
145
293
|
noAuth: true,
|
|
146
294
|
handler: async (server) => {
|
|
147
295
|
const toReturn = {totpJWT:null,status:'ok',}
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
const totpTemporaryJWT = server.cookies.find((cookie)=>cookie.key === `adminforth_${brandNameSlug}_totpTemporaryJWT`)?.value;
|
|
296
|
+
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: server.cookies, name: "2FaTemporaryJWT"});
|
|
151
297
|
if (totpTemporaryJWT){
|
|
152
298
|
toReturn.totpJWT = totpTemporaryJWT
|
|
153
299
|
}
|
|
154
300
|
return toReturn
|
|
155
301
|
}
|
|
156
302
|
})
|
|
303
|
+
|
|
304
|
+
// this enpoints sets final login cookie if 2nd factor is confirmed
|
|
157
305
|
server.endpoint({
|
|
158
306
|
method: 'POST',
|
|
159
|
-
path: `/plugin/twofa/
|
|
307
|
+
path: `/plugin/twofa/confirmLogin`,
|
|
160
308
|
noAuth: true,
|
|
161
309
|
handler: async ({ body, adminUser, response, cookies }) => {
|
|
162
310
|
const brandNameSlug = this.adminforth.config.customization.brandNameSlug;
|
|
163
|
-
const totpTemporaryJWT =
|
|
164
|
-
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, '
|
|
165
|
-
if (!decoded)
|
|
166
|
-
return {status:'error',message:'Invalid token'}
|
|
311
|
+
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "2FaTemporaryJWT"});
|
|
312
|
+
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'temp2FA');
|
|
313
|
+
if (!decoded) {
|
|
314
|
+
return { status:'error', message:'Invalid token' }
|
|
315
|
+
}
|
|
167
316
|
|
|
168
317
|
if (decoded.newSecret) {
|
|
318
|
+
// set up standard TOTP request - ensured by presence of newSecret in temp2FA token
|
|
169
319
|
const verified = body.skip && decoded.userCanSkipSetup ? true : twofactor.verifyToken(decoded.newSecret, body.code, this.options.timeStepWindow);
|
|
170
320
|
if (verified) {
|
|
171
321
|
this.connectors = this.adminforth.connectors
|
|
@@ -173,20 +323,38 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
173
323
|
const connector = this.connectors[this.authResource.dataSource];
|
|
174
324
|
await connector.updateRecord({resource:this.authResource, recordId:decoded.pk, newValues:{[this.options.twoFaSecretFieldName]: decoded.newSecret}})
|
|
175
325
|
}
|
|
176
|
-
this.adminforth.auth.removeCustomCookie({response, name:'
|
|
326
|
+
this.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'})
|
|
177
327
|
this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
|
|
178
328
|
return { status: 'ok', allowedLogin: true }
|
|
179
329
|
} else {
|
|
180
330
|
return {error: 'Wrong or expired OTP code'}
|
|
181
331
|
}
|
|
182
332
|
} else {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
333
|
+
// login with confirming existing TOTP or Passkey
|
|
334
|
+
let verified = null;
|
|
335
|
+
if (body.usePasskey && this.options.passkeys) {
|
|
336
|
+
// passkeys are enabled and user wants to use them
|
|
337
|
+
const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyTemporaryJWT`});
|
|
338
|
+
if (!passkeysCookies) {
|
|
339
|
+
return { error: 'Passkey token is required' };
|
|
340
|
+
}
|
|
341
|
+
const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempPasskeyChallenge', false);
|
|
342
|
+
if (!decodedPasskeysCookies) {
|
|
343
|
+
return { error: 'Invalid passkey token' };
|
|
344
|
+
}
|
|
345
|
+
const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, decodedPasskeysCookies);
|
|
346
|
+
if (res.ok && res.passkeyConfirmed) {
|
|
347
|
+
verified = true;
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
// user already has TOTP secret, get it
|
|
351
|
+
this.connectors = this.adminforth.connectors
|
|
352
|
+
const connector = this.connectors[this.authResource.dataSource];
|
|
353
|
+
const user = await connector.getRecordByPrimaryKey(this.authResource, decoded.pk)
|
|
354
|
+
verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow);
|
|
355
|
+
}
|
|
188
356
|
if (verified) {
|
|
189
|
-
this.adminforth.auth.removeCustomCookie({response, name:'
|
|
357
|
+
this.adminforth.auth.removeCustomCookie({response, name:'2FaTemporaryJWT'})
|
|
190
358
|
this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
|
|
191
359
|
return { status: 'ok', allowedLogin: true }
|
|
192
360
|
} else {
|
|
@@ -200,13 +368,13 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
200
368
|
path: "/plugin/twofa/skip-allow",
|
|
201
369
|
noAuth: true,
|
|
202
370
|
handler: async ({ cookies }) => {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
)
|
|
371
|
+
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({
|
|
372
|
+
cookies,
|
|
373
|
+
name: `2FaTemporaryJWT`,
|
|
374
|
+
});
|
|
207
375
|
const decoded = await this.adminforth.auth.verify(
|
|
208
376
|
totpTemporaryJWT,
|
|
209
|
-
"
|
|
377
|
+
"temp2FA",
|
|
210
378
|
);
|
|
211
379
|
if (!decoded) {
|
|
212
380
|
return { status: "error", message: "Invalid token" };
|
|
@@ -241,5 +409,263 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
241
409
|
return verified ? { ok: true } : { error: 'Wrong or expired OTP code' };
|
|
242
410
|
}
|
|
243
411
|
});
|
|
412
|
+
server.endpoint({
|
|
413
|
+
method: 'POST',
|
|
414
|
+
path: `/plugin/passkeys/registerPasskeyRequest`,
|
|
415
|
+
noAuth: false,
|
|
416
|
+
handler: async ({ body, adminUser, response }) => {
|
|
417
|
+
const mode = body?.mode;
|
|
418
|
+
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
419
|
+
const rp = {
|
|
420
|
+
name: this.options.passkeys?.settings.rp.name || this.adminforth.config.customization.brandName,
|
|
421
|
+
id: this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname,
|
|
422
|
+
};
|
|
423
|
+
const userResourceId = this.adminforth.config.auth.usersResourceId;
|
|
424
|
+
const usersResource = this.adminforth.config.resources.find(r => r.resourceId === userResourceId);
|
|
425
|
+
const usersPrimaryKeyColumn = usersResource.columns.find((col) => col.primaryKey);
|
|
426
|
+
const usersPrimaryKeyFieldName = usersPrimaryKeyColumn.name;
|
|
427
|
+
const userInfo = await this.adminforth.resource(userResourceId).get( [Filters.EQ(usersPrimaryKeyFieldName, adminUser.pk)] );
|
|
428
|
+
const user = {
|
|
429
|
+
id: adminUser.pk,
|
|
430
|
+
name: userInfo[this.options.passkeys?.settings.user.nameField],
|
|
431
|
+
displayName: userInfo[this.options.passkeys?.settings.user.displayNameField] ? userInfo[this.options.passkeys?.settings.user.displayNameField] : userInfo[this.options.passkeys?.settings.user.nameField],
|
|
432
|
+
};
|
|
433
|
+
const excludeCredentials = [];
|
|
434
|
+
const temp = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list([Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
|
|
435
|
+
for (const rec of temp) {
|
|
436
|
+
if (rec.credential_id && rec.credential_id.length > 0) {
|
|
437
|
+
const meta = JSON.parse(rec[this.options.passkeys.credentialMetaFieldName]);
|
|
438
|
+
excludeCredentials.push({
|
|
439
|
+
id: rec.credential_id,
|
|
440
|
+
type: "public-key",
|
|
441
|
+
transports: JSON.parse(meta.transports || '[]')
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const options = await generateRegistrationOptions({
|
|
446
|
+
rpName: rp.name,
|
|
447
|
+
rpID: rp.id,
|
|
448
|
+
userID: isoUint8Array.fromUTF8String(user.id),
|
|
449
|
+
userName: user.name,
|
|
450
|
+
userDisplayName: user.displayName,
|
|
451
|
+
excludeCredentials,
|
|
452
|
+
authenticatorSelection: {
|
|
453
|
+
authenticatorAttachment: mode,
|
|
454
|
+
requireResidentKey: this.options.passkeys?.settings.authenticatorSelection.requireResidentKey || true,
|
|
455
|
+
userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required"
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
const value = this.adminforth.auth.issueJWT({ "challenge": options.challenge }, 'tempPasskeyChallenge', '5m');
|
|
459
|
+
this.adminforth.auth.setCustomCookie({response, payload: {name: "passkeyTemporaryJWT", value: value, expiry: 1 * 60 * 60 * 1000, httpOnly: true}});
|
|
460
|
+
return { ok: true, data: options };
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
server.endpoint({
|
|
464
|
+
method: 'POST',
|
|
465
|
+
path: `/plugin/passkeys/finishRegisteringPasskey`,
|
|
466
|
+
noAuth: false,
|
|
467
|
+
handler: async ({body, adminUser, cookies }) => {
|
|
468
|
+
const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "passkeyTemporaryJWT"});
|
|
469
|
+
if (!passkeysCookies) {
|
|
470
|
+
return { error: 'Passkey token is required' };
|
|
471
|
+
}
|
|
472
|
+
const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempPasskeyChallenge', false);
|
|
473
|
+
if (!decodedPasskeysCookies) {
|
|
474
|
+
return { error: 'Invalid passkey token' };
|
|
475
|
+
}
|
|
476
|
+
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
477
|
+
const expectedOrigin = body.origin;
|
|
478
|
+
const expectedChallenge = decodedPasskeysCookies.challenge;
|
|
479
|
+
const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname;
|
|
480
|
+
const response = JSON.parse(body.credential);
|
|
481
|
+
try {
|
|
482
|
+
if (settingsOrigin !== expectedOrigin) {
|
|
483
|
+
throw new Error('Invalid origin');
|
|
484
|
+
}
|
|
485
|
+
const { verified, registrationInfo } = await verifyRegistrationResponse({
|
|
486
|
+
response,
|
|
487
|
+
expectedChallenge,
|
|
488
|
+
expectedOrigin,
|
|
489
|
+
expectedRPID,
|
|
490
|
+
requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
if (!verified) {
|
|
494
|
+
throw new Error('Verification failed.');
|
|
495
|
+
}
|
|
496
|
+
const {
|
|
497
|
+
aaguid,
|
|
498
|
+
credential,
|
|
499
|
+
credentialBackedUp
|
|
500
|
+
} = registrationInfo;
|
|
501
|
+
|
|
502
|
+
const credentialPublicKey = credential.publicKey;
|
|
503
|
+
const credentialID = credential.id;
|
|
504
|
+
|
|
505
|
+
const base64CredentialID = credentialID;
|
|
506
|
+
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
|
|
507
|
+
|
|
508
|
+
const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
509
|
+
if (!credResource) {
|
|
510
|
+
throw new Error('Credential resource not found.');
|
|
511
|
+
}
|
|
512
|
+
await this.adminforth.createResourceRecord({
|
|
513
|
+
resource: credResource,
|
|
514
|
+
record: {
|
|
515
|
+
[this.options.passkeys.credentialIdFieldName] : base64CredentialID,
|
|
516
|
+
[this.options.passkeys.credentialUserIdFieldName] : adminUser.pk,
|
|
517
|
+
[this.options.passkeys.credentialMetaFieldName] : JSON.stringify({
|
|
518
|
+
public_key : base64PublicKey,
|
|
519
|
+
public_key_algorithm : response.response.publicKeyAlgorithm,
|
|
520
|
+
sign_count : 0,
|
|
521
|
+
transports : JSON.stringify(response.response.transports),
|
|
522
|
+
created_at : new Date().toISOString(),
|
|
523
|
+
last_used_at : new Date().toISOString(),
|
|
524
|
+
aaguid : aaguid,
|
|
525
|
+
name : `Passkey ${adminUser.username}`,
|
|
526
|
+
}),
|
|
527
|
+
},
|
|
528
|
+
adminUser: adminUser
|
|
529
|
+
});
|
|
530
|
+
} catch (error) {
|
|
531
|
+
console.error(error);
|
|
532
|
+
return { error: 'Error registering passkey: ' + error.message };
|
|
533
|
+
}
|
|
534
|
+
return { ok: true };
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
server.endpoint({
|
|
538
|
+
method: 'POST',
|
|
539
|
+
path: `/plugin/passkeys/signInRequest`,
|
|
540
|
+
noAuth: true,
|
|
541
|
+
handler: async ({ response }) => {
|
|
542
|
+
try {
|
|
543
|
+
const options = await generateAuthenticationOptions({
|
|
544
|
+
rpID: this.options.passkeys?.settings.rp.id,
|
|
545
|
+
userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required"
|
|
546
|
+
});
|
|
547
|
+
const value = this.adminforth.auth.issueJWT({ "challenge": options.challenge }, 'tempPasskeyChallenge', '5m');
|
|
548
|
+
this.adminforth.auth.setCustomCookie({response, payload: {name: `passkeyTemporaryJWT`, value: value, expiry: 1 * 60 * 60 * 1000, httpOnly: true}});
|
|
549
|
+
return { ok: true, data: options };
|
|
550
|
+
} catch (e) {
|
|
551
|
+
return { ok: false, error: e };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
server.endpoint({
|
|
556
|
+
method: 'GET',
|
|
557
|
+
path: `/plugin/passkeys/getPasskeys`,
|
|
558
|
+
noAuth: false,
|
|
559
|
+
handler: async ({ adminUser }) => {
|
|
560
|
+
let passkeys;
|
|
561
|
+
try {
|
|
562
|
+
passkeys = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list( [Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)] );
|
|
563
|
+
} catch (error) {
|
|
564
|
+
return { ok: false, error: 'Error fetching passkeys: ' + error.message };
|
|
565
|
+
}
|
|
566
|
+
let dataToReturn = [];
|
|
567
|
+
for (const pk of passkeys) {
|
|
568
|
+
const parsedKey = JSON.parse(pk[this.options.passkeys.credentialMetaFieldName]);
|
|
569
|
+
dataToReturn.push({
|
|
570
|
+
name: parsedKey.name,
|
|
571
|
+
created_at: parsedKey.created_at,
|
|
572
|
+
last_used_at: parsedKey.last_used_at,
|
|
573
|
+
id: pk[this.options.passkeys.credentialIdFieldName],
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return { ok: true, data: dataToReturn, authenticatorAttachment: this.options.passkeys?.settings.authenticatorSelection.authenticatorAttachment || 'both' };
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
server.endpoint({
|
|
580
|
+
method: 'DELETE',
|
|
581
|
+
path: `/plugin/passkeys/deletePasskey`,
|
|
582
|
+
noAuth: false,
|
|
583
|
+
handler: async ({body, adminUser }) => {
|
|
584
|
+
const passkeyId = body.passkeyId;
|
|
585
|
+
if (!passkeyId) {
|
|
586
|
+
return { ok: false, error: 'Passkey ID is required' };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, passkeyId), Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
|
|
590
|
+
if (!passkeyRecord) {
|
|
591
|
+
return { ok: false, error: 'Passkey not found' };
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
595
|
+
if (!credResource) {
|
|
596
|
+
throw new Error('Credential resource not found.');
|
|
597
|
+
}
|
|
598
|
+
await this.adminforth.deleteResourceRecord({
|
|
599
|
+
resource: credResource,
|
|
600
|
+
recordId: passkeyId,
|
|
601
|
+
record: passkeyRecord,
|
|
602
|
+
adminUser: adminUser,
|
|
603
|
+
});
|
|
604
|
+
} catch (error) {
|
|
605
|
+
return { ok: false, error: 'Error deleting passkey: ' + error.message };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return { ok: true };
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
server.endpoint({
|
|
612
|
+
method: 'POST',
|
|
613
|
+
path: `/plugin/passkeys/renamePasskey`,
|
|
614
|
+
noAuth: false,
|
|
615
|
+
handler: async ({body, adminUser }) => {
|
|
616
|
+
const passkeyId = body.passkeyId;
|
|
617
|
+
const newName = body.newName;
|
|
618
|
+
if (!passkeyId) {
|
|
619
|
+
return { ok: false, error: 'Passkey ID is required' };
|
|
620
|
+
}
|
|
621
|
+
if (!newName) {
|
|
622
|
+
return { ok: false, error: 'New name is required' };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const passkeyRecord = await this.adminforth.resource(this.options.passkeys.credentialResourceID).get([Filters.EQ(this.options.passkeys.credentialIdFieldName, passkeyId), Filters.EQ(this.options.passkeys.credentialUserIdFieldName, adminUser.pk)]);
|
|
626
|
+
if (!passkeyRecord) {
|
|
627
|
+
return { ok: false, error: 'Passkey not found' };
|
|
628
|
+
}
|
|
629
|
+
const meta = JSON.parse(passkeyRecord[this.options.passkeys.credentialMetaFieldName]);
|
|
630
|
+
meta.name = newName;
|
|
631
|
+
const newRecord = { ...passkeyRecord, [this.options.passkeys.credentialMetaFieldName]: JSON.stringify(meta) };
|
|
632
|
+
try {
|
|
633
|
+
const credResource = this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
634
|
+
if (!credResource) {
|
|
635
|
+
throw new Error('Credential resource not found.');
|
|
636
|
+
}
|
|
637
|
+
await this.adminforth.updateResourceRecord({
|
|
638
|
+
resource: credResource,
|
|
639
|
+
recordId: passkeyId,
|
|
640
|
+
oldRecord: passkeyRecord,
|
|
641
|
+
record: newRecord,
|
|
642
|
+
adminUser: adminUser
|
|
643
|
+
});
|
|
644
|
+
} catch (error) {
|
|
645
|
+
return { ok: false, error: 'Error renaming passkey: ' + error.message };
|
|
646
|
+
}
|
|
647
|
+
return { ok: true };
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
server.endpoint({
|
|
651
|
+
method: 'POST',
|
|
652
|
+
path: `/plugin/passkeys/checkIfUserHasPasskeys`,
|
|
653
|
+
noAuth: true,
|
|
654
|
+
handler: async ({ cookies }) => {
|
|
655
|
+
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "2FaTemporaryJWT"});
|
|
656
|
+
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'temp2FA');
|
|
657
|
+
if (!decoded)
|
|
658
|
+
return { ok: false, error:'Invalid token'}
|
|
659
|
+
if (decoded.newSecret) {
|
|
660
|
+
return { ok: true, hasPasskeys: false };
|
|
661
|
+
}
|
|
662
|
+
const passkeys = await this.adminforth.resource(this.options.passkeys.credentialResourceID).list( [Filters.EQ(this.options.passkeys.credentialUserIdFieldName, decoded.pk)] );
|
|
663
|
+
if (passkeys && passkeys.length > 0) {
|
|
664
|
+
return { ok: true, hasPasskeys: true };
|
|
665
|
+
} else {
|
|
666
|
+
return { ok: true, hasPasskeys: false };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
});
|
|
244
670
|
}
|
|
245
671
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adminforth/two-factors-auth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "AdminForth plugin for two factors authentication",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"author": "devforth",
|
|
26
26
|
"license": "ISC",
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"@simplewebauthn/server": "^13.2.1",
|
|
29
|
+
"adminforth": "^2.4.0-next.193",
|
|
28
30
|
"node-2fa": "^2.0.3"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|