781 lines
30 KiB
TypeScript
781 lines
30 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
|
|
import { LoginSession } from './classes.loginsession.js';
|
|
import { MfaChallenge } from './classes.mfachallenge.js';
|
|
import { PasskeyCredential } from './classes.passkeycredential.js';
|
|
import { TotpCredential } from './classes.totpcredential.js';
|
|
import { WebAuthnChallenge } from './classes.webauthnchallenge.js';
|
|
import type { Reception } from './classes.reception.js';
|
|
import type { User } from './classes.user.js';
|
|
|
|
type TMfaMethod = 'totp' | 'backupCode' | 'passkey';
|
|
|
|
export class MfaManager {
|
|
private readonly mfaChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({ minutes: 5 });
|
|
private readonly webAuthnChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({ minutes: 5 });
|
|
private readonly attemptConfig = {
|
|
maxAttempts: 5,
|
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
|
};
|
|
|
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
|
public CTotpCredential = plugins.smartdata.setDefaultManagerForDoc(this, TotpCredential);
|
|
public CMfaChallenge = plugins.smartdata.setDefaultManagerForDoc(this, MfaChallenge);
|
|
public CPasskeyCredential = plugins.smartdata.setDefaultManagerForDoc(this, PasskeyCredential);
|
|
public CWebAuthnChallenge = plugins.smartdata.setDefaultManagerForDoc(this, WebAuthnChallenge);
|
|
|
|
constructor(public receptionRef: Reception) {
|
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('getMfaStatus', async (requestArg) => {
|
|
const user = await this.getUserByJwt(requestArg.jwt);
|
|
const [totpCredential, passkeys] = await Promise.all([
|
|
this.getActiveTotpCredential(user.id),
|
|
this.getActivePasskeysForUser(user.id),
|
|
]);
|
|
return {
|
|
totpEnabled: !!totpCredential,
|
|
backupCodesRemaining: totpCredential ? this.getRemainingBackupCodeCount(totpCredential) : 0,
|
|
passkeys: passkeys.map((passkeyArg) => this.serializePasskey(passkeyArg)),
|
|
availableMethods: await this.getAvailableMfaMethodsForUser(user.id),
|
|
};
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('startTotpEnrollment', async (requestArg) => {
|
|
const user = await this.getUserByJwt(requestArg.jwt);
|
|
return this.startTotpEnrollmentForUser(user);
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('finishTotpEnrollment', async (requestArg) => {
|
|
const user = await this.getUserByJwt(requestArg.jwt);
|
|
return this.finishTotpEnrollmentForUser(user, requestArg.credentialId, requestArg.code);
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('disableTotp', async (requestArg) => {
|
|
const user = await this.getUserByJwt(requestArg.jwt);
|
|
await this.disableTotpForUser(user.id, requestArg.code);
|
|
return { success: true };
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('regenerateBackupCodes', async (requestArg) => {
|
|
const user = await this.getUserByJwt(requestArg.jwt);
|
|
return { backupCodes: await this.regenerateBackupCodesForUser(user.id, requestArg.code) };
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('verifyMfaChallenge', async (requestArg) => {
|
|
return this.verifyMfaChallengeWithCode(
|
|
requestArg.mfaChallengeToken,
|
|
requestArg.method,
|
|
requestArg.code,
|
|
);
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('startPasskeyRegistration', async (requestArg) => {
|
|
const user = await this.getUserByJwt(requestArg.jwt);
|
|
return this.startPasskeyRegistrationForUser(user, requestArg.label);
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('finishPasskeyRegistration', async (requestArg) => {
|
|
const user = await this.getUserByJwt(requestArg.jwt);
|
|
return this.finishPasskeyRegistrationForUser(user, requestArg.challengeId, requestArg.response, requestArg.label);
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('revokePasskey', async (requestArg) => {
|
|
const user = await this.getUserByJwt(requestArg.jwt);
|
|
await this.revokePasskeyForUser(user.id, requestArg.passkeyId);
|
|
return { success: true };
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('startPasskeyLogin', async (requestArg) => {
|
|
return this.startPasskeyLogin(requestArg.username);
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('finishPasskeyLogin', async (requestArg) => {
|
|
return this.finishPasskeyLogin(requestArg.challengeId, requestArg.response);
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('startPasskeyMfa', async (requestArg) => {
|
|
return this.startPasskeyMfa(requestArg.mfaChallengeToken);
|
|
}));
|
|
|
|
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('finishPasskeyMfa', async (requestArg) => {
|
|
return this.finishPasskeyMfa(
|
|
requestArg.mfaChallengeToken,
|
|
requestArg.challengeId,
|
|
requestArg.response,
|
|
);
|
|
}));
|
|
}
|
|
|
|
public get db() {
|
|
return this.receptionRef.db.smartdataDb;
|
|
}
|
|
|
|
public async getAvailableMfaMethodsForUser(userIdArg: string): Promise<TMfaMethod[]> {
|
|
const methods: TMfaMethod[] = [];
|
|
const [totpCredential, passkeys] = await Promise.all([
|
|
this.getActiveTotpCredential(userIdArg),
|
|
this.getActivePasskeysForUser(userIdArg),
|
|
]);
|
|
if (totpCredential) {
|
|
methods.push('totp');
|
|
if (this.getRemainingBackupCodeCount(totpCredential) > 0) {
|
|
methods.push('backupCode');
|
|
}
|
|
}
|
|
if (passkeys.length > 0) {
|
|
methods.push('passkey');
|
|
}
|
|
return methods;
|
|
}
|
|
|
|
public async createMfaChallengeForUser(userIdArg: string, primaryAuthMethodArg: 'password' | 'email') {
|
|
const availableMethods = await this.getAvailableMfaMethodsForUser(userIdArg);
|
|
if (!availableMethods.length) {
|
|
return null;
|
|
}
|
|
|
|
const token = this.createOpaqueToken('mfa_');
|
|
const mfaChallenge = new MfaChallenge();
|
|
mfaChallenge.id = plugins.smartunique.shortId();
|
|
mfaChallenge.data = {
|
|
userId: userIdArg,
|
|
tokenHash: MfaChallenge.hashToken(token),
|
|
status: 'pending',
|
|
availableMethods,
|
|
primaryAuthMethod: primaryAuthMethodArg,
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + this.mfaChallengeMillis,
|
|
completedAt: null,
|
|
};
|
|
await mfaChallenge.save();
|
|
|
|
return {
|
|
token,
|
|
availableMethods,
|
|
};
|
|
}
|
|
|
|
public async cleanupExpiredChallenges() {
|
|
const now = Date.now();
|
|
const [mfaChallenges, webAuthnChallenges] = await Promise.all([
|
|
this.CMfaChallenge.getInstances({ 'data.status': 'pending' }),
|
|
this.CWebAuthnChallenge.getInstances({ 'data.status': 'pending' }),
|
|
]);
|
|
|
|
for (const challenge of mfaChallenges) {
|
|
if (challenge.data.expiresAt < now) {
|
|
await challenge.markExpired();
|
|
}
|
|
}
|
|
for (const challenge of webAuthnChallenges) {
|
|
if (challenge.data.expiresAt < now) {
|
|
await challenge.markExpired();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async getUserByJwt(jwtArg: string): Promise<User> {
|
|
const user = await this.receptionRef.userManager.getUserByJwtValidation(jwtArg);
|
|
if (!user) {
|
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
|
}
|
|
return user;
|
|
}
|
|
|
|
private async getUserByIdentifier(identifierArg?: string): Promise<User | null> {
|
|
if (!identifierArg) {
|
|
return null;
|
|
}
|
|
let user = await this.receptionRef.userManager.CUser.getInstance({
|
|
data: {
|
|
username: identifierArg,
|
|
},
|
|
});
|
|
if (!user && identifierArg.includes('@')) {
|
|
user = await this.receptionRef.userManager.CUser.getInstance({
|
|
data: {
|
|
email: identifierArg,
|
|
},
|
|
});
|
|
}
|
|
return user;
|
|
}
|
|
|
|
private createOpaqueToken(prefixArg: string) {
|
|
return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
|
}
|
|
|
|
private getOrigin() {
|
|
return new URL(this.receptionRef.options.baseUrl).origin;
|
|
}
|
|
|
|
private getRpId() {
|
|
return new URL(this.receptionRef.options.baseUrl).hostname;
|
|
}
|
|
|
|
private getEncryptionKey() {
|
|
const keyMaterial =
|
|
process.env.IDP_TOTP_ENCRYPTION_KEY ||
|
|
process.env.TOTP_ENCRYPTION_KEY ||
|
|
`${this.receptionRef.options.name}:${this.receptionRef.options.baseUrl}`;
|
|
return plugins.crypto.createHash('sha256').update(keyMaterial).digest();
|
|
}
|
|
|
|
private encryptSecret(secretArg: string) {
|
|
const iv = plugins.crypto.randomBytes(12);
|
|
const cipher = plugins.crypto.createCipheriv('aes-256-gcm', this.getEncryptionKey(), iv);
|
|
const ciphertext = Buffer.concat([cipher.update(secretArg, 'utf8'), cipher.final()]);
|
|
return {
|
|
secretCiphertext: ciphertext.toString('base64'),
|
|
secretIv: iv.toString('base64'),
|
|
secretAuthTag: cipher.getAuthTag().toString('base64'),
|
|
};
|
|
}
|
|
|
|
private decryptSecret(totpCredentialArg: TotpCredential) {
|
|
const decipher = plugins.crypto.createDecipheriv(
|
|
'aes-256-gcm',
|
|
this.getEncryptionKey(),
|
|
Buffer.from(totpCredentialArg.data.secretIv, 'base64'),
|
|
);
|
|
decipher.setAuthTag(Buffer.from(totpCredentialArg.data.secretAuthTag, 'base64'));
|
|
return Buffer.concat([
|
|
decipher.update(Buffer.from(totpCredentialArg.data.secretCiphertext, 'base64')),
|
|
decipher.final(),
|
|
]).toString('utf8');
|
|
}
|
|
|
|
private normalizeOtpCode(codeArg: string) {
|
|
return String(codeArg || '').replace(/\s/g, '').trim();
|
|
}
|
|
|
|
private normalizeBackupCode(codeArg: string) {
|
|
return String(codeArg || '').replace(/\s/g, '').toLowerCase();
|
|
}
|
|
|
|
private hashBackupCode(codeArg: string) {
|
|
return plugins.smarthash.sha256FromStringSync(this.normalizeBackupCode(codeArg));
|
|
}
|
|
|
|
private createBackupCodes() {
|
|
return Array.from({ length: 10 }, () => {
|
|
const raw = plugins.crypto.randomBytes(5).toString('hex');
|
|
return `${raw.slice(0, 5)}-${raw.slice(5)}`;
|
|
});
|
|
}
|
|
|
|
private getRemainingBackupCodeCount(totpCredentialArg: TotpCredential) {
|
|
return (totpCredentialArg.data.backupCodes || []).filter((codeArg) => !codeArg.usedAt).length;
|
|
}
|
|
|
|
private async getActiveTotpCredential(userIdArg: string) {
|
|
return this.CTotpCredential.getInstance({
|
|
'data.userId': userIdArg,
|
|
'data.status': 'active',
|
|
});
|
|
}
|
|
|
|
private async getActivePasskeysForUser(userIdArg: string) {
|
|
return this.CPasskeyCredential.getInstances({
|
|
'data.userId': userIdArg,
|
|
'data.status': 'active',
|
|
});
|
|
}
|
|
|
|
private serializePasskey(passkeyArg: PasskeyCredential) {
|
|
return {
|
|
id: passkeyArg.id,
|
|
data: passkeyArg.data,
|
|
};
|
|
}
|
|
|
|
private async startTotpEnrollmentForUser(userArg: User) {
|
|
const activeCredential = await this.getActiveTotpCredential(userArg.id);
|
|
if (activeCredential) {
|
|
throw new plugins.typedrequest.TypedResponseError('TOTP is already enabled');
|
|
}
|
|
|
|
const existingPending = await this.CTotpCredential.getInstances({
|
|
'data.userId': userArg.id,
|
|
'data.status': 'pending',
|
|
});
|
|
for (const pendingCredential of existingPending) {
|
|
pendingCredential.data.status = 'disabled';
|
|
pendingCredential.data.disabledAt = Date.now();
|
|
await pendingCredential.save();
|
|
}
|
|
|
|
const secret = plugins.otplib.generateSecret();
|
|
const encryptedSecret = this.encryptSecret(secret);
|
|
const totpCredential = new TotpCredential();
|
|
totpCredential.id = plugins.smartunique.shortId();
|
|
totpCredential.data = {
|
|
userId: userArg.id,
|
|
status: 'pending',
|
|
...encryptedSecret,
|
|
algorithm: 'sha1',
|
|
digits: 6,
|
|
period: 30,
|
|
backupCodes: [],
|
|
createdAt: Date.now(),
|
|
verifiedAt: null,
|
|
disabledAt: null,
|
|
lastUsedAt: null,
|
|
};
|
|
await totpCredential.save();
|
|
|
|
const otpauthUrl = plugins.otplib.generateURI({
|
|
issuer: this.receptionRef.options.name,
|
|
label: userArg.data.email || userArg.data.username,
|
|
secret,
|
|
});
|
|
|
|
return {
|
|
credentialId: totpCredential.id,
|
|
secret,
|
|
otpauthUrl,
|
|
};
|
|
}
|
|
|
|
private async verifyTotpCodeForCredential(totpCredentialArg: TotpCredential, codeArg: string) {
|
|
const token = this.normalizeOtpCode(codeArg);
|
|
if (!/^\d{6,8}$/.test(token)) {
|
|
return false;
|
|
}
|
|
const secret = this.decryptSecret(totpCredentialArg);
|
|
const result = await plugins.otplib.verify({
|
|
secret,
|
|
token,
|
|
algorithm: totpCredentialArg.data.algorithm,
|
|
digits: totpCredentialArg.data.digits,
|
|
period: totpCredentialArg.data.period,
|
|
epochTolerance: 30,
|
|
});
|
|
return !!result.valid;
|
|
}
|
|
|
|
private async finishTotpEnrollmentForUser(userArg: User, credentialIdArg: string, codeArg: string) {
|
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
|
'totpEnrollment',
|
|
userArg.id,
|
|
this.attemptConfig,
|
|
'Too many TOTP setup attempts. Please wait before trying again.',
|
|
);
|
|
|
|
const totpCredential = await this.CTotpCredential.getInstance({
|
|
id: credentialIdArg,
|
|
'data.userId': userArg.id,
|
|
'data.status': 'pending',
|
|
});
|
|
if (!totpCredential) {
|
|
throw new plugins.typedrequest.TypedResponseError('TOTP enrollment not found');
|
|
}
|
|
const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg);
|
|
if (!valid) {
|
|
throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code');
|
|
}
|
|
|
|
const backupCodes = this.createBackupCodes();
|
|
totpCredential.data.status = 'active';
|
|
totpCredential.data.verifiedAt = Date.now();
|
|
totpCredential.data.lastUsedAt = Date.now();
|
|
totpCredential.data.backupCodes = backupCodes.map((codeArg) => ({
|
|
id: plugins.smartunique.shortId(),
|
|
codeHash: this.hashBackupCode(codeArg),
|
|
usedAt: null,
|
|
createdAt: Date.now(),
|
|
}));
|
|
await totpCredential.save();
|
|
await this.receptionRef.abuseProtectionManager.clearAttempts('totpEnrollment', userArg.id);
|
|
await this.receptionRef.activityLogManager.logActivity(userArg.id, 'totp_enabled' as any, 'Enabled TOTP two-factor authentication');
|
|
|
|
return {
|
|
success: true,
|
|
backupCodes,
|
|
};
|
|
}
|
|
|
|
private async verifyTotpForUser(userIdArg: string, codeArg: string) {
|
|
const totpCredential = await this.getActiveTotpCredential(userIdArg);
|
|
if (!totpCredential) {
|
|
return false;
|
|
}
|
|
const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg);
|
|
if (valid) {
|
|
totpCredential.data.lastUsedAt = Date.now();
|
|
await totpCredential.save();
|
|
}
|
|
return valid;
|
|
}
|
|
|
|
private async consumeBackupCodeForUser(userIdArg: string, codeArg: string) {
|
|
const totpCredential = await this.getActiveTotpCredential(userIdArg);
|
|
if (!totpCredential) {
|
|
return false;
|
|
}
|
|
const codeHash = this.hashBackupCode(codeArg);
|
|
const backupCode = totpCredential.data.backupCodes.find((codeArg) => {
|
|
return !codeArg.usedAt && codeArg.codeHash === codeHash;
|
|
});
|
|
if (!backupCode) {
|
|
return false;
|
|
}
|
|
backupCode.usedAt = Date.now();
|
|
totpCredential.data.lastUsedAt = Date.now();
|
|
await totpCredential.save();
|
|
return true;
|
|
}
|
|
|
|
private async disableTotpForUser(userIdArg: string, codeArg: string) {
|
|
const totpCredential = await this.getActiveTotpCredential(userIdArg);
|
|
if (!totpCredential) {
|
|
throw new plugins.typedrequest.TypedResponseError('TOTP is not enabled');
|
|
}
|
|
const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg);
|
|
if (!valid) {
|
|
throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code');
|
|
}
|
|
totpCredential.data.status = 'disabled';
|
|
totpCredential.data.disabledAt = Date.now();
|
|
await totpCredential.save();
|
|
await this.receptionRef.activityLogManager.logActivity(userIdArg, 'totp_disabled' as any, 'Disabled TOTP two-factor authentication');
|
|
}
|
|
|
|
private async regenerateBackupCodesForUser(userIdArg: string, codeArg: string) {
|
|
const totpCredential = await this.getActiveTotpCredential(userIdArg);
|
|
if (!totpCredential) {
|
|
throw new plugins.typedrequest.TypedResponseError('TOTP is not enabled');
|
|
}
|
|
const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg);
|
|
if (!valid) {
|
|
throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code');
|
|
}
|
|
const backupCodes = this.createBackupCodes();
|
|
totpCredential.data.backupCodes = backupCodes.map((backupCodeArg) => ({
|
|
id: plugins.smartunique.shortId(),
|
|
codeHash: this.hashBackupCode(backupCodeArg),
|
|
usedAt: null,
|
|
createdAt: Date.now(),
|
|
}));
|
|
await totpCredential.save();
|
|
await this.receptionRef.activityLogManager.logActivity(userIdArg, 'backup_codes_regenerated' as any, 'Regenerated TOTP backup codes');
|
|
return backupCodes;
|
|
}
|
|
|
|
private async getPendingMfaChallengeByToken(tokenArg: string) {
|
|
const mfaChallenge = await this.CMfaChallenge.getInstance({
|
|
'data.tokenHash': MfaChallenge.hashToken(tokenArg),
|
|
});
|
|
if (!mfaChallenge || mfaChallenge.data.status !== 'pending') {
|
|
throw new plugins.typedrequest.TypedResponseError('MFA challenge not found');
|
|
}
|
|
if (mfaChallenge.isExpired()) {
|
|
await mfaChallenge.markExpired();
|
|
throw new plugins.typedrequest.TypedResponseError('MFA challenge expired');
|
|
}
|
|
return mfaChallenge;
|
|
}
|
|
|
|
private async completeMfaChallenge(mfaChallengeArg: MfaChallenge) {
|
|
const user = await this.receptionRef.userManager.CUser.getInstance({ id: mfaChallengeArg.data.userId });
|
|
if (!user) {
|
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
|
}
|
|
await mfaChallengeArg.markCompleted();
|
|
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
|
const refreshToken = await loginSession.getRefreshToken();
|
|
if (!refreshToken) {
|
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
|
}
|
|
await this.receptionRef.activityLogManager.logActivity(user.id, 'mfa_completed' as any, 'Completed multi-factor authentication');
|
|
return { refreshToken };
|
|
}
|
|
|
|
private async verifyMfaChallengeWithCode(tokenArg: string, methodArg: TMfaMethod, codeArg: string) {
|
|
const mfaChallenge = await this.getPendingMfaChallengeByToken(tokenArg);
|
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
|
'mfaChallenge',
|
|
mfaChallenge.id,
|
|
this.attemptConfig,
|
|
'Too many MFA attempts. Please wait before trying again.',
|
|
);
|
|
|
|
let valid = false;
|
|
if (methodArg === 'totp') {
|
|
valid = await this.verifyTotpForUser(mfaChallenge.data.userId, codeArg);
|
|
} else if (methodArg === 'backupCode') {
|
|
valid = await this.consumeBackupCodeForUser(mfaChallenge.data.userId, codeArg);
|
|
}
|
|
if (!valid) {
|
|
throw new plugins.typedrequest.TypedResponseError('Invalid MFA code');
|
|
}
|
|
await this.receptionRef.abuseProtectionManager.clearAttempts('mfaChallenge', mfaChallenge.id);
|
|
return this.completeMfaChallenge(mfaChallenge);
|
|
}
|
|
|
|
private async startPasskeyRegistrationForUser(userArg: User, labelArg?: string) {
|
|
const passkeys = await this.getActivePasskeysForUser(userArg.id);
|
|
const options = await plugins.simpleWebAuthnServer.generateRegistrationOptions({
|
|
rpName: this.receptionRef.options.name,
|
|
rpID: this.getRpId(),
|
|
userName: userArg.data.email || userArg.data.username,
|
|
userDisplayName: userArg.data.name || userArg.data.email || userArg.data.username,
|
|
userID: Buffer.from(userArg.id, 'utf8'),
|
|
attestationType: 'none',
|
|
excludeCredentials: passkeys.map((passkeyArg) => ({
|
|
id: passkeyArg.data.credentialId,
|
|
transports: passkeyArg.data.transports as any,
|
|
})),
|
|
authenticatorSelection: {
|
|
residentKey: 'preferred',
|
|
userVerification: 'required',
|
|
},
|
|
supportedAlgorithmIDs: [-7, -257],
|
|
});
|
|
|
|
const webAuthnChallenge = new WebAuthnChallenge();
|
|
webAuthnChallenge.id = plugins.smartunique.shortId();
|
|
webAuthnChallenge.data = {
|
|
userId: userArg.id,
|
|
username: userArg.data.email || userArg.data.username,
|
|
mfaChallengeId: null,
|
|
type: 'registration',
|
|
challenge: options.challenge,
|
|
status: 'pending',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + this.webAuthnChallengeMillis,
|
|
completedAt: null,
|
|
};
|
|
await webAuthnChallenge.save();
|
|
|
|
return {
|
|
challengeId: webAuthnChallenge.id,
|
|
options,
|
|
};
|
|
}
|
|
|
|
private async getPendingWebAuthnChallenge(challengeIdArg: string, typeArg: 'registration' | 'login' | 'mfa') {
|
|
const webAuthnChallenge = await this.CWebAuthnChallenge.getInstance({
|
|
id: challengeIdArg,
|
|
'data.type': typeArg,
|
|
'data.status': 'pending',
|
|
});
|
|
if (!webAuthnChallenge) {
|
|
throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge not found');
|
|
}
|
|
if (webAuthnChallenge.isExpired()) {
|
|
await webAuthnChallenge.markExpired();
|
|
throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge expired');
|
|
}
|
|
return webAuthnChallenge;
|
|
}
|
|
|
|
private async finishPasskeyRegistrationForUser(userArg: User, challengeIdArg: string, responseArg: any, labelArg?: string) {
|
|
const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'registration');
|
|
if (webAuthnChallenge.data.userId !== userArg.id) {
|
|
throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge does not belong to this user');
|
|
}
|
|
|
|
const verification = await plugins.simpleWebAuthnServer.verifyRegistrationResponse({
|
|
response: responseArg,
|
|
expectedChallenge: webAuthnChallenge.data.challenge,
|
|
expectedOrigin: this.getOrigin(),
|
|
expectedRPID: this.getRpId(),
|
|
requireUserVerification: true,
|
|
supportedAlgorithmIDs: [-7, -257],
|
|
});
|
|
if (!verification.verified) {
|
|
throw new plugins.typedrequest.TypedResponseError('Passkey registration failed');
|
|
}
|
|
|
|
const credential = verification.registrationInfo.credential;
|
|
const existingCredential = await this.CPasskeyCredential.getInstance({
|
|
'data.credentialId': credential.id,
|
|
'data.status': 'active',
|
|
});
|
|
if (existingCredential) {
|
|
throw new plugins.typedrequest.TypedResponseError('Passkey is already registered');
|
|
}
|
|
|
|
const passkeyCredential = new PasskeyCredential();
|
|
passkeyCredential.id = plugins.smartunique.shortId();
|
|
passkeyCredential.data = {
|
|
userId: userArg.id,
|
|
label: labelArg || 'Passkey',
|
|
credentialId: credential.id,
|
|
publicKeyBase64: Buffer.from(credential.publicKey).toString('base64'),
|
|
counter: credential.counter,
|
|
deviceType: verification.registrationInfo.credentialDeviceType,
|
|
backedUp: verification.registrationInfo.credentialBackedUp,
|
|
transports: credential.transports || [],
|
|
status: 'active',
|
|
createdAt: Date.now(),
|
|
lastUsedAt: null,
|
|
revokedAt: null,
|
|
};
|
|
await passkeyCredential.save();
|
|
await webAuthnChallenge.markCompleted();
|
|
await this.receptionRef.activityLogManager.logActivity(userArg.id, 'passkey_registered' as any, `Registered passkey ${passkeyCredential.data.label}`);
|
|
return {
|
|
success: true,
|
|
passkey: this.serializePasskey(passkeyCredential),
|
|
};
|
|
}
|
|
|
|
private async revokePasskeyForUser(userIdArg: string, passkeyIdArg: string) {
|
|
const passkeyCredential = await this.CPasskeyCredential.getInstance({
|
|
id: passkeyIdArg,
|
|
'data.userId': userIdArg,
|
|
'data.status': 'active',
|
|
});
|
|
if (!passkeyCredential) {
|
|
throw new plugins.typedrequest.TypedResponseError('Passkey not found');
|
|
}
|
|
passkeyCredential.data.status = 'revoked';
|
|
passkeyCredential.data.revokedAt = Date.now();
|
|
await passkeyCredential.save();
|
|
await this.receptionRef.activityLogManager.logActivity(userIdArg, 'passkey_revoked' as any, `Revoked passkey ${passkeyCredential.data.label}`);
|
|
}
|
|
|
|
private async startPasskeyLogin(usernameArg?: string) {
|
|
const user = await this.getUserByIdentifier(usernameArg);
|
|
const passkeys = user ? await this.getActivePasskeysForUser(user.id) : [];
|
|
if (usernameArg && !passkeys.length) {
|
|
throw new plugins.typedrequest.TypedResponseError('No passkeys registered for this account');
|
|
}
|
|
|
|
const options = await plugins.simpleWebAuthnServer.generateAuthenticationOptions({
|
|
rpID: this.getRpId(),
|
|
allowCredentials: usernameArg ? passkeys.map((passkeyArg) => ({
|
|
id: passkeyArg.data.credentialId,
|
|
transports: passkeyArg.data.transports as any,
|
|
})) : undefined,
|
|
userVerification: 'required',
|
|
});
|
|
|
|
const webAuthnChallenge = new WebAuthnChallenge();
|
|
webAuthnChallenge.id = plugins.smartunique.shortId();
|
|
webAuthnChallenge.data = {
|
|
userId: user?.id || null,
|
|
username: usernameArg || null,
|
|
mfaChallengeId: null,
|
|
type: 'login',
|
|
challenge: options.challenge,
|
|
status: 'pending',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + this.webAuthnChallengeMillis,
|
|
completedAt: null,
|
|
};
|
|
await webAuthnChallenge.save();
|
|
|
|
return {
|
|
challengeId: webAuthnChallenge.id,
|
|
options,
|
|
};
|
|
}
|
|
|
|
private async verifyPasskeyAuthentication(webAuthnChallengeArg: WebAuthnChallenge, responseArg: any) {
|
|
const credentialId = responseArg?.id;
|
|
if (!credentialId) {
|
|
throw new plugins.typedrequest.TypedResponseError('Passkey credential id missing');
|
|
}
|
|
const passkeyCredential = await this.CPasskeyCredential.getInstance({
|
|
'data.credentialId': credentialId,
|
|
'data.status': 'active',
|
|
});
|
|
if (!passkeyCredential) {
|
|
throw new plugins.typedrequest.TypedResponseError('Passkey credential not found');
|
|
}
|
|
if (webAuthnChallengeArg.data.userId && passkeyCredential.data.userId !== webAuthnChallengeArg.data.userId) {
|
|
throw new plugins.typedrequest.TypedResponseError('Passkey does not belong to this challenge');
|
|
}
|
|
|
|
const verification = await plugins.simpleWebAuthnServer.verifyAuthenticationResponse({
|
|
response: responseArg,
|
|
expectedChallenge: webAuthnChallengeArg.data.challenge,
|
|
expectedOrigin: this.getOrigin(),
|
|
expectedRPID: this.getRpId(),
|
|
credential: {
|
|
id: passkeyCredential.data.credentialId,
|
|
publicKey: new Uint8Array(Buffer.from(passkeyCredential.data.publicKeyBase64, 'base64')),
|
|
counter: passkeyCredential.data.counter,
|
|
transports: passkeyCredential.data.transports as any,
|
|
},
|
|
requireUserVerification: true,
|
|
});
|
|
if (!verification.verified || !verification.authenticationInfo.userVerified) {
|
|
throw new plugins.typedrequest.TypedResponseError('Passkey authentication failed');
|
|
}
|
|
|
|
passkeyCredential.data.counter = verification.authenticationInfo.newCounter;
|
|
passkeyCredential.data.backedUp = verification.authenticationInfo.credentialBackedUp;
|
|
passkeyCredential.data.deviceType = verification.authenticationInfo.credentialDeviceType;
|
|
passkeyCredential.data.lastUsedAt = Date.now();
|
|
await passkeyCredential.save();
|
|
await webAuthnChallengeArg.markCompleted();
|
|
return passkeyCredential;
|
|
}
|
|
|
|
private async finishPasskeyLogin(challengeIdArg: string, responseArg: any) {
|
|
const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'login');
|
|
const passkeyCredential = await this.verifyPasskeyAuthentication(webAuthnChallenge, responseArg);
|
|
const user = await this.receptionRef.userManager.CUser.getInstance({ id: passkeyCredential.data.userId });
|
|
if (!user) {
|
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
|
}
|
|
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
|
const refreshToken = await loginSession.getRefreshToken();
|
|
if (!refreshToken) {
|
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
|
}
|
|
await this.receptionRef.activityLogManager.logActivity(user.id, 'passkey_login' as any, `Signed in with passkey ${passkeyCredential.data.label}`);
|
|
return { refreshToken };
|
|
}
|
|
|
|
private async startPasskeyMfa(mfaChallengeTokenArg: string) {
|
|
const mfaChallenge = await this.getPendingMfaChallengeByToken(mfaChallengeTokenArg);
|
|
const passkeys = await this.getActivePasskeysForUser(mfaChallenge.data.userId);
|
|
if (!passkeys.length) {
|
|
throw new plugins.typedrequest.TypedResponseError('No passkeys registered for this account');
|
|
}
|
|
const options = await plugins.simpleWebAuthnServer.generateAuthenticationOptions({
|
|
rpID: this.getRpId(),
|
|
allowCredentials: passkeys.map((passkeyArg) => ({
|
|
id: passkeyArg.data.credentialId,
|
|
transports: passkeyArg.data.transports as any,
|
|
})),
|
|
userVerification: 'required',
|
|
});
|
|
const webAuthnChallenge = new WebAuthnChallenge();
|
|
webAuthnChallenge.id = plugins.smartunique.shortId();
|
|
webAuthnChallenge.data = {
|
|
userId: mfaChallenge.data.userId,
|
|
username: null,
|
|
mfaChallengeId: mfaChallenge.id,
|
|
type: 'mfa',
|
|
challenge: options.challenge,
|
|
status: 'pending',
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + this.webAuthnChallengeMillis,
|
|
completedAt: null,
|
|
};
|
|
await webAuthnChallenge.save();
|
|
return {
|
|
challengeId: webAuthnChallenge.id,
|
|
options,
|
|
};
|
|
}
|
|
|
|
private async finishPasskeyMfa(mfaChallengeTokenArg: string, challengeIdArg: string, responseArg: any) {
|
|
const mfaChallenge = await this.getPendingMfaChallengeByToken(mfaChallengeTokenArg);
|
|
const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'mfa');
|
|
if (webAuthnChallenge.data.mfaChallengeId !== mfaChallenge.id) {
|
|
throw new plugins.typedrequest.TypedResponseError('Passkey MFA challenge mismatch');
|
|
}
|
|
await this.verifyPasskeyAuthentication(webAuthnChallenge, responseArg);
|
|
return this.completeMfaChallenge(mfaChallenge);
|
|
}
|
|
}
|