194 lines
5.7 KiB
TypeScript
194 lines
5.7 KiB
TypeScript
import type { Handler } from 'hono';
|
|
import type { User } from 'types';
|
|
import { createRouter } from '../../create-router';
|
|
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
|
|
import { sign } from '../../jwt';
|
|
import * as errors from '../../custom-errors';
|
|
import { isLockdown, noteBlocked } from './panic';
|
|
import {
|
|
getUserByEmail,
|
|
getPasskeysByUserIdAndOrigin,
|
|
getPasskeyByCredentialId,
|
|
createPasskey,
|
|
updatePasskey,
|
|
storeChallenge,
|
|
consumeChallenge,
|
|
} from 'officerdb';
|
|
import {
|
|
generateRegistrationOptions,
|
|
verifyRegistrationResponse,
|
|
generateAuthenticationOptions,
|
|
verifyAuthenticationResponse,
|
|
} from '@simplewebauthn/server';
|
|
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server';
|
|
|
|
export const passkeyRouter = createRouter();
|
|
|
|
const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
const RP_NAME = 'Officer';
|
|
|
|
function getRpId(origin: string): string {
|
|
try {
|
|
const url = new URL(origin);
|
|
return url.hostname;
|
|
} catch {
|
|
return 'officer.dev';
|
|
}
|
|
}
|
|
|
|
// Generate registration options (challenge) for creating a new passkey
|
|
const passkeyRouterPostChallenge: Handler = async (ctx) => {
|
|
const { email } = ctx.req.param();
|
|
const origin = ctx.get('origin') as string;
|
|
const rpId = getRpId(origin);
|
|
|
|
const dbUser = await getUserByEmail(email!);
|
|
if (!dbUser) throw errors.NOT_FOUND('User not found');
|
|
|
|
// Get existing passkeys to exclude them
|
|
const existingPasskeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName: RP_NAME,
|
|
rpID: rpId,
|
|
userName: email!,
|
|
userDisplayName: email!,
|
|
attestationType: 'none',
|
|
excludeCredentials: existingPasskeys.map((p) => ({
|
|
id: p.credentialId!,
|
|
})),
|
|
authenticatorSelection: {
|
|
residentKey: 'preferred',
|
|
userVerification: 'preferred',
|
|
},
|
|
});
|
|
|
|
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
|
|
return ctx.json(options);
|
|
};
|
|
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
|
|
|
|
// Verify and store new passkey registration
|
|
const passkeyRouterPost: Handler = async (ctx) => {
|
|
const origin = ctx.get('origin') as string;
|
|
const rpId = getRpId(origin);
|
|
const user = ctx.get('user') as User;
|
|
const response = ctx.get('body') as RegistrationResponseJSON;
|
|
|
|
const storedChallenge = await consumeChallenge(user.id, origin);
|
|
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
|
|
|
const verification = await verifyRegistrationResponse({
|
|
response,
|
|
expectedChallenge: storedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpId,
|
|
});
|
|
|
|
if (!verification.verified || !verification.registrationInfo) {
|
|
throw errors.BAD_CREDENTIALS();
|
|
}
|
|
|
|
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
|
|
await createPasskey({
|
|
userId: user.id,
|
|
origin,
|
|
credentialId: credential.id,
|
|
publicKey: Buffer.from(credential.publicKey).toString('base64'),
|
|
counter: credential.counter,
|
|
});
|
|
|
|
return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp });
|
|
};
|
|
passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost);
|
|
|
|
// Generate authentication options for signing in with passkey
|
|
const passkeyRouterGet: Handler = async (ctx) => {
|
|
const { email } = ctx.req.param();
|
|
const origin = ctx.get('origin') as string;
|
|
const rpId = getRpId(origin);
|
|
|
|
const dbUser = await getUserByEmail(email!);
|
|
if (!dbUser) throw errors.NOT_FOUND('User not found');
|
|
|
|
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID: rpId,
|
|
allowCredentials: passkeys.map((p) => ({
|
|
id: p.credentialId!,
|
|
})),
|
|
userVerification: 'preferred',
|
|
});
|
|
|
|
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
|
|
return ctx.json(options);
|
|
};
|
|
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
|
|
|
|
// Verify passkey authentication and issue token
|
|
const passkeyRouterPostVerify: Handler = async (ctx) => {
|
|
if (isLockdown()) {
|
|
noteBlocked('passkey login');
|
|
throw errors.UNAUTHORIZED();
|
|
} // duress lockdown blocks passkey logins too
|
|
const { email } = ctx.req.param();
|
|
const origin = ctx.get('origin') as string;
|
|
const rpId = getRpId(origin);
|
|
const response = ctx.get('body') as AuthenticationResponseJSON;
|
|
|
|
const dbUser = await getUserByEmail(email!);
|
|
if (!dbUser) throw errors.UNAUTHORIZED();
|
|
|
|
const storedChallenge = await consumeChallenge(dbUser.id, origin);
|
|
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
|
|
|
// Find the passkey being used
|
|
const dbPasskey = await getPasskeyByCredentialId(dbUser.id, response.id);
|
|
|
|
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
|
|
|
|
const verification = await verifyAuthenticationResponse({
|
|
response,
|
|
expectedChallenge: storedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpId,
|
|
credential: {
|
|
id: dbPasskey.credentialId!,
|
|
publicKey: Buffer.from(dbPasskey.publicKey, 'base64'),
|
|
counter: dbPasskey.counter,
|
|
},
|
|
});
|
|
|
|
if (!verification.verified) throw errors.BAD_CREDENTIALS();
|
|
|
|
// Update counter to prevent replay attacks
|
|
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
|
|
|
|
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
|
|
|
const { id, name, username, role } = dbUser;
|
|
const token = await sign({
|
|
id,
|
|
email,
|
|
name,
|
|
username,
|
|
role,
|
|
passkeys: passkeys.length,
|
|
});
|
|
|
|
return ctx.json({
|
|
token,
|
|
user: {
|
|
id,
|
|
email,
|
|
name,
|
|
username,
|
|
role,
|
|
passkeys: passkeys.length,
|
|
},
|
|
});
|
|
};
|
|
passkeyRouter.post('/verify/:email', passkeyRateLimiter, passkeyRouterPostVerify);
|