feat(auth): working plugin

This commit is contained in:
Maieul BOYER 2025-08-27 22:25:44 +02:00 committed by Maix0
parent c545499c73
commit ddde700494
5 changed files with 140 additions and 31 deletions

View file

@ -9,7 +9,9 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/jwt": "^9.1.0", "@fastify/jwt": "^9.1.0",
"@sinclair/typebox": "^0.34.40",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^11.10.0", "better-sqlite3": "^11.10.0",
@ -17,7 +19,6 @@
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"joi": "^18.0.0", "joi": "^18.0.0",
"otp": "^1.1.2", "otp": "^1.1.2",
"rfc4648": "^1.5.4",
"uuidv7": "^1.0.2" "uuidv7": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,16 +0,0 @@
//! Anything in this file shouldn't be used...
//!
//! This file is here because it is easier to share code in here.
import type { Database } from "@shared/database";
import type { UserId } from "../database/mixin/user";
import OTP, * as OtpModules from "otp";
let secret = "JKZSGXRCBP3UHOFVDVLYQ3W43IZH3D76";
let otp = new OTP({ secret, name: "test" });
console.log(`${otp.totpURL}`);
console.log(new URL(otp.totpURL));
setInterval(() => {
console.log(`${otp.totp(Date.now())}`);
}, 999);

View file

@ -1,10 +1,26 @@
import fastifyJwt from "@fastify/jwt";
import { FastifyPluginAsync } from "fastify";
import fp from 'fastify-plugin'
import { user } from "@shared/database"
import OTP from "otp"; import OTP from "otp";
import fastifyJwt from "@fastify/jwt";
import fp from 'fastify-plugin'
import { FastifyPluginAsync, FastifyRequest } from "fastify";
import { Static, Type } from "@sinclair/typebox"
import { useDatabase, user } from "@shared/database"
import cookie from "@fastify/cookie";
const kRouteAuthDone = Symbol('shared-route-auth-done');
declare module 'fastify' {
export interface FastifyInstance {
signJwt: (kind: "auth" | "otp", who: string) => string;
}
export interface FastifyRequest {
authUser?: user.UserId;
}
export interface FastifyContextConfig {
requireAuth?: boolean,
}
}
export const Otp = OTP; export const Otp = OTP;
export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => { export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
let env = process.env.JWT_SECRET; let env = process.env.JWT_SECRET;
@ -14,14 +30,38 @@ export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
secret: env, secret: env,
decode: { complete: false }, decode: { complete: false },
}); });
void fastify.decorate("signJwt", (kind, who) => fastify.jwt.sign({ kind, who, createdAt: Date.now() }))
}); });
export type JwtClaims = { export const JwtType = Type.Object({
id: user.UserId, kind: Type.Union([
}; Type.Const("otp", { description: "the token is only valid for otp call" }),
Type.Const("auth", { description: "the token is valid for authentication" })
]),
who: Type.String({ description: "the login of the user" }),
createdAt: Type.Integer({ description: "Unix timestamp of when the token as been created at" })
});
export * as _inner from "./_inner.js"; export type JwtType = Static<typeof JwtType>;
export const otpPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
fastify.decorate('otp', {}, ["db"]); export const authPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
await fastify.register(useDatabase as any, {});
await fastify.register(jwtPlugin as any, {});
await fastify.register(cookie);
fastify.addHook('onRoute', (routeOpts) => {
if (routeOpts.config?.requireAuth) {
routeOpts.preValidation = [function(req, res) {
if (req.cookies.token === undefined)
return res.clearCookie("token").send({ kind: "notLoggedIn", msg_key: "" })
let tok = this.jwt.verify<JwtType>(req.cookies.token);
if (tok.kind != "auth")
return res.clearCookie("token").send({ kind: "notLoggedIn", msg_key: "" })
let user = this.db.getUserFromName(tok.who);
if (user === null)
return res.clearCookie("token").send({ kind: "notLoggedIn", msg_key: "" })
req.authUser = user.id;
}, ...(routeOpts.preValidation as any || []),];
}
})
}) })

