feat(auth): Base auth with plugins

- Add fastify to protect routes plugins (requireAuth: true)
- Simple Demo to show regular password auth (no 2FA/OTP nor remote auth)
- Currently supports: login, logout, signin
- OTP workflow should work, not tested
- Fixed convention for docker volumes (now all placed in /volumes/<name>)
This commit is contained in:
Maieul BOYER 2025-08-30 23:23:34 +02:00 committed by Maix0
parent ddde700494
commit 964fe908a6
17 changed files with 398 additions and 197 deletions

View file

@ -1,67 +1,119 @@
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";
import fastifyJwt from "@fastify/jwt";
import fp from "fastify-plugin";
import { FastifyPluginAsync, preValidationAsyncHookHandler } from "fastify";
import { Static, Type } from "@sinclair/typebox";
import { UserId } from "@shared/database/mixin/user";
import { useDatabase } from "@shared/database";
import { makeResponse } from "@shared/utils";
const kRouteAuthDone = Symbol("shared-route-auth-done");
const kRouteAuthDone = Symbol('shared-route-auth-done');
type AuthedUser = {
id: UserId;
name: string;
};
declare module 'fastify' {
declare module "fastify" {
export interface FastifyInstance {
signJwt: (kind: "auth" | "otp", who: string) => string;
[s: symbol]: boolean;
}
export interface FastifyRequest {
authUser?: user.UserId;
authUser?: AuthedUser;
}
export interface FastifyContextConfig {
requireAuth?: boolean,
requireAuth?: boolean;
}
}
export const Otp = OTP;
let jwtAdded = false;
export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
if (jwtAdded) jwtAdded = true;
let env = process.env.JWT_SECRET;
if (env === undefined || env === null)
throw "JWT_SECRET is not defined"
void fastify.register(fastifyJwt, {
secret: env,
decode: { complete: false },
});
void fastify.decorate("signJwt", (kind, who) => fastify.jwt.sign({ kind, who, createdAt: Date.now() }))
if (env === undefined || env === null) throw "JWT_SECRET is not defined";
if (!fastify.hasDecorator("signJwt")) {
void fastify.decorate("signJwt", (kind, who) =>
fastify.jwt.sign({ kind, who, createdAt: Date.now() }),
);
void fastify.register(fastifyJwt, {
secret: env,
decode: { complete: false },
});
}
});
export const JwtType = Type.Object({
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" })
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" })
createdAt: Type.Integer({
description: "Unix timestamp of when the token as been created at",
}),
});
export type JwtType = Static<typeof JwtType>;
let authAdded = false;
export const authPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
if (authAdded) return void console.log("skipping");
authAdded = true;
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 || []),];
if (!fastify.hasRequestDecorator("authUser"))
fastify.decorateRequest("authUser", undefined);
fastify.addHook("onRoute", (routeOpts) => {
if (
routeOpts.config?.requireAuth &&
!(routeOpts as any)[kRouteAuthDone]
) {
let f: preValidationAsyncHookHandler = async function(req, res) {
try {
if (req.cookies.token === undefined)
return res
.clearCookie("token")
.send(
JSON.stringify(makeResponse("notLoggedIn", "auth.noCookie")),
);
let tok = this.jwt.verify<JwtType>(req.cookies.token);
if (tok.kind != "auth")
return res
.clearCookie("token")
.send(
JSON.stringify(makeResponse("notLoggedIn", "auth.invalidKind")),
);
let user = this.db.getUserFromName(tok.who);
if (user === null)
return res
.clearCookie("token")
.send(
JSON.stringify(makeResponse("notLoggedIn", "auth.noUser")),
);
req.authUser = { id: user.id, name: tok.who };
} catch {
return res
.clearCookie("token")
.send(JSON.stringify(makeResponse("notLoggedIn", "auth.invalid")));
}
};
if (!routeOpts.preValidation) {
routeOpts.preValidation = [f];
} else if (Array.isArray(routeOpts.preValidation)) {
routeOpts.preValidation.push(f);
} else {
routeOpts.preValidation = [routeOpts.preValidation, f];
}
(routeOpts as any)[kRouteAuthDone] = true;
}
})
})
});
});

View file

@ -16,16 +16,21 @@ declare module 'fastify' {
}
}
let dbAdded = false;
export const useDatabase = fp<FastifyPluginAsync>(async function(
f: FastifyInstance,
_options: {}) {
if (dbAdded)
return;
dbAdded = true;
let path = process.env.DATABASE_DIR;
if (path === null || path === undefined)
throw "env `DATABASE_DIR` not defined";
f.log.info(`Opening database with path: ${path}/database.db`)
let db: Database = new DbImpl(`${path}/database.db`) as Database;
f.decorate('db', db);
if (!f.hasDecorator("db"))
f.decorate('db', db);
});
export * as user from "./mixin/user"

View file

@ -0,0 +1,58 @@
import { Type } from "@sinclair/typebox";
/**
* @description Represent a message key
* Used for translation of text, taken from a prebuilt dictionary
* Format: `category.sub.desc`
*
* @example `login.failure.invalid`
* @example `login.failure.missingPassword`
* @example `login.failure.missingUser`
* @example `signin.success`
* @example `pong.you.lost`
*/
export type MessageKey = string;
export type ResponseBase<T = {}> = {
kind: string,
msg: MessageKey,
payload?: T,
}
/**
* @description Builds a response from a `kind`, `key` and an arbitrary payload
*
* * USE THIS FUNCTION TO ALLOW GREPING :) *
*
* @example makeResponse("failure", "login.failure.invalid")
* @example makeResponse("success", "login.success", { token: "supersecrettoken" })
*/
export function makeResponse<T = {}>(kind: string, key: MessageKey, payload?: T): ResponseBase<T> {
console.log(`making response {kind: ${JSON.stringify(kind)}; key: ${JSON.stringify(key)}}`)
return { kind, msg: key, payload }
}
/**
* @description Create a typebox Type for a response.
*
* @example typeResponse("failure", ["login.failure.invalid", "login.failure.generic", "login.failure.missingPassword"])
* @example typeResponse("otpRequired", "login.otpRequired", { token: Type.String() })
* @example typeResponse("success", "login.success", { token: Type.String() })
*/
export function typeResponse(kind: string, key: MessageKey | MessageKey[], payload?: any): any {
let tKey;
if (key instanceof Array) {
tKey = Type.Union(key.map(l => Type.Const(l)));
} else {
tKey = Type.Const(key);
}
const Ty = {
kind: Type.Const(kind),
msg: tKey,
};
if (payload !== undefined)
Object.assign(Ty, { payload: Type.Object(payload) })
return Type.Object(Ty)
}