@adminforth/two-factors-auth 3.3.9 → 4.0.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 +2 -2
- package/custom/TwoFAModal.vue +4 -4
- package/custom/TwoFactorsConfirmation.vue +9 -10
- package/custom/TwoFactorsPasskeysSettings.vue +10 -10
- package/custom/utils.js +7 -8
- package/dist/custom/TwoFAModal.vue +4 -4
- package/dist/custom/TwoFactorsConfirmation.vue +9 -10
- package/dist/custom/TwoFactorsPasskeysSettings.vue +10 -10
- package/dist/custom/utils.js +7 -8
- package/dist/endpoints/registerPasskeyEndpoints.d.ts +2 -0
- package/dist/endpoints/registerPasskeyEndpoints.js +59 -0
- package/dist/endpoints/registerTwoFaEndpoints.d.ts +2 -0
- package/dist/endpoints/registerTwoFaEndpoints.js +46 -0
- package/dist/handlers/createPasskeyHandlers.d.ts +48 -0
- package/dist/handlers/createPasskeyHandlers.js +99 -0
- package/dist/handlers/createTwoFaHandlers.d.ts +56 -0
- package/dist/handlers/createTwoFaHandlers.js +167 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +51 -858
- package/dist/repositories/passkeyRepository.d.ts +18 -0
- package/dist/repositories/passkeyRepository.js +89 -0
- package/dist/repositories/userRepository.d.ts +14 -0
- package/dist/repositories/userRepository.js +45 -0
- package/dist/services/cookieService.d.ts +52 -0
- package/dist/services/cookieService.js +144 -0
- package/dist/services/passkeyService.d.ts +84 -0
- package/dist/services/passkeyService.js +369 -0
- package/dist/services/totpService.d.ts +21 -0
- package/dist/services/totpService.js +43 -0
- package/dist/services/verifyMfaConfirmation.d.ts +30 -0
- package/dist/services/verifyMfaConfirmation.js +67 -0
- package/dist/types.d.ts +116 -0
- package/dist/utils/errors.d.ts +4 -0
- package/dist/utils/errors.js +9 -0
- package/dist/utils/parsePeriod.d.ts +1 -0
- package/dist/utils/parsePeriod.js +17 -0
- package/dist/utils/types.d.ts +13 -0
- package/dist/utils/types.js +1 -0
- package/endpoints/registerPasskeyEndpoints.ts +61 -0
- package/endpoints/registerTwoFaEndpoints.ts +45 -0
- package/handlers/createPasskeyHandlers.ts +103 -0
- package/handlers/createTwoFaHandlers.ts +172 -0
- package/index.ts +57 -886
- package/package.json +1 -1
- package/repositories/passkeyRepository.ts +78 -0
- package/repositories/userRepository.ts +42 -0
- package/services/cookieService.ts +165 -0
- package/services/passkeyService.ts +375 -0
- package/services/totpService.ts +37 -0
- package/services/verifyMfaConfirmation.ts +92 -0
- package/tsconfig.json +1 -1
- package/utils/errors.ts +13 -0
- package/utils/parsePeriod.ts +19 -0
- package/utils/types.ts +17 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { convertPeriodToSeconds } from "adminforth";
|
|
11
|
+
import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
|
|
12
|
+
import { isoUint8Array, isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
13
|
+
import aaguids from '../custom/aaguid.json';
|
|
14
|
+
import { PasskeyRepository } from "../repositories/passkeyRepository.js";
|
|
15
|
+
import { UserRepository } from "../repositories/userRepository.js";
|
|
16
|
+
import { CookieService } from "./cookieService.js";
|
|
17
|
+
import { errorMessage, errorResult, prefixedErrorResult } from "../utils/errors.js";
|
|
18
|
+
export class PasskeyService {
|
|
19
|
+
constructor(options, adminforth, userRepository = new UserRepository(adminforth, options), passkeyRepository = new PasskeyRepository(adminforth, options), cookieService = new CookieService(adminforth, options)) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.adminforth = adminforth;
|
|
22
|
+
this.userRepository = userRepository;
|
|
23
|
+
this.passkeyRepository = passkeyRepository;
|
|
24
|
+
this.cookieService = cookieService;
|
|
25
|
+
}
|
|
26
|
+
markChallengeAsUsed(challenge, expiresIn) {
|
|
27
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
28
|
+
const expiresInSeconds = expiresIn ? convertPeriodToSeconds(expiresIn) : undefined;
|
|
29
|
+
yield this.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
isChallengeUnused(challenge) {
|
|
33
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
34
|
+
const res = yield this.options.passkeys.keyValueAdapter.get(challenge);
|
|
35
|
+
return !res;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
validateLoginChallengeCookie(cookies) {
|
|
39
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
40
|
+
var _a;
|
|
41
|
+
const passkeysCookies = this.cookieService.getPasskeyLoginTemporary(cookies);
|
|
42
|
+
if (!passkeysCookies) {
|
|
43
|
+
return errorResult('Passkey token is required');
|
|
44
|
+
}
|
|
45
|
+
const decodedPasskeysCookies = yield this.cookieService.verifyPasskeyLoginTemporary(cookies);
|
|
46
|
+
if (!(decodedPasskeysCookies === null || decodedPasskeysCookies === void 0 ? void 0 : decodedPasskeysCookies.challenge)) {
|
|
47
|
+
return errorResult('Invalid passkey');
|
|
48
|
+
}
|
|
49
|
+
const isChallangeValid = yield this.isChallengeUnused(decodedPasskeysCookies.challenge);
|
|
50
|
+
if (isChallangeValid) {
|
|
51
|
+
yield this.markChallengeAsUsed(decodedPasskeysCookies.challenge, ((_a = this.options.passkeys) === null || _a === void 0 ? void 0 : _a.challengeValidityPeriod) || '2m');
|
|
52
|
+
}
|
|
53
|
+
if (!decodedPasskeysCookies || !isChallangeValid) {
|
|
54
|
+
return errorResult('Invalid passkey');
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, decodedPasskeysCookies };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
verifyPasskeyResponse(body, user_id, cookies) {
|
|
60
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
61
|
+
var _a, _b, _c, _d, _e, _f;
|
|
62
|
+
const settingsOrigin = (_a = this.options.passkeys) === null || _a === void 0 ? void 0 : _a.settings.expectedOrigin;
|
|
63
|
+
const expectedOrigin = body.origin;
|
|
64
|
+
const expectedChallenge = cookies.challenge;
|
|
65
|
+
const expectedRPID = ((_d = (_c = (_b = this.options.passkeys) === null || _b === void 0 ? void 0 : _b.settings) === null || _c === void 0 ? void 0 : _c.rp) === null || _d === void 0 ? void 0 : _d.id) || (new URL(settingsOrigin)).hostname;
|
|
66
|
+
try {
|
|
67
|
+
const response = JSON.parse(body.response);
|
|
68
|
+
if (settingsOrigin !== expectedOrigin) {
|
|
69
|
+
throw new Error(`Origin mismatch. Allowed in settings: ${settingsOrigin}, received from client: ${expectedOrigin}`);
|
|
70
|
+
}
|
|
71
|
+
const cred = yield this.passkeyRepository.getByCredentialId(response.id);
|
|
72
|
+
if (!cred) {
|
|
73
|
+
throw new Error('Credential not found.');
|
|
74
|
+
}
|
|
75
|
+
const credMeta = cred[this.options.passkeys.credentialMetaFieldName];
|
|
76
|
+
if (!credMeta || !credMeta.public_key) {
|
|
77
|
+
throw new Error('Credential public key not found.');
|
|
78
|
+
}
|
|
79
|
+
const usersPrimaryKeyFieldName = this.userRepository.getUserPkField();
|
|
80
|
+
const user = yield this.userRepository.getAuthUser(cred[this.options.passkeys.credentialUserIdFieldName]);
|
|
81
|
+
if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) {
|
|
82
|
+
throw new Error('User not found.');
|
|
83
|
+
}
|
|
84
|
+
const counter = (_e = credMeta.sign_count) !== null && _e !== void 0 ? _e : 0;
|
|
85
|
+
const { verified, authenticationInfo } = yield verifyAuthenticationResponse({
|
|
86
|
+
response,
|
|
87
|
+
expectedChallenge,
|
|
88
|
+
expectedOrigin: settingsOrigin,
|
|
89
|
+
expectedRPID,
|
|
90
|
+
credential: {
|
|
91
|
+
id: cred[this.options.passkeys.credentialIdFieldName],
|
|
92
|
+
publicKey: isoBase64URL.toBuffer(credMeta.public_key),
|
|
93
|
+
counter,
|
|
94
|
+
transports: credMeta.transports,
|
|
95
|
+
},
|
|
96
|
+
requireUserVerification: ((_f = this.options.passkeys) === null || _f === void 0 ? void 0 : _f.settings.authenticatorSelection.userVerification) === 'discouraged' ? false : true,
|
|
97
|
+
});
|
|
98
|
+
if (!verified) {
|
|
99
|
+
return errorResult('User verification failed.');
|
|
100
|
+
}
|
|
101
|
+
credMeta.sign_count = authenticationInfo.newCounter;
|
|
102
|
+
credMeta.last_used_at = new Date().toISOString();
|
|
103
|
+
yield this.passkeyRepository.updateMeta(cred, credMeta);
|
|
104
|
+
return { ok: true, passkeyConfirmed: true };
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
return prefixedErrorResult('Error authenticating passkey: ', e);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
createRegistrationOptions(mode, adminUser, response) {
|
|
112
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
113
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
|
|
114
|
+
const settingsOrigin = (_a = this.options.passkeys) === null || _a === void 0 ? void 0 : _a.settings.expectedOrigin;
|
|
115
|
+
const authenticatorAttachment = mode === "both" ? undefined : mode;
|
|
116
|
+
const rp = {
|
|
117
|
+
name: ((_c = (_b = this.options.passkeys) === null || _b === void 0 ? void 0 : _b.settings.rp) === null || _c === void 0 ? void 0 : _c.name) || this.adminforth.config.customization.brandName,
|
|
118
|
+
id: ((_f = (_e = (_d = this.options.passkeys) === null || _d === void 0 ? void 0 : _d.settings) === null || _e === void 0 ? void 0 : _e.rp) === null || _f === void 0 ? void 0 : _f.id) || (new URL(settingsOrigin)).hostname,
|
|
119
|
+
};
|
|
120
|
+
const userInfo = yield this.userRepository.getAuthUser(adminUser.pk);
|
|
121
|
+
if (!userInfo) {
|
|
122
|
+
return errorResult('User not found');
|
|
123
|
+
}
|
|
124
|
+
const user = {
|
|
125
|
+
pk: adminUser.pk,
|
|
126
|
+
name: userInfo[(_g = this.options.passkeys) === null || _g === void 0 ? void 0 : _g.settings.user.nameField],
|
|
127
|
+
displayName: userInfo[(_h = this.options.passkeys) === null || _h === void 0 ? void 0 : _h.settings.user.displayNameField] ? userInfo[(_j = this.options.passkeys) === null || _j === void 0 ? void 0 : _j.settings.user.displayNameField] : userInfo[(_k = this.options.passkeys) === null || _k === void 0 ? void 0 : _k.settings.user.nameField],
|
|
128
|
+
};
|
|
129
|
+
const excludeCredentials = [];
|
|
130
|
+
const temp = yield this.passkeyRepository.listByUserId(adminUser.pk);
|
|
131
|
+
for (const rec of temp) {
|
|
132
|
+
const credentialId = rec[this.options.passkeys.credentialIdFieldName];
|
|
133
|
+
if (credentialId) {
|
|
134
|
+
const meta = rec[this.options.passkeys.credentialMetaFieldName];
|
|
135
|
+
excludeCredentials.push({
|
|
136
|
+
id: credentialId,
|
|
137
|
+
type: "public-key",
|
|
138
|
+
transports: meta.transports || []
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const options = yield generateRegistrationOptions({
|
|
143
|
+
rpName: rp.name,
|
|
144
|
+
rpID: rp.id,
|
|
145
|
+
userID: isoUint8Array.fromUTF8String(user.pk),
|
|
146
|
+
userName: user.name,
|
|
147
|
+
userDisplayName: user.displayName,
|
|
148
|
+
excludeCredentials,
|
|
149
|
+
authenticatorSelection: {
|
|
150
|
+
authenticatorAttachment,
|
|
151
|
+
requireResidentKey: (_m = (_l = this.options.passkeys) === null || _l === void 0 ? void 0 : _l.settings.authenticatorSelection.requireResidentKey) !== null && _m !== void 0 ? _m : true,
|
|
152
|
+
userVerification: ((_o = this.options.passkeys) === null || _o === void 0 ? void 0 : _o.settings.authenticatorSelection.userVerification) || "required"
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
this.cookieService.setRegisterPasskeyTemporary(response, { challenge: options.challenge, user_id: adminUser.pk });
|
|
156
|
+
return { ok: true, data: options };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
finishRegistration(body, adminUser, cookies) {
|
|
160
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
161
|
+
var _a, _b, _c, _d, _e, _f;
|
|
162
|
+
const passkeysCookies = this.cookieService.getRegisterPasskeyTemporary(cookies);
|
|
163
|
+
if (!passkeysCookies) {
|
|
164
|
+
return { error: 'Passkey token is required' };
|
|
165
|
+
}
|
|
166
|
+
const decodedPasskeysCookies = yield this.cookieService.verifyRegisterPasskeyTemporary(cookies);
|
|
167
|
+
if (!decodedPasskeysCookies) {
|
|
168
|
+
return { error: 'Invalid passkey token' };
|
|
169
|
+
}
|
|
170
|
+
if (decodedPasskeysCookies.user_id !== adminUser.pk) {
|
|
171
|
+
return { error: 'Invalid user' };
|
|
172
|
+
}
|
|
173
|
+
const secret = this.userRepository.getSecret(adminUser.dbUser);
|
|
174
|
+
if (!secret || secret.length === 0) {
|
|
175
|
+
return { error: 'TOTP must be set up before registering a passkey' };
|
|
176
|
+
}
|
|
177
|
+
const settingsOrigin = (_a = this.options.passkeys) === null || _a === void 0 ? void 0 : _a.settings.expectedOrigin;
|
|
178
|
+
const expectedOrigin = body.origin;
|
|
179
|
+
const expectedChallenge = decodedPasskeysCookies.challenge;
|
|
180
|
+
const expectedRPID = ((_d = (_c = (_b = this.options.passkeys) === null || _b === void 0 ? void 0 : _b.settings) === null || _c === void 0 ? void 0 : _c.rp) === null || _d === void 0 ? void 0 : _d.id) || (new URL(settingsOrigin)).hostname;
|
|
181
|
+
try {
|
|
182
|
+
const response = JSON.parse(body.credential);
|
|
183
|
+
if (settingsOrigin !== expectedOrigin) {
|
|
184
|
+
throw new Error('Invalid origin');
|
|
185
|
+
}
|
|
186
|
+
const { verified, registrationInfo } = yield verifyRegistrationResponse({
|
|
187
|
+
response,
|
|
188
|
+
expectedChallenge,
|
|
189
|
+
expectedOrigin,
|
|
190
|
+
expectedRPID,
|
|
191
|
+
requireUserVerification: ((_e = this.options.passkeys) === null || _e === void 0 ? void 0 : _e.settings.authenticatorSelection.userVerification) === 'discouraged' ? false : true,
|
|
192
|
+
});
|
|
193
|
+
if (!verified) {
|
|
194
|
+
throw new Error('Verification failed.');
|
|
195
|
+
}
|
|
196
|
+
const { aaguid, credential, } = registrationInfo;
|
|
197
|
+
const provider_name = ((_f = aaguids[aaguid]) === null || _f === void 0 ? void 0 : _f.name) || 'Unknown provider';
|
|
198
|
+
const credentialPublicKey = credential.publicKey;
|
|
199
|
+
const credentialID = credential.id;
|
|
200
|
+
const base64CredentialID = credentialID;
|
|
201
|
+
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
|
|
202
|
+
yield this.passkeyRepository.create({
|
|
203
|
+
[this.options.passkeys.credentialIdFieldName]: base64CredentialID,
|
|
204
|
+
[this.options.passkeys.credentialUserIdFieldName]: adminUser.pk,
|
|
205
|
+
[this.options.passkeys.credentialMetaFieldName]: {
|
|
206
|
+
public_key: base64PublicKey,
|
|
207
|
+
public_key_algorithm: response.response.publicKeyAlgorithm,
|
|
208
|
+
sign_count: 0,
|
|
209
|
+
transports: response.response.transports,
|
|
210
|
+
created_at: new Date().toISOString(),
|
|
211
|
+
last_used_at: new Date().toISOString(),
|
|
212
|
+
aaguid: aaguid,
|
|
213
|
+
name: provider_name,
|
|
214
|
+
},
|
|
215
|
+
}, adminUser);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
console.error(error);
|
|
219
|
+
return { error: 'Error registering passkey: ' + errorMessage(error) };
|
|
220
|
+
}
|
|
221
|
+
return { ok: true };
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
createLoginOptions(response) {
|
|
225
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
226
|
+
var _a, _b, _c, _d;
|
|
227
|
+
try {
|
|
228
|
+
const options = yield generateAuthenticationOptions({
|
|
229
|
+
rpID: ((_b = (_a = this.options.passkeys) === null || _a === void 0 ? void 0 : _a.settings.rp) === null || _b === void 0 ? void 0 : _b.id) || (new URL((_c = this.options.passkeys) === null || _c === void 0 ? void 0 : _c.settings.expectedOrigin)).hostname,
|
|
230
|
+
userVerification: ((_d = this.options.passkeys) === null || _d === void 0 ? void 0 : _d.settings.authenticatorSelection.userVerification) || "required"
|
|
231
|
+
});
|
|
232
|
+
this.cookieService.setPasskeyLoginTemporary(response, options.challenge);
|
|
233
|
+
return { ok: true, data: options };
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
return { ok: false, error: errorMessage(e) };
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
getPasskeys(adminUser) {
|
|
241
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
242
|
+
var _a, _b, _c;
|
|
243
|
+
let passkeys;
|
|
244
|
+
try {
|
|
245
|
+
passkeys = yield this.passkeyRepository.listByUserId(adminUser.pk);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
return prefixedErrorResult('Error fetching passkeys: ', error);
|
|
249
|
+
}
|
|
250
|
+
let dataToReturn = [];
|
|
251
|
+
for (const pk of passkeys) {
|
|
252
|
+
const parsedKey = pk[this.options.passkeys.credentialMetaFieldName];
|
|
253
|
+
dataToReturn.push({
|
|
254
|
+
name: parsedKey.name,
|
|
255
|
+
light_icon: ((_a = aaguids[parsedKey.aaguid]) === null || _a === void 0 ? void 0 : _a.icon_light) || null,
|
|
256
|
+
dark_icon: ((_b = aaguids[parsedKey.aaguid]) === null || _b === void 0 ? void 0 : _b.icon_dark) || null,
|
|
257
|
+
created_at: parsedKey.created_at,
|
|
258
|
+
last_used_at: parsedKey.last_used_at,
|
|
259
|
+
id: pk[this.options.passkeys.credentialIdFieldName],
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, data: dataToReturn, authenticatorAttachment: ((_c = this.options.passkeys) === null || _c === void 0 ? void 0 : _c.settings.authenticatorSelection.authenticatorAttachment) || 'both' };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
deletePasskey(credentialID, adminUser) {
|
|
266
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
267
|
+
if (!credentialID) {
|
|
268
|
+
return errorResult('Passkey ID is required');
|
|
269
|
+
}
|
|
270
|
+
const passkeyRecord = yield this.passkeyRepository.getByCredentialIdAndUserId(credentialID, adminUser.pk);
|
|
271
|
+
if (!passkeyRecord) {
|
|
272
|
+
return errorResult('Passkey not found');
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
yield this.passkeyRepository.delete(passkeyRecord, adminUser);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
return prefixedErrorResult('Error deleting passkey: ', error);
|
|
279
|
+
}
|
|
280
|
+
return { ok: true };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
renamePasskey(credentialID, newName, adminUser) {
|
|
284
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
285
|
+
if (!credentialID) {
|
|
286
|
+
return errorResult('Passkey ID is required');
|
|
287
|
+
}
|
|
288
|
+
if (!newName) {
|
|
289
|
+
return errorResult('New name is required');
|
|
290
|
+
}
|
|
291
|
+
const passkeyRecord = yield this.passkeyRepository.getByCredentialIdAndUserId(credentialID, adminUser.pk);
|
|
292
|
+
if (!passkeyRecord) {
|
|
293
|
+
return errorResult('Passkey not found');
|
|
294
|
+
}
|
|
295
|
+
const metaField = this.options.passkeys.credentialMetaFieldName;
|
|
296
|
+
const oldMeta = passkeyRecord[metaField];
|
|
297
|
+
const newMeta = Object.assign(Object.assign({}, oldMeta), { name: newName });
|
|
298
|
+
const newRecord = Object.assign(Object.assign({}, passkeyRecord), { [metaField]: newMeta });
|
|
299
|
+
try {
|
|
300
|
+
yield this.passkeyRepository.update(passkeyRecord, newRecord, adminUser);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return prefixedErrorResult('Error renaming passkey: ', error);
|
|
304
|
+
}
|
|
305
|
+
return { ok: true };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
checkIfUserHasPasskeys(cookies) {
|
|
309
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
310
|
+
if (!this.options.passkeys) {
|
|
311
|
+
return { ok: false, hasPasskeys: false };
|
|
312
|
+
}
|
|
313
|
+
const decoded = yield this.cookieService.verifyTotpTemporary(cookies);
|
|
314
|
+
if (!decoded)
|
|
315
|
+
return errorResult('Invalid token');
|
|
316
|
+
if (decoded.newSecret) {
|
|
317
|
+
return { ok: true, hasPasskeys: false };
|
|
318
|
+
}
|
|
319
|
+
const hasPasskeys = yield this.passkeyRepository.hasByUserId(decoded.pk);
|
|
320
|
+
if (hasPasskeys) {
|
|
321
|
+
return { ok: true, hasPasskeys: true };
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
return { ok: true, hasPasskeys: false };
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
hasPasskeysForUser(userId) {
|
|
329
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
330
|
+
return this.passkeyRepository.hasByUserId(userId);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
getLoginUserByPasskeyResponse(passkeyResponse, cookies) {
|
|
334
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
335
|
+
const cookiesValidationResult = yield this.validateLoginChallengeCookie(cookies);
|
|
336
|
+
if (!cookiesValidationResult.ok) {
|
|
337
|
+
return { error: cookiesValidationResult.error };
|
|
338
|
+
}
|
|
339
|
+
let parsedPasskeyResponse;
|
|
340
|
+
try {
|
|
341
|
+
parsedPasskeyResponse = JSON.parse(passkeyResponse.response);
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
return { error: 'Malformed passkey response' };
|
|
345
|
+
}
|
|
346
|
+
const credential_id = parsedPasskeyResponse.id;
|
|
347
|
+
if (!credential_id) {
|
|
348
|
+
return { error: 'Credential ID is required' };
|
|
349
|
+
}
|
|
350
|
+
const passkeyRecord = yield this.passkeyRepository.getByCredentialId(credential_id);
|
|
351
|
+
if (!passkeyRecord) {
|
|
352
|
+
return { error: 'No such passkey found, most likely it was removed on this website but you still have it on your device' };
|
|
353
|
+
}
|
|
354
|
+
const userPk = passkeyRecord[this.options.passkeys.credentialUserIdFieldName];
|
|
355
|
+
if (!userPk) {
|
|
356
|
+
return { error: 'User ID not found in passkey record' };
|
|
357
|
+
}
|
|
358
|
+
const user = yield this.userRepository.getAuthUser(userPk);
|
|
359
|
+
if (!user) {
|
|
360
|
+
return { error: 'User not found' };
|
|
361
|
+
}
|
|
362
|
+
const verificationResult = yield this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies);
|
|
363
|
+
if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
|
|
364
|
+
return { error: 'Passkey verification failed' };
|
|
365
|
+
}
|
|
366
|
+
return { ok: true, user };
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AdminUser } from "adminforth";
|
|
2
|
+
import type { PluginOptions } from "../types.js";
|
|
3
|
+
import { UserRepository } from "../repositories/userRepository.js";
|
|
4
|
+
export declare class TotpService {
|
|
5
|
+
private readonly options;
|
|
6
|
+
private readonly userRepository;
|
|
7
|
+
constructor(options: PluginOptions, userRepository: UserRepository);
|
|
8
|
+
generateSecret(issuerName: string, userName: string): string;
|
|
9
|
+
verifySecret(secret: string, code: string): boolean;
|
|
10
|
+
verifyUserCode(userPk: string, code: string): Promise<{
|
|
11
|
+
ok: true;
|
|
12
|
+
} | {
|
|
13
|
+
error: string;
|
|
14
|
+
}>;
|
|
15
|
+
verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{
|
|
16
|
+
ok: true;
|
|
17
|
+
} | {
|
|
18
|
+
error: string;
|
|
19
|
+
}>;
|
|
20
|
+
saveSecret(userPk: string, secret: string): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import twofactor from 'node-2fa';
|
|
11
|
+
export class TotpService {
|
|
12
|
+
constructor(options, userRepository) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.userRepository = userRepository;
|
|
15
|
+
}
|
|
16
|
+
generateSecret(issuerName, userName) {
|
|
17
|
+
return twofactor.generateSecret({ name: issuerName, account: userName }).secret;
|
|
18
|
+
}
|
|
19
|
+
verifySecret(secret, code) {
|
|
20
|
+
return Boolean(twofactor.verifyToken(secret, code, this.options.timeStepWindow));
|
|
21
|
+
}
|
|
22
|
+
verifyUserCode(userPk, code) {
|
|
23
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
24
|
+
const user = yield this.userRepository.getAuthUser(userPk);
|
|
25
|
+
if (!user)
|
|
26
|
+
return { error: "User not found" };
|
|
27
|
+
const secret = this.userRepository.getSecret(user);
|
|
28
|
+
if (!secret)
|
|
29
|
+
return { error: "2FA is not set up for this user" };
|
|
30
|
+
return this.verifySecret(secret, code) ? { ok: true } : { error: "Wrong or expired OTP code" };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
verifyAdminUserCode(adminUser, code) {
|
|
34
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
35
|
+
return this.verifyUserCode(this.userRepository.getUserPk(adminUser), code);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
saveSecret(userPk, secret) {
|
|
39
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
40
|
+
yield this.userRepository.updateSecret(userPk, secret);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AdminUser, HttpExtra, IAdminForthHttpResponse } from "adminforth";
|
|
2
|
+
import type { CookieList } from "../utils/types.js";
|
|
3
|
+
type TotpConfirmationResult = {
|
|
4
|
+
mode: "totp";
|
|
5
|
+
result: string;
|
|
6
|
+
};
|
|
7
|
+
type PasskeyConfirmationResult = {
|
|
8
|
+
mode: "passkey";
|
|
9
|
+
result: {
|
|
10
|
+
response: string;
|
|
11
|
+
origin: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
type GraceConfirmationResult = {
|
|
15
|
+
mode?: undefined;
|
|
16
|
+
};
|
|
17
|
+
export type MfaConfirmationResult = TotpConfirmationResult | PasskeyConfirmationResult | GraceConfirmationResult;
|
|
18
|
+
export type VerifyOptions = {
|
|
19
|
+
adminUser: AdminUser;
|
|
20
|
+
userPk: string;
|
|
21
|
+
cookies?: CookieList;
|
|
22
|
+
response?: IAdminForthHttpResponse;
|
|
23
|
+
extra?: HttpExtra;
|
|
24
|
+
};
|
|
25
|
+
export declare function verifyMfaConfirmation(ctx: any, confirmationResult: MfaConfirmationResult, opts: VerifyOptions): Promise<{
|
|
26
|
+
ok: true;
|
|
27
|
+
} | {
|
|
28
|
+
error: string;
|
|
29
|
+
}>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
export function verifyMfaConfirmation(ctx, confirmationResult, opts) {
|
|
11
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
12
|
+
var _a, _b, _c, _d, _e;
|
|
13
|
+
if (!confirmationResult)
|
|
14
|
+
return { error: "Confirmation result is required" };
|
|
15
|
+
const cookies = ((_a = opts.extra) === null || _a === void 0 ? void 0 : _a.cookies) || opts.cookies;
|
|
16
|
+
const response = ((_b = opts.extra) === null || _b === void 0 ? void 0 : _b.response) || opts.response;
|
|
17
|
+
if (ctx.options.usersFilterToApply) {
|
|
18
|
+
const res = ctx.options.usersFilterToApply(opts.adminUser);
|
|
19
|
+
if (res === false) {
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (ctx.options.usersFilterToAllowSkipSetup) {
|
|
24
|
+
const res = yield ctx.checkIfSkipSetupAllowSkipVerify(opts.adminUser);
|
|
25
|
+
if (res.skipAllowed === true) {
|
|
26
|
+
return { ok: true };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (ctx.options.stepUpMfaGracePeriodSeconds && ((_c = opts.extra) === null || _c === void 0 ? void 0 : _c.headers) && !confirmationResult.mode) {
|
|
30
|
+
const verificationResult = yield ctx.cookieService.isMfaGraceValid(opts.extra.headers, cookies);
|
|
31
|
+
if (verificationResult === true) {
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const mode = confirmationResult.mode;
|
|
36
|
+
if (!mode) {
|
|
37
|
+
return { error: "Unsupported confirmation mode" };
|
|
38
|
+
}
|
|
39
|
+
if (mode === "totp") {
|
|
40
|
+
const { result: code } = confirmationResult;
|
|
41
|
+
const verificationResult = yield ctx.totpService.verifyUserCode(opts.userPk, code);
|
|
42
|
+
if ('ok' in verificationResult && verificationResult.ok) {
|
|
43
|
+
if (ctx.options.stepUpMfaGracePeriodSeconds) {
|
|
44
|
+
ctx.cookieService.issueMfaGrace({ headers: (_d = opts.extra) === null || _d === void 0 ? void 0 : _d.headers, response }, cookies);
|
|
45
|
+
}
|
|
46
|
+
return { ok: true };
|
|
47
|
+
}
|
|
48
|
+
return verificationResult;
|
|
49
|
+
}
|
|
50
|
+
else if (mode === "passkey") {
|
|
51
|
+
const { result } = confirmationResult;
|
|
52
|
+
const cookiesValidationResult = yield ctx.passkeyService.validateLoginChallengeCookie(cookies);
|
|
53
|
+
if (!cookiesValidationResult.ok) {
|
|
54
|
+
return { error: cookiesValidationResult.error };
|
|
55
|
+
}
|
|
56
|
+
const verificationResult = yield ctx.passkeyService.verifyPasskeyResponse(result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies);
|
|
57
|
+
if (verificationResult.ok && verificationResult.passkeyConfirmed) {
|
|
58
|
+
if (ctx.options.stepUpMfaGracePeriodSeconds) {
|
|
59
|
+
ctx.cookieService.issueMfaGrace({ headers: (_e = opts.extra) === null || _e === void 0 ? void 0 : _e.headers, response }, cookies);
|
|
60
|
+
}
|
|
61
|
+
return { ok: true };
|
|
62
|
+
}
|
|
63
|
+
return { error: "Invalid passkey" };
|
|
64
|
+
}
|
|
65
|
+
return { error: "Unsupported confirmation mode" };
|
|
66
|
+
});
|
|
67
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { AdminUser, KeyValueAdapter } from "adminforth";
|
|
2
|
+
import { type PluginsCommonOptions } from "adminforth";
|
|
3
|
+
export interface PluginOptions extends PluginsCommonOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Name of the field in the auth resource which will store 2FA secret.
|
|
6
|
+
*
|
|
7
|
+
* Resource mandatory should have one columns which defined {@link AdminForthResourceColumn} which
|
|
8
|
+
* name should be equal to the value .
|
|
9
|
+
*/
|
|
10
|
+
twoFaSecretFieldName: string;
|
|
11
|
+
/**
|
|
12
|
+
* Not-negative optional time step window for 2FA. This value means that the user will be able to enter the old code
|
|
13
|
+
* for the next 30 seconds after the new code was generated.
|
|
14
|
+
*/
|
|
15
|
+
timeStepWindow?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Number of seconds for step-up MFA grace period.
|
|
18
|
+
* During this period, user will not be challenged with 2FA on sensitive actions.
|
|
19
|
+
*/
|
|
20
|
+
stepUpMfaGracePeriodSeconds?: number;
|
|
21
|
+
customBrandPrefix?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Passkeys (WebAuthn) configuration.
|
|
24
|
+
*/
|
|
25
|
+
passkeys?: {
|
|
26
|
+
credentialResourceID: string;
|
|
27
|
+
credentialIdFieldName: string;
|
|
28
|
+
credentialMetaFieldName: string;
|
|
29
|
+
credentialUserIdFieldName: string;
|
|
30
|
+
/**
|
|
31
|
+
* KeyValueAdapter is required to make sure that generated challenge can't be reused more than once
|
|
32
|
+
*/
|
|
33
|
+
keyValueAdapter: KeyValueAdapter;
|
|
34
|
+
/**
|
|
35
|
+
* Allow login with Passkeys even if 2FA is not set up. Default is true.
|
|
36
|
+
*/
|
|
37
|
+
allowLoginWithPasskeys?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Remember user for number of days after login with Passkey, so that user won't be challenged with 2FA on every login.
|
|
40
|
+
*/
|
|
41
|
+
rememberDaysAfterPasskeyLogin?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Order of "Continue with passkey" button in under login button injection
|
|
44
|
+
*/
|
|
45
|
+
continueWithButtonsOrder?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Period between showing alert suggesting to set up Passkeys if not set up yet.
|
|
48
|
+
*/
|
|
49
|
+
suggestionPeriod?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Challenge period for Passkeys authentication.
|
|
52
|
+
*/
|
|
53
|
+
challengeValidityPeriod?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Passkeys settings for WebAuthn API.
|
|
56
|
+
*/
|
|
57
|
+
settings: {
|
|
58
|
+
/**
|
|
59
|
+
* The origin that you expect the authentication to come from. (e.g. https://example.com or http://localhost:3000)
|
|
60
|
+
*/
|
|
61
|
+
expectedOrigin: string;
|
|
62
|
+
rp?: {
|
|
63
|
+
/**
|
|
64
|
+
* The Relying Party name.
|
|
65
|
+
*/
|
|
66
|
+
name?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The Relying Party ID. A domain or subdomain (e.g. example.com or login.example.com).
|
|
69
|
+
*/
|
|
70
|
+
id?: string;
|
|
71
|
+
};
|
|
72
|
+
user: {
|
|
73
|
+
/**
|
|
74
|
+
* Field in users resource, that user will recognize as unique user ID.(e.g. email or username)
|
|
75
|
+
*/
|
|
76
|
+
nameField: string;
|
|
77
|
+
/**
|
|
78
|
+
* Field in users resource, that user will recognize as display name.(e.g. full name)
|
|
79
|
+
*/
|
|
80
|
+
displayNameField?: string;
|
|
81
|
+
};
|
|
82
|
+
authenticatorSelection?: {
|
|
83
|
+
/**
|
|
84
|
+
* The preferred authenticator attachment. It can be either "platform", "cross-platform" or "both".
|
|
85
|
+
* Default to "platform".
|
|
86
|
+
*/
|
|
87
|
+
authenticatorAttachment?: 'platform' | 'cross-platform' | 'both';
|
|
88
|
+
/**
|
|
89
|
+
* Set it to a boolean true. A discoverable credential (resident key)
|
|
90
|
+
* stores user information to the passkey and lets users select the account upon authentication.
|
|
91
|
+
* Default to "True".
|
|
92
|
+
*/
|
|
93
|
+
requireResidentKey?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Indicates whether a user verification using the device screen lock is "required" or "discouraged".
|
|
96
|
+
* The default is "required".
|
|
97
|
+
*/
|
|
98
|
+
userVerification?: 'required' | 'discouraged';
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Optional function to filter users to apply 2FA.
|
|
104
|
+
* Should return true if 2FA should be applied to the user and false if AdminForth should not challenge the user with 2FA.
|
|
105
|
+
* @param adminUser
|
|
106
|
+
* @returns true if 2FA should be applied to the user and false if AdminForth should not challenge the user with 2FA.
|
|
107
|
+
*/
|
|
108
|
+
usersFilterToApply?: (adminUser: AdminUser) => boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Optional function to allow users to skip 2FA setup.
|
|
111
|
+
* Should return true if the user should be allowed to skip the 2FA setup and false if AdminForth should require the user to set up 2FA.
|
|
112
|
+
* @param adminUser
|
|
113
|
+
* @returns true if the user should be allowed to skip the 2FA setup and false if AdminForth should require the user to set up 2FA.
|
|
114
|
+
*/
|
|
115
|
+
usersFilterToAllowSkipSetup?: (adminUser: AdminUser) => boolean;
|
|
116
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ErrorResponse } from "./types.js";
|
|
2
|
+
export declare function errorMessage(error: unknown): string;
|
|
3
|
+
export declare function errorResult(message: string): ErrorResponse;
|
|
4
|
+
export declare function prefixedErrorResult(prefix: string, error: unknown): ErrorResponse;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function errorMessage(error) {
|
|
2
|
+
return error instanceof Error ? error.message : String(error);
|
|
3
|
+
}
|
|
4
|
+
export function errorResult(message) {
|
|
5
|
+
return { ok: false, error: message };
|
|
6
|
+
}
|
|
7
|
+
export function prefixedErrorResult(prefix, error) {
|
|
8
|
+
return errorResult(`${prefix}${errorMessage(error)}`);
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function parsePeriod(period?: string): number;
|