View file

@ -63,13 +63,11 @@ const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
if (user.otp !== null) { if (user.otp !== null) {
// yes -> we ask them to fill it, // yes -> we ask them to fill it,
// send them somehting to verify that they indeed passed throught the user+password phase // send them somehting to verify that they indeed passed throught the user+password phase
let otpToken = this.jwt.sign({ kind: "otp", user: user.name, createAt: Date.now() / 1000 }); return { kind: "otpRequired", msg_key: "login.otpRequired", token: this.signJwt("otp", user.name) };
return { kind: "otpRequired", msg_key: "login.otpRequired", token: otpToken };
} }
// every check has been passed, they are now logged in, using this token to say who they are... // every check has been passed, they are now logged in, using this token to say who they are...
let userToken = this.jwt.sign({ kind: "auth", user: user.name, createAt: Date.now() / 1000 }); return { kind: "success", msg_key: "login.success", token: this.signJwt("auth", user.name) }
return { kind: "success", msg_key: "login.success", token: userToken }
} }
catch { catch {
return { kind: "failed", msg_key: "login.failed.generic" }; return { kind: "failed", msg_key: "login.failed.generic" };

View file

@ -0,0 +1,86 @@
import { FastifyPluginAsync } from "fastify";
import { Static, Type } from "@sinclair/typebox";
import { JwtType, Otp } from "@shared/auth";
export const OtpReq = Type.Object({
token: Type.String({ description: "The token given at the login phase" }),
code: Type.String({ description: "The OTP given by the user" }),
});
export type OtpReq = Static<typeof OtpReq>;
export const OtpRes = Type.Union([
Type.Object({
kind: Type.Const("failed"),
msg_key: Type.Union([
Type.Const("otp.failed.generic"),
Type.Const("otp.failed.invalid"),
Type.Const("otp.failed.timeout"),
]),
}),
Type.Object({
kind: Type.Const("success"),
msg_key: Type.Const("otp.success"),
token: Type.String({ description: "The JWT token" }),
}),
]);
export type OtpRes = Static<typeof OtpRes>;
const OTP_TOKEN_TIMEOUT_SEC = 120;
const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get<{ Body: OtpReq }>(
"/whoami",
{ schema: { body: OtpReq, response: { "2xx": OtpRes } } },
async function(req, res) {
try {
const { token, code } = req.body;
// lets try to decode+verify the jwt
let dJwt = this.jwt.verify<JwtType>(token);
// is the jwt a valid `otp` jwt ?
if (dJwt.kind != "otp")
// no ? fuck off then
return { kind: "failed", msg_key: "otp.failed.invalid" };
// is it too old ?
if (dJwt.createdAt + OTP_TOKEN_TIMEOUT_SEC * 1000 > Date.now())
// yes ? fuck off then, redo the password
return { kind: "failed", msg_key: "otp.failed.timeout" };
// get the Otp sercret from the db
let otpSecret = this.db.getUserFromName(dJwt.who)?.otp;
if (otpSecret === null)
// oops, either no user, or user without otpSecret
// fuck off
return { kind: "failed", msg_key: "otp.failed.invalid" };
// good lets now verify the token you gave us is the correct one...
let otpHandle = new Otp({ secret: otpSecret });
let now = Date.now();
const tokens = [
// we also get the last code, to mitiage the delay between client<->server roundtrip...
otpHandle.totp(now - 30 * 1000),
// this is the current token :)
otpHandle.totp(now),
];
// checking if any of the array match
if (tokens.some((c) => c === code))
// they do !
// gg you are now logged in !
return {
kind: "success",
msg_key: "otp.success",
token: this.signJwt("auth", dJwt.who),
};
} catch {
return { kind: "failed", msg_key: "otp.failed.generic" };
}
},
);
};
export default route;