add webauthn routes
This commit is contained in:
@@ -16,8 +16,9 @@ import { rtsSchemas } from "./rts/rts.schema";
|
||||
import { taskSchemas } from "./task/task.schema";
|
||||
import { notificationSchemas } from "./notification/notification.schema";
|
||||
import { noteSchemas } from "./note/note.schema";
|
||||
import { webAuthnRoutes } from "./webauthn/webauthn.route";
|
||||
|
||||
const app = fastify({ logger: true });
|
||||
const app = fastify({ logger: true, trustProxy: true });
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
return { status: "OK" };
|
||||
@@ -33,6 +34,7 @@ app.register(cors, {
|
||||
});
|
||||
app.register(multipart, { limits: { fileSize: 50000000 } });
|
||||
app.register(authRoutes, { prefix: "/auth" });
|
||||
app.register(webAuthnRoutes, { prefix: "/webauthn" });
|
||||
app.register(routes, { prefix: "/api/v1" });
|
||||
|
||||
for (const schema of [
|
||||
|
||||
@@ -28,6 +28,21 @@ const userSchema = new mongoose.Schema({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
passKeys: [],
|
||||
challenge: new mongoose.Schema(
|
||||
{
|
||||
value: String,
|
||||
expiry: Date,
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
otp: new mongoose.Schema(
|
||||
{
|
||||
value: String,
|
||||
expiry: Date,
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
createdAt: Date,
|
||||
createdBy: mongoose.Types.ObjectId,
|
||||
lastLogin: Date,
|
||||
|
||||
@@ -17,3 +17,13 @@ export async function generateToken(): Promise<string> {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function generateOTP() {
|
||||
let otp = "";
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
otp += crypto.randomInt(10);
|
||||
}
|
||||
|
||||
return parseInt(otp);
|
||||
}
|
||||
|
||||
59
src/utils/mail.ts
Normal file
59
src/utils/mail.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import axios from "axios";
|
||||
|
||||
let token = {
|
||||
value: "",
|
||||
expiry: new Date(),
|
||||
};
|
||||
|
||||
export async function getToken() {
|
||||
if (token.value != "" && new Date() < token.expiry) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
const res = await axios({
|
||||
url: `https://accounts.zoho.com/oauth/v2/token?refresh_token=${process.env.ZOHO_REFRESH_TOKEN}&grant_type=refresh_token&client_id=${process.env.ZOHO_CLIENT_ID}&client_secret=${process.env.ZOHO_CLIENT_SECRET}`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (res.data.status && res.data.status.code != 200) {
|
||||
console.dir(res.data, { depth: null });
|
||||
throw "error fetching token";
|
||||
}
|
||||
|
||||
token.value = res.data.access_token;
|
||||
token.expiry = new Date(Date.now() + res.data.expires_in * 1000);
|
||||
|
||||
return token.value;
|
||||
}
|
||||
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string
|
||||
): Promise<Boolean> {
|
||||
try {
|
||||
const token = await getToken();
|
||||
|
||||
const res = await axios({
|
||||
url: `https://mail.zoho.com/api/accounts/${process.env.ZOHO_ACCOUNT_ID}/messages`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Zoho-oauthtoken ${token}`,
|
||||
},
|
||||
data: {
|
||||
fromAddress: "akhil.reddy@qualyval.com",
|
||||
toAddress: to,
|
||||
subject,
|
||||
content: body,
|
||||
mailFormat: "plaintext",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.response) console.log(err.response.data);
|
||||
else console.log(err);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
213
src/webauthn/webauthn.route.ts
Normal file
213
src/webauthn/webauthn.route.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post<{
|
||||
Body: {
|
||||
email: string;
|
||||
code: string;
|
||||
attestationResponse: RegistrationResponseJSON;
|
||||
};
|
||||
}>("/register/verify", 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 });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post<{ Body: { email: string } }>(
|
||||
"/login/request",
|
||||
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 (Step 4)
|
||||
fastify.post<{ Body: { email: string; assertionResponse: any } }>(
|
||||
"/login/verify",
|
||||
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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user