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:
parent
ddde700494
commit
964fe908a6
17 changed files with 398 additions and 197 deletions
|
|
@ -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;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
58
src/@shared/src/utils/index.ts
Normal file
58
src/@shared/src/utils/index.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue