254 lines
7.0 KiB
TypeScript
254 lines
7.0 KiB
TypeScript
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import {
|
|
generateAuthenticationOptions,
|
|
generateRegistrationOptions,
|
|
RegistrationResponseJSON,
|
|
VerifiedAuthenticationResponse,
|
|
verifyAuthenticationResponse,
|
|
verifyRegistrationResponse,
|
|
} from "@simplewebauthn/server";
|
|
import { isoUint8Array } from "@simplewebauthn/server/helpers";
|
|
import { getUserByEmail } from "../user/user.service";
|
|
import { createSession } from "../auth/auth.service";
|
|
import { generateOTP } from "../utils/id";
|
|
import { sendMail } from "../utils/mail";
|
|
|
|
const rpID = process.env.SERVER_DOMAIN;
|
|
const rpName = "Quicker Permits";
|
|
const origin = `http://${rpID}:3000`;
|
|
|
|
export async function webAuthnRoutes(fastify: FastifyInstance) {
|
|
// Registration request
|
|
fastify.post<{ Body: { email: string } }>(
|
|
"/register/request",
|
|
{
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
properties: {
|
|
email: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (req, res: FastifyReply) => {
|
|
const { email } = req.body;
|
|
|
|
if (!email) {
|
|
return res.code(400).send({ error: "Email is required" });
|
|
}
|
|
|
|
const userInDB = await getUserByEmail(email);
|
|
if (!userInDB) return res.code(400).send({ error: "not allowed" });
|
|
|
|
const otp = generateOTP();
|
|
|
|
const userId = userInDB.id;
|
|
const options = await generateRegistrationOptions({
|
|
rpName,
|
|
rpID,
|
|
userID: isoUint8Array.fromUTF8String(userId),
|
|
userName: email,
|
|
attestationType: "none",
|
|
excludeCredentials: userInDB.passKeys.map((cred) => ({
|
|
id: cred.credentialID,
|
|
type: "public-key",
|
|
transports: cred.transports,
|
|
})),
|
|
});
|
|
|
|
userInDB.challenge = {
|
|
value: options.challenge,
|
|
expiry: new Date(Date.now() + 60 * 5 * 1000),
|
|
};
|
|
|
|
userInDB.otp = {
|
|
value: otp,
|
|
expiry: new Date(Date.now() + 60 * 5 * 1000),
|
|
};
|
|
|
|
await userInDB.save();
|
|
|
|
const sent = await sendMail(
|
|
email,
|
|
"Code for Quicker Permits Authentication",
|
|
`Your code is ${otp}`
|
|
);
|
|
if (!sent) return res.code(500).send({ error: "server error" });
|
|
|
|
return res.send(options);
|
|
}
|
|
);
|
|
|
|
// Registration verification
|
|
fastify.post<{
|
|
Body: {
|
|
email: string;
|
|
code: string;
|
|
attestationResponse: RegistrationResponseJSON;
|
|
};
|
|
}>(
|
|
"/register/verify",
|
|
{
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
properties: {
|
|
email: { type: "string" },
|
|
code: { type: "string" },
|
|
attestationResponse: { type: "object" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (req, res: FastifyReply) => {
|
|
const { email, code, attestationResponse } = req.body;
|
|
|
|
const userInDB = await getUserByEmail(email);
|
|
if (!userInDB) return res.code(400).send({ error: "not allowed" });
|
|
|
|
const challenge = userInDB.challenge;
|
|
const requiredOTP = userInDB.otp;
|
|
|
|
if (!challenge || !requiredOTP)
|
|
return res.code(400).send({ error: "not allowed" });
|
|
|
|
if (new Date() > challenge.expiry || new Date() > requiredOTP.expiry)
|
|
return res.code(400).send({ error: "challenge expired" });
|
|
|
|
if (code != requiredOTP.value)
|
|
return res.code(400).send({ error: "invalid code" });
|
|
|
|
try {
|
|
const verification = await verifyRegistrationResponse({
|
|
response: attestationResponse,
|
|
expectedChallenge: challenge.value as string,
|
|
expectedRPID: rpID,
|
|
expectedOrigin: origin,
|
|
});
|
|
|
|
if (!verification.verified) {
|
|
return res.code(400).send({ error: "registration failed" });
|
|
}
|
|
|
|
userInDB.passKeys.push({
|
|
credentialID: verification.registrationInfo.credential.id,
|
|
credentialPublicKey:
|
|
verification.registrationInfo.credential.publicKey,
|
|
counter: verification.registrationInfo.credential.counter,
|
|
transports: attestationResponse.response.transports,
|
|
});
|
|
|
|
await userInDB.save();
|
|
|
|
return res.code(200).send({ success: "registration complete" });
|
|
} catch (error) {
|
|
return res.code(400).send({ error: error.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Authentication request
|
|
fastify.post<{ Body: { email: string } }>(
|
|
"/login/request",
|
|
{
|
|
schema: {
|
|
body: {
|
|
type: "string",
|
|
properties: {
|
|
email: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (req, res) => {
|
|
const { email } = req.body;
|
|
|
|
const userInDB = await getUserByEmail(email);
|
|
|
|
if (!userInDB || userInDB.passKeys.length === 0) {
|
|
return res.code(400).send({ error: "user not registered" });
|
|
}
|
|
|
|
const options: PublicKeyCredentialRequestOptionsJSON =
|
|
await generateAuthenticationOptions({
|
|
rpID,
|
|
allowCredentials: userInDB.passKeys.map((cred) => ({
|
|
id: cred.credentialID,
|
|
type: "public-key",
|
|
transports: cred.transports,
|
|
})),
|
|
userVerification: "preferred",
|
|
});
|
|
|
|
userInDB.challenge.value = options.challenge;
|
|
userInDB.challenge.expiry = new Date(Date.now() + 60 * 5 * 1000);
|
|
|
|
await userInDB.save();
|
|
|
|
return res.send(options);
|
|
}
|
|
);
|
|
|
|
// Authentication Verification
|
|
fastify.post<{ Body: { email: string; assertionResponse: any } }>(
|
|
"/login/verify",
|
|
{
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
properties: {
|
|
email: { type: "string" },
|
|
assertionResponse: { type: "object" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (req, res) => {
|
|
const { email, assertionResponse } = req.body;
|
|
|
|
const userInDB = await getUserByEmail(email);
|
|
if (!userInDB) return res.code(400).send({ error: "not allowed" });
|
|
|
|
const challenge = userInDB.challenge;
|
|
if (!challenge) return res.code(400).send({ error: "not allowed" });
|
|
|
|
if (new Date() > challenge.expiry)
|
|
return res.code(400).send({ error: "challenge expired" });
|
|
|
|
try {
|
|
const credential = userInDB.passKeys.find(
|
|
(cred) => cred.credentialID === assertionResponse.id
|
|
);
|
|
|
|
if (!credential)
|
|
return res.code(400).send({ error: "credential not found" });
|
|
|
|
const verification: VerifiedAuthenticationResponse =
|
|
await verifyAuthenticationResponse({
|
|
response: assertionResponse,
|
|
expectedChallenge: userInDB.challenge.value as string,
|
|
expectedRPID: rpID,
|
|
expectedOrigin: origin,
|
|
credential: credential,
|
|
});
|
|
|
|
if (!verification.verified)
|
|
return res.code(400).send({ error: "Authentication failed" });
|
|
|
|
credential.counter = verification.authenticationInfo.newCounter;
|
|
|
|
const newSession = await createSession(
|
|
userInDB.id,
|
|
req.ip,
|
|
req.headers["user-agent"]
|
|
);
|
|
|
|
res.send({ session_token: newSession.sid });
|
|
} catch (error) {
|
|
res.code(400).send({ error: (error as Error).message });
|
|
}
|
|
}
|
|
);
|
|
}
|