@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
package/package.json
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Filters } from "adminforth";
|
|
2
|
+
import type { AdminForthResource, AdminUser, IAdminForth } from "adminforth";
|
|
3
|
+
import type { PluginOptions } from "../types.js";
|
|
4
|
+
import type { AnyRecord } from "../utils/types.js";
|
|
5
|
+
|
|
6
|
+
export class PasskeyRepository {
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly adminforth: IAdminForth,
|
|
9
|
+
private readonly options: PluginOptions,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
public getCredentialResource(): AdminForthResource {
|
|
13
|
+
return this.adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public getCredentialResourcePkField(): string {
|
|
17
|
+
return this.getCredentialResource().columns.find(c => c.primaryKey).name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public async getByCredentialId(credentialId: string): Promise<AnyRecord> {
|
|
21
|
+
return this.adminforth.resource(this.options.passkeys.credentialResourceID).get([
|
|
22
|
+
Filters.EQ(this.options.passkeys.credentialIdFieldName, credentialId),
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public async getByCredentialIdAndUserId(credentialId: string, userId: string): Promise<AnyRecord> {
|
|
27
|
+
return this.adminforth.resource(this.options.passkeys.credentialResourceID).get([
|
|
28
|
+
Filters.EQ(this.options.passkeys.credentialIdFieldName, credentialId),
|
|
29
|
+
Filters.EQ(this.options.passkeys.credentialUserIdFieldName, userId),
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public async listByUserId(userId: string): Promise<AnyRecord[]> {
|
|
34
|
+
return this.adminforth.resource(this.options.passkeys.credentialResourceID).list([
|
|
35
|
+
Filters.EQ(this.options.passkeys.credentialUserIdFieldName, userId),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public async hasByUserId(userId: string): Promise<boolean> {
|
|
40
|
+
const passkeys = await this.listByUserId(userId);
|
|
41
|
+
return passkeys && passkeys.length > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async create(record: AnyRecord, adminUser: AdminUser): Promise<void> {
|
|
45
|
+
await this.adminforth.createResourceRecord({
|
|
46
|
+
resource: this.getCredentialResource(),
|
|
47
|
+
record,
|
|
48
|
+
adminUser,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public async updateMeta(passkeyRecord: AnyRecord, meta: AnyRecord): Promise<void> {
|
|
53
|
+
await this.adminforth
|
|
54
|
+
.resource(this.options.passkeys.credentialResourceID)
|
|
55
|
+
.update(passkeyRecord[this.getCredentialResourcePkField()], {
|
|
56
|
+
[this.options.passkeys.credentialMetaFieldName]: meta,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async update(passkeyRecord: AnyRecord, newRecord: AnyRecord, adminUser: AdminUser): Promise<void> {
|
|
61
|
+
await this.adminforth.updateResourceRecord({
|
|
62
|
+
resource: this.getCredentialResource(),
|
|
63
|
+
recordId: passkeyRecord[this.getCredentialResourcePkField()],
|
|
64
|
+
oldRecord: passkeyRecord,
|
|
65
|
+
record: newRecord,
|
|
66
|
+
adminUser,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public async delete(passkeyRecord: AnyRecord, adminUser: AdminUser): Promise<void> {
|
|
71
|
+
await this.adminforth.deleteResourceRecord({
|
|
72
|
+
resource: this.getCredentialResource(),
|
|
73
|
+
recordId: passkeyRecord[this.getCredentialResourcePkField()],
|
|
74
|
+
record: passkeyRecord,
|
|
75
|
+
adminUser,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AdminForthResource, AdminUser, IAdminForth } from "adminforth";
|
|
2
|
+
import type { PluginOptions } from "../types.js";
|
|
3
|
+
import type { AnyRecord } from "../utils/types.js";
|
|
4
|
+
|
|
5
|
+
export class UserRepository {
|
|
6
|
+
constructor(
|
|
7
|
+
private readonly adminforth: IAdminForth,
|
|
8
|
+
private readonly options: PluginOptions,
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
public getAuthResource(): AdminForthResource {
|
|
12
|
+
return this.adminforth.config.resources.find(r => r.resourceId === this.adminforth.config.auth.usersResourceId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public getUserPkField(): string {
|
|
16
|
+
return this.getAuthResource().columns.find(c => c.primaryKey).name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public getUserPk(adminUser: AdminUser): string {
|
|
20
|
+
return adminUser.dbUser[this.getUserPkField()];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public async getAuthUser(pk: string): Promise<AnyRecord> {
|
|
24
|
+
const authResource = this.getAuthResource();
|
|
25
|
+
const connector = this.adminforth.connectors[authResource.dataSource];
|
|
26
|
+
return connector.getRecordByPrimaryKey(authResource, pk);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public getSecret(user: AnyRecord): string {
|
|
30
|
+
return user[this.options.twoFaSecretFieldName];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public async updateSecret(pk: string, secret: string): Promise<void> {
|
|
34
|
+
const authResource = this.getAuthResource();
|
|
35
|
+
const connector = this.adminforth.connectors[authResource.dataSource];
|
|
36
|
+
await connector.updateRecord({
|
|
37
|
+
resource: authResource,
|
|
38
|
+
recordId: pk,
|
|
39
|
+
newValues: { [this.options.twoFaSecretFieldName]: secret },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { IAdminForth, IAdminForthHttpResponse } from "adminforth";
|
|
2
|
+
import type { PluginOptions } from "../types.js";
|
|
3
|
+
import type { CookieList, HttpHeaders } from "../utils/types.js";
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
const TOTP_TEMP_COOKIE = "2FaTemporaryJWT";
|
|
7
|
+
const PASSKEY_LOGIN_TEMP_COOKIE = "passkeyLoginTemporaryJWT";
|
|
8
|
+
const REGISTER_PASSKEY_TEMP_COOKIE = "registerPasskeyTemporaryJWT";
|
|
9
|
+
const MFA_GRACE_COOKIE = "TempSkip2FA_Modal_JWT";
|
|
10
|
+
const AUTH_COOKIE = "jwt";
|
|
11
|
+
|
|
12
|
+
export type TotpTemporaryPayload = {
|
|
13
|
+
userName: string;
|
|
14
|
+
issuer?: string;
|
|
15
|
+
pk: string;
|
|
16
|
+
newSecret?: string;
|
|
17
|
+
userCanSkipSetup?: boolean;
|
|
18
|
+
sessionDuration?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PasskeyLoginTemporaryPayload = {
|
|
22
|
+
challenge: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type RegisterPasskeyTemporaryPayload = {
|
|
26
|
+
challenge: string;
|
|
27
|
+
user_id: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type MfaGracePayload = {
|
|
31
|
+
hash: string;
|
|
32
|
+
exp?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class CookieService {
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly adminforth: IAdminForth,
|
|
38
|
+
private readonly options: PluginOptions,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
public getAuthSessionCookie(cookies: CookieList): string | null {
|
|
42
|
+
return this.adminforth.auth.getCustomCookie({ cookies, name: AUTH_COOKIE });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public setAuthCookie(opts: { response: IAdminForthHttpResponse; username: string; pk: string; expireInDuration: string }): void {
|
|
46
|
+
this.adminforth.auth.setAuthCookie(opts);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public getTotpTemporary(cookies: CookieList): string | null {
|
|
50
|
+
return this.adminforth.auth.getCustomCookie({ cookies, name: TOTP_TEMP_COOKIE });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public async verifyTotpTemporary(cookies: CookieList): Promise<TotpTemporaryPayload | null> {
|
|
54
|
+
const jwt = this.getTotpTemporary(cookies);
|
|
55
|
+
if (!jwt) return null;
|
|
56
|
+
return this.adminforth.auth.verify(jwt, 'temp2FA') as Promise<TotpTemporaryPayload | null>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public setTotpTemporary(response: IAdminForthHttpResponse, payload: TotpTemporaryPayload): void {
|
|
60
|
+
const value = this.adminforth.auth.issueJWT(payload, 'temp2FA', this.options.passkeys?.challengeValidityPeriod || '1m');
|
|
61
|
+
this.adminforth.auth.setCustomCookie({
|
|
62
|
+
response,
|
|
63
|
+
payload: { name: TOTP_TEMP_COOKIE, value, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public removeTotpTemporary(response: IAdminForthHttpResponse): void {
|
|
68
|
+
this.adminforth.auth.removeCustomCookie({ response, name: TOTP_TEMP_COOKIE });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public getPasskeyLoginTemporary(cookies: CookieList): string | null {
|
|
72
|
+
return this.adminforth.auth.getCustomCookie({ cookies, name: PASSKEY_LOGIN_TEMP_COOKIE });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public async verifyPasskeyLoginTemporary(cookies: CookieList): Promise<PasskeyLoginTemporaryPayload | null> {
|
|
76
|
+
const jwt = this.getPasskeyLoginTemporary(cookies);
|
|
77
|
+
if (!jwt) return null;
|
|
78
|
+
return this.adminforth.auth.verify(jwt, 'tempLoginPasskeyChallenge', false) as Promise<PasskeyLoginTemporaryPayload | null>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public setPasskeyLoginTemporary(response: IAdminForthHttpResponse, challenge: string): void {
|
|
82
|
+
const value = this.adminforth.auth.issueJWT({ challenge }, 'tempLoginPasskeyChallenge', this.options.passkeys?.challengeValidityPeriod || '1m');
|
|
83
|
+
this.adminforth.auth.setCustomCookie({
|
|
84
|
+
response,
|
|
85
|
+
payload: { name: PASSKEY_LOGIN_TEMP_COOKIE, value, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public getRegisterPasskeyTemporary(cookies: CookieList): string | null {
|
|
90
|
+
return this.adminforth.auth.getCustomCookie({ cookies, name: REGISTER_PASSKEY_TEMP_COOKIE });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public async verifyRegisterPasskeyTemporary(cookies: CookieList): Promise<RegisterPasskeyTemporaryPayload | null> {
|
|
94
|
+
const jwt = this.getRegisterPasskeyTemporary(cookies);
|
|
95
|
+
if (!jwt) return null;
|
|
96
|
+
return this.adminforth.auth.verify(jwt, 'registerTempPasskeyChallenge', false) as Promise<RegisterPasskeyTemporaryPayload | null>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public setRegisterPasskeyTemporary(response: IAdminForthHttpResponse, payload: RegisterPasskeyTemporaryPayload): void {
|
|
100
|
+
const value = this.adminforth.auth.issueJWT(payload, 'registerTempPasskeyChallenge', this.options.passkeys?.challengeValidityPeriod || '1m');
|
|
101
|
+
this.adminforth.auth.setCustomCookie({
|
|
102
|
+
response,
|
|
103
|
+
payload: { name: REGISTER_PASSKEY_TEMP_COOKIE, value, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public setMfaGrace(response: IAdminForthHttpResponse, hash: string): void {
|
|
108
|
+
const value = this.adminforth.auth.issueJWT({ hash }, 'MfaGrace', `${this.options.stepUpMfaGracePeriodSeconds}s`);
|
|
109
|
+
//TODO: fix ts-ignore after releasing new version of adminforth with updated types
|
|
110
|
+
//@ts-ignore
|
|
111
|
+
this.adminforth.auth.setCustomCookie({ response, payload: { name: MFA_GRACE_COOKIE, sessionBased: true, value, httpOnly: true } });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public async verifyMfaGrace(cookies: CookieList): Promise<MfaGracePayload | null> {
|
|
115
|
+
const jwt = this.adminforth.auth.getCustomCookie({ cookies, name: MFA_GRACE_COOKIE });
|
|
116
|
+
if (!jwt) return null;
|
|
117
|
+
return this.adminforth.auth.verify(jwt, 'MfaGrace', false) as Promise<MfaGracePayload | null>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private generateMfaGraceHash(headers: HttpHeaders, cookies: CookieList): string | null {
|
|
121
|
+
const ip = this.adminforth.auth.getClientIp(headers);
|
|
122
|
+
const userAgent = headers['user-agent'] || '';
|
|
123
|
+
const acceptLanguage = headers['accept-language'] || '';
|
|
124
|
+
const sessionCookie = this.getAuthSessionCookie(cookies);
|
|
125
|
+
if (!process.env.ADMINFORTH_SECRET || !ip || !userAgent || !acceptLanguage || !sessionCookie) {
|
|
126
|
+
console.error("❗️❗️❗️ Cannot set step-up MFA grace cookie: missing required request headers to identify client ❗️❗️❗️");
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return crypto.createHmac('sha256', process.env.ADMINFORTH_SECRET)
|
|
131
|
+
.update(`${acceptLanguage}_${userAgent}_${ip}_${sessionCookie}`)
|
|
132
|
+
.digest('hex');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public issueMfaGrace(opts: { headers?: HttpHeaders; response?: IAdminForthHttpResponse }, cookies: CookieList): void {
|
|
136
|
+
if (!opts.response) {
|
|
137
|
+
console.error("❗️❗️❗️ Cannot set step-up MFA grace cookie: response object is missing. You probably called verify() method without response parameter ❗️❗️❗️");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!opts.headers) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const hash = this.generateMfaGraceHash(opts.headers, cookies);
|
|
145
|
+
if (hash) {
|
|
146
|
+
this.setMfaGrace(opts.response, hash);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public async isMfaGraceValid(headers: HttpHeaders, cookies: CookieList, checkIfJWTAboutToExpire: boolean = false): Promise<boolean> {
|
|
151
|
+
const hash = this.generateMfaGraceHash(headers, cookies);
|
|
152
|
+
if (!hash) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const jwtVerificationResult = await this.verifyMfaGrace(cookies);
|
|
156
|
+
if (!jwtVerificationResult) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (checkIfJWTAboutToExpire && jwtVerificationResult.exp && (jwtVerificationResult.exp - ( Date.now() / 1000)) < 30 ) {
|
|
160
|
+
console.error("❗️❗️❗️ Cannot validate step-up MFA grace cookie: token is expired or about to expire ❗️❗️❗️");
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return hash === jwtVerificationResult['hash'];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { convertPeriodToSeconds } from "adminforth";
|
|
2
|
+
import type { AdminUser, IAdminForth, IAdminForthHttpResponse } from "adminforth";
|
|
3
|
+
import {
|
|
4
|
+
generateRegistrationOptions,
|
|
5
|
+
verifyRegistrationResponse,
|
|
6
|
+
generateAuthenticationOptions,
|
|
7
|
+
verifyAuthenticationResponse
|
|
8
|
+
} from '@simplewebauthn/server';
|
|
9
|
+
import { isoUint8Array, isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
10
|
+
import aaguids from '../custom/aaguid.json';
|
|
11
|
+
import type { PluginOptions } from "../types.js";
|
|
12
|
+
import { PasskeyRepository } from "../repositories/passkeyRepository.js";
|
|
13
|
+
import { UserRepository } from "../repositories/userRepository.js";
|
|
14
|
+
import { CookieService } from "./cookieService.js";
|
|
15
|
+
import { errorMessage, errorResult, prefixedErrorResult } from "../utils/errors.js";
|
|
16
|
+
import type { CookieList } from "../utils/types.js";
|
|
17
|
+
|
|
18
|
+
type PasskeyAuthenticationPayload = {
|
|
19
|
+
response: string;
|
|
20
|
+
origin: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type PasskeyRegistrationPayload = {
|
|
24
|
+
credential: string;
|
|
25
|
+
origin: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type PluginAuthenticatorAttachment = NonNullable<NonNullable<
|
|
29
|
+
NonNullable<PluginOptions["passkeys"]>["settings"]["authenticatorSelection"]
|
|
30
|
+
>["authenticatorAttachment"]>;
|
|
31
|
+
type WebAuthnAuthenticatorAttachment = Exclude<PluginAuthenticatorAttachment, "both">;
|
|
32
|
+
|
|
33
|
+
export class PasskeyService {
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly options: PluginOptions,
|
|
36
|
+
private readonly adminforth: IAdminForth,
|
|
37
|
+
private readonly userRepository = new UserRepository(adminforth, options),
|
|
38
|
+
private readonly passkeyRepository = new PasskeyRepository(adminforth, options),
|
|
39
|
+
private readonly cookieService = new CookieService(adminforth, options),
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
private async markChallengeAsUsed(challenge: string, expiresIn?: string): Promise<void> {
|
|
43
|
+
const expiresInSeconds = expiresIn ? convertPeriodToSeconds(expiresIn) : undefined;
|
|
44
|
+
await this.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async isChallengeUnused(challenge: string): Promise<boolean> {
|
|
48
|
+
const res = await this.options.passkeys.keyValueAdapter.get(challenge);
|
|
49
|
+
return !res;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public async validateLoginChallengeCookie(cookies: CookieList): Promise<{ok: boolean, decodedPasskeysCookies?: { challenge: string }, error?: string}> {
|
|
53
|
+
const passkeysCookies = this.cookieService.getPasskeyLoginTemporary(cookies);
|
|
54
|
+
if (!passkeysCookies) {
|
|
55
|
+
return errorResult('Passkey token is required');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const decodedPasskeysCookies = await this.cookieService.verifyPasskeyLoginTemporary(cookies);
|
|
59
|
+
if (!decodedPasskeysCookies?.challenge) {
|
|
60
|
+
return errorResult('Invalid passkey');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isChallangeValid = await this.isChallengeUnused(decodedPasskeysCookies.challenge);
|
|
64
|
+
if (isChallangeValid) {
|
|
65
|
+
await this.markChallengeAsUsed(decodedPasskeysCookies.challenge, this.options.passkeys?.challengeValidityPeriod || '2m');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!decodedPasskeysCookies || !isChallangeValid) {
|
|
69
|
+
return errorResult('Invalid passkey');
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, decodedPasskeysCookies };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public async verifyPasskeyResponse(body: PasskeyAuthenticationPayload, user_id: string, cookies: { challenge: string }) {
|
|
75
|
+
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
76
|
+
const expectedOrigin = body.origin;
|
|
77
|
+
const expectedChallenge = cookies.challenge;
|
|
78
|
+
const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname;
|
|
79
|
+
try {
|
|
80
|
+
const response = JSON.parse(body.response);
|
|
81
|
+
if (settingsOrigin !== expectedOrigin) {
|
|
82
|
+
throw new Error(`Origin mismatch. Allowed in settings: ${settingsOrigin}, received from client: ${expectedOrigin}`);
|
|
83
|
+
}
|
|
84
|
+
const cred = await this.passkeyRepository.getByCredentialId(response.id);
|
|
85
|
+
if (!cred) {
|
|
86
|
+
throw new Error('Credential not found.');
|
|
87
|
+
}
|
|
88
|
+
const credMeta = cred[this.options.passkeys.credentialMetaFieldName];
|
|
89
|
+
if (!credMeta || !credMeta.public_key) {
|
|
90
|
+
throw new Error('Credential public key not found.');
|
|
91
|
+
}
|
|
92
|
+
const usersPrimaryKeyFieldName = this.userRepository.getUserPkField();
|
|
93
|
+
const user = await this.userRepository.getAuthUser(cred[this.options.passkeys.credentialUserIdFieldName]);
|
|
94
|
+
if (!user || !user_id || user[usersPrimaryKeyFieldName] !== user_id) {
|
|
95
|
+
throw new Error('User not found.');
|
|
96
|
+
}
|
|
97
|
+
const counter = credMeta.sign_count ?? 0;
|
|
98
|
+
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
|
|
99
|
+
response,
|
|
100
|
+
expectedChallenge,
|
|
101
|
+
expectedOrigin: settingsOrigin,
|
|
102
|
+
expectedRPID,
|
|
103
|
+
credential: {
|
|
104
|
+
id: cred[this.options.passkeys.credentialIdFieldName],
|
|
105
|
+
publicKey: isoBase64URL.toBuffer(credMeta.public_key),
|
|
106
|
+
counter,
|
|
107
|
+
transports: credMeta.transports,
|
|
108
|
+
},
|
|
109
|
+
requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!verified) {
|
|
113
|
+
return errorResult('User verification failed.');
|
|
114
|
+
}
|
|
115
|
+
credMeta.sign_count = authenticationInfo.newCounter;
|
|
116
|
+
credMeta.last_used_at = new Date().toISOString();
|
|
117
|
+
await this.passkeyRepository.updateMeta(cred, credMeta);
|
|
118
|
+
return { ok: true, passkeyConfirmed: true };
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return prefixedErrorResult('Error authenticating passkey: ', e);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public async createRegistrationOptions(mode: PluginAuthenticatorAttachment, adminUser: AdminUser, response: IAdminForthHttpResponse) {
|
|
125
|
+
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
126
|
+
const authenticatorAttachment: WebAuthnAuthenticatorAttachment | undefined = mode === "both" ? undefined : mode;
|
|
127
|
+
const rp = {
|
|
128
|
+
name: this.options.passkeys?.settings.rp?.name || this.adminforth.config.customization.brandName,
|
|
129
|
+
id: this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname,
|
|
130
|
+
};
|
|
131
|
+
const userInfo = await this.userRepository.getAuthUser(adminUser.pk);
|
|
132
|
+
if (!userInfo) {
|
|
133
|
+
return errorResult('User not found');
|
|
134
|
+
}
|
|
135
|
+
const user = {
|
|
136
|
+
pk: adminUser.pk,
|
|
137
|
+
name: userInfo[this.options.passkeys?.settings.user.nameField],
|
|
138
|
+
displayName: userInfo[this.options.passkeys?.settings.user.displayNameField] ? userInfo[this.options.passkeys?.settings.user.displayNameField] : userInfo[this.options.passkeys?.settings.user.nameField],
|
|
139
|
+
};
|
|
140
|
+
const excludeCredentials = [];
|
|
141
|
+
const temp = await this.passkeyRepository.listByUserId(adminUser.pk);
|
|
142
|
+
for (const rec of temp) {
|
|
143
|
+
const credentialId = rec[this.options.passkeys.credentialIdFieldName];
|
|
144
|
+
if (credentialId) {
|
|
145
|
+
const meta = rec[this.options.passkeys.credentialMetaFieldName];
|
|
146
|
+
excludeCredentials.push({
|
|
147
|
+
id: credentialId,
|
|
148
|
+
type: "public-key",
|
|
149
|
+
transports: meta.transports || []
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const options = await generateRegistrationOptions({
|
|
154
|
+
rpName: rp.name,
|
|
155
|
+
rpID: rp.id,
|
|
156
|
+
userID: isoUint8Array.fromUTF8String(user.pk),
|
|
157
|
+
userName: user.name,
|
|
158
|
+
userDisplayName: user.displayName,
|
|
159
|
+
excludeCredentials,
|
|
160
|
+
authenticatorSelection: {
|
|
161
|
+
authenticatorAttachment,
|
|
162
|
+
requireResidentKey: this.options.passkeys?.settings.authenticatorSelection.requireResidentKey ?? true,
|
|
163
|
+
userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required"
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
this.cookieService.setRegisterPasskeyTemporary(response, { challenge: options.challenge, user_id: adminUser.pk });
|
|
167
|
+
return { ok: true, data: options };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
public async finishRegistration(body: PasskeyRegistrationPayload, adminUser: AdminUser, cookies: CookieList) {
|
|
171
|
+
const passkeysCookies = this.cookieService.getRegisterPasskeyTemporary(cookies);
|
|
172
|
+
if (!passkeysCookies) {
|
|
173
|
+
return { error: 'Passkey token is required' };
|
|
174
|
+
}
|
|
175
|
+
const decodedPasskeysCookies = await this.cookieService.verifyRegisterPasskeyTemporary(cookies);
|
|
176
|
+
if (!decodedPasskeysCookies) {
|
|
177
|
+
return { error: 'Invalid passkey token' };
|
|
178
|
+
}
|
|
179
|
+
if (decodedPasskeysCookies.user_id !== adminUser.pk) {
|
|
180
|
+
return { error: 'Invalid user' };
|
|
181
|
+
}
|
|
182
|
+
const secret = this.userRepository.getSecret(adminUser.dbUser)
|
|
183
|
+
if (!secret || secret.length === 0) {
|
|
184
|
+
return { error: 'TOTP must be set up before registering a passkey' };
|
|
185
|
+
}
|
|
186
|
+
const settingsOrigin = this.options.passkeys?.settings.expectedOrigin;
|
|
187
|
+
const expectedOrigin = body.origin;
|
|
188
|
+
const expectedChallenge = decodedPasskeysCookies.challenge;
|
|
189
|
+
const expectedRPID = this.options.passkeys?.settings?.rp?.id || (new URL(settingsOrigin)).hostname;
|
|
190
|
+
try {
|
|
191
|
+
const response = JSON.parse(body.credential);
|
|
192
|
+
if (settingsOrigin !== expectedOrigin) {
|
|
193
|
+
throw new Error('Invalid origin');
|
|
194
|
+
}
|
|
195
|
+
const { verified, registrationInfo } = await verifyRegistrationResponse({
|
|
196
|
+
response,
|
|
197
|
+
expectedChallenge,
|
|
198
|
+
expectedOrigin,
|
|
199
|
+
expectedRPID,
|
|
200
|
+
requireUserVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification === 'discouraged' ? false : true,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!verified) {
|
|
204
|
+
throw new Error('Verification failed.');
|
|
205
|
+
}
|
|
206
|
+
const {
|
|
207
|
+
aaguid,
|
|
208
|
+
credential,
|
|
209
|
+
} = registrationInfo;
|
|
210
|
+
const provider_name = aaguids[aaguid]?.name || 'Unknown provider';
|
|
211
|
+
const credentialPublicKey = credential.publicKey;
|
|
212
|
+
const credentialID = credential.id;
|
|
213
|
+
|
|
214
|
+
const base64CredentialID = credentialID;
|
|
215
|
+
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
|
|
216
|
+
|
|
217
|
+
await this.passkeyRepository.create({
|
|
218
|
+
[this.options.passkeys.credentialIdFieldName] : base64CredentialID,
|
|
219
|
+
[this.options.passkeys.credentialUserIdFieldName] : adminUser.pk,
|
|
220
|
+
[this.options.passkeys.credentialMetaFieldName] : {
|
|
221
|
+
public_key : base64PublicKey,
|
|
222
|
+
public_key_algorithm : response.response.publicKeyAlgorithm,
|
|
223
|
+
sign_count : 0,
|
|
224
|
+
transports : response.response.transports,
|
|
225
|
+
created_at : new Date().toISOString(),
|
|
226
|
+
last_used_at : new Date().toISOString(),
|
|
227
|
+
aaguid : aaguid,
|
|
228
|
+
name : provider_name,
|
|
229
|
+
},
|
|
230
|
+
}, adminUser);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error(error);
|
|
233
|
+
return { error: 'Error registering passkey: ' + errorMessage(error) };
|
|
234
|
+
}
|
|
235
|
+
return { ok: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
public async createLoginOptions(response: IAdminForthHttpResponse) {
|
|
239
|
+
try {
|
|
240
|
+
const options = await generateAuthenticationOptions({
|
|
241
|
+
rpID: this.options.passkeys?.settings.rp?.id || (new URL(this.options.passkeys?.settings.expectedOrigin)).hostname,
|
|
242
|
+
userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required"
|
|
243
|
+
});
|
|
244
|
+
this.cookieService.setPasskeyLoginTemporary(response, options.challenge);
|
|
245
|
+
return { ok: true, data: options };
|
|
246
|
+
} catch (e) {
|
|
247
|
+
return { ok: false, error: errorMessage(e) };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
public async getPasskeys(adminUser: AdminUser) {
|
|
252
|
+
let passkeys;
|
|
253
|
+
try {
|
|
254
|
+
passkeys = await this.passkeyRepository.listByUserId(adminUser.pk);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return prefixedErrorResult('Error fetching passkeys: ', error);
|
|
257
|
+
}
|
|
258
|
+
let dataToReturn = [];
|
|
259
|
+
for (const pk of passkeys) {
|
|
260
|
+
const parsedKey = pk[this.options.passkeys.credentialMetaFieldName];
|
|
261
|
+
dataToReturn.push({
|
|
262
|
+
name: parsedKey.name,
|
|
263
|
+
light_icon: aaguids[parsedKey.aaguid]?.icon_light || null,
|
|
264
|
+
dark_icon: aaguids[parsedKey.aaguid]?.icon_dark || null,
|
|
265
|
+
created_at: parsedKey.created_at,
|
|
266
|
+
last_used_at: parsedKey.last_used_at,
|
|
267
|
+
id: pk[this.options.passkeys.credentialIdFieldName],
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return { ok: true, data: dataToReturn, authenticatorAttachment: this.options.passkeys?.settings.authenticatorSelection.authenticatorAttachment || 'both' };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
public async deletePasskey(credentialID: string, adminUser: AdminUser) {
|
|
274
|
+
if (!credentialID) {
|
|
275
|
+
return errorResult('Passkey ID is required');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const passkeyRecord = await this.passkeyRepository.getByCredentialIdAndUserId(credentialID, adminUser.pk);
|
|
279
|
+
if (!passkeyRecord) {
|
|
280
|
+
return errorResult('Passkey not found');
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await this.passkeyRepository.delete(passkeyRecord, adminUser);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return prefixedErrorResult('Error deleting passkey: ', error);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { ok: true };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
public async renamePasskey(credentialID: string, newName: string, adminUser: AdminUser) {
|
|
292
|
+
if (!credentialID) {
|
|
293
|
+
return errorResult('Passkey ID is required');
|
|
294
|
+
}
|
|
295
|
+
if (!newName) {
|
|
296
|
+
return errorResult('New name is required');
|
|
297
|
+
}
|
|
298
|
+
const passkeyRecord = await this.passkeyRepository.getByCredentialIdAndUserId(credentialID, adminUser.pk);
|
|
299
|
+
if (!passkeyRecord) {
|
|
300
|
+
return errorResult('Passkey not found');
|
|
301
|
+
}
|
|
302
|
+
const metaField = this.options.passkeys.credentialMetaFieldName;
|
|
303
|
+
const oldMeta = passkeyRecord[metaField];
|
|
304
|
+
const newMeta = { ...oldMeta, name: newName };
|
|
305
|
+
const newRecord = { ...passkeyRecord, [metaField]: newMeta };
|
|
306
|
+
try {
|
|
307
|
+
await this.passkeyRepository.update(passkeyRecord, newRecord, adminUser);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
return prefixedErrorResult('Error renaming passkey: ', error);
|
|
310
|
+
}
|
|
311
|
+
return { ok: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
public async checkIfUserHasPasskeys(cookies: CookieList) {
|
|
315
|
+
if (!this.options.passkeys) {
|
|
316
|
+
return { ok: false, hasPasskeys: false };
|
|
317
|
+
}
|
|
318
|
+
const decoded = await this.cookieService.verifyTotpTemporary(cookies);
|
|
319
|
+
if (!decoded)
|
|
320
|
+
return errorResult('Invalid token')
|
|
321
|
+
if (decoded.newSecret) {
|
|
322
|
+
return { ok: true, hasPasskeys: false };
|
|
323
|
+
}
|
|
324
|
+
const hasPasskeys = await this.passkeyRepository.hasByUserId(decoded.pk);
|
|
325
|
+
if (hasPasskeys) {
|
|
326
|
+
return { ok: true, hasPasskeys: true };
|
|
327
|
+
} else {
|
|
328
|
+
return { ok: true, hasPasskeys: false };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
public async hasPasskeysForUser(userId: string): Promise<boolean> {
|
|
333
|
+
return this.passkeyRepository.hasByUserId(userId);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
public async getLoginUserByPasskeyResponse(passkeyResponse: PasskeyAuthenticationPayload, cookies: CookieList) {
|
|
337
|
+
const cookiesValidationResult = await this.validateLoginChallengeCookie(cookies);
|
|
338
|
+
if (!cookiesValidationResult.ok) {
|
|
339
|
+
return { error: cookiesValidationResult.error };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let parsedPasskeyResponse;
|
|
343
|
+
try {
|
|
344
|
+
parsedPasskeyResponse = JSON.parse(passkeyResponse.response);
|
|
345
|
+
} catch (e) {
|
|
346
|
+
return { error: 'Malformed passkey response' };
|
|
347
|
+
}
|
|
348
|
+
const credential_id = parsedPasskeyResponse.id;
|
|
349
|
+
if (!credential_id) {
|
|
350
|
+
return { error: 'Credential ID is required' };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const passkeyRecord = await this.passkeyRepository.getByCredentialId(credential_id);
|
|
354
|
+
if (!passkeyRecord) {
|
|
355
|
+
return { error: 'No such passkey found, most likely it was removed on this website but you still have it on your device' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const userPk = passkeyRecord[this.options.passkeys.credentialUserIdFieldName];
|
|
359
|
+
if (!userPk) {
|
|
360
|
+
return { error: 'User ID not found in passkey record' };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const user = await this.userRepository.getAuthUser(userPk);
|
|
364
|
+
if (!user) {
|
|
365
|
+
return { error: 'User not found' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies);
|
|
369
|
+
if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
|
|
370
|
+
return { error: 'Passkey verification failed' };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { ok: true, user };
|
|
374
|
+
}
|
|
375
|
+
}
|