From 51309ee7ebf1810b80ab007f2622d8c4ff1d53dc Mon Sep 17 00:00:00 2001 From: David Ludwig Date: Tue, 13 Apr 2021 23:50:42 -0500 Subject: [PATCH] Added server-side middleware. Added JWT auth token middleware. Added basic login functionality --- package.json | 2 + src/app/auth.ts | 19 +++++ src/app/routes/index.ts | 57 ++++++++++++- src/app/validation.ts | 0 src/app/views/Login.vue | 3 + src/server/Application.ts | 10 +++ src/server/services/WebServer/index.ts | 46 ++++++----- .../services/WebServer/middleware/auth.ts | 47 +++++++++++ .../services/WebServer/middleware/index.ts | 23 ++++++ .../WebServer/routes/RouteRegisterFactory.ts | 79 +++++++++++++++++++ src/server/services/WebServer/routes/auth.ts | 30 +++++-- yarn.lock | 19 +++++ 12 files changed, 306 insertions(+), 29 deletions(-) create mode 100644 src/app/auth.ts delete mode 100644 src/app/validation.ts create mode 100644 src/server/services/WebServer/middleware/auth.ts create mode 100644 src/server/services/WebServer/middleware/index.ts create mode 100644 src/server/services/WebServer/routes/RouteRegisterFactory.ts diff --git a/package.json b/package.json index f15c8aa..4521828 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,13 @@ "bcrypt": "^5.0.1", "discord.js": "^12.5.3", "fastify": "^3.14.1", + "fastify-cookie": "^5.3.0", "fastify-formbody": "^5.0.0", "fastify-http-proxy": "^5.0.0", "fastify-multipart": "^4.0.3", "fastify-static": "^4.0.1", "jsonwebtoken": "^8.5.1", + "jwt-decode": "^3.1.2", "mysql": "^2.18.1", "node-ipc": "^9.1.4", "tvdb-v4": "^1.0.0", diff --git a/src/app/auth.ts b/src/app/auth.ts new file mode 100644 index 0000000..b95cff9 --- /dev/null +++ b/src/app/auth.ts @@ -0,0 +1,19 @@ +import jwtDecode from "jwt-decode"; + +let token: string | null; + +export function isAuthenticated() { + return Boolean(token); +} + +export function decodeToken(token: string) { + return jwtDecode(token); +} + +export function forgetToken() { + localStorage.removeItem("jwt"); +} + +export function storeToken(token: string) { + localStorage.setItem("jwt", token); +} diff --git a/src/app/routes/index.ts b/src/app/routes/index.ts index db3d766..40e361c 100644 --- a/src/app/routes/index.ts +++ b/src/app/routes/index.ts @@ -1,20 +1,46 @@ -import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; +import { createRouter, createWebHistory, NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router"; +import * as auth from "../auth"; + +/** + * Check if the user is a guest; redirect otherwise + */ +function requiresGuest(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) { + if (auth.isAuthenticated()) { + next({ name: "Home" }); + return; + } + next(); +} + +/** + * Check if the user is authenticated; redirect otherwise + */ +function requiresAuth(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) { + if (!auth.isAuthenticated()) { + next({ name: "Login" }); + return; + } + next(); +} const routes: RouteRecordRaw[] = [ { path: "/", name: "Home", - component: () => import("../views/Home.vue") + component: () => import("../views/Home.vue"), + beforeEnter: requiresAuth }, { path: "/login", name: "Login", - component: () => import("../views/Login.vue") + component: () => import("../views/Login.vue"), + beforeEnter: requiresGuest }, { path: "/register", name: "Register", component: () => import("../views/Register.vue"), + beforeEnter: requiresGuest } ]; @@ -23,4 +49,29 @@ const router = createRouter({ routes, }); +/** + * Guard authentication and guest routes + */ +router.beforeEach((to, from, next) => { + if (to.matched.some(record => record.meta.requiresAuth)) { + if (localStorage.getItem("jwt") == null) { + next({ name: "Login" }); + return; + } + let user = JSON.parse(localStorage.getItem("user")); + if (to.matched.some(record => record.meta.requiresAdmin)) { + if (!user.isAdmin) { + next({ name: "Home" }); + return; + } + } + } else if (to.matched.some(record => record.meta.guest)) { + if (localStorage.getItem("jwt") != null) { + next({ name: "Home" }); + return; + } + } + next(); +}); + export default router; diff --git a/src/app/validation.ts b/src/app/validation.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/views/Login.vue b/src/app/views/Login.vue index a71216d..772c896 100644 --- a/src/app/views/Login.vue +++ b/src/app/views/Login.vue @@ -27,6 +27,7 @@ import { defineComponent } from "vue"; import CheckBox from "../components/CheckBox.vue"; import TextBox from "../components/TextBox.vue"; +import * as auth from "../auth"; export default defineComponent({ components: { @@ -69,6 +70,8 @@ export default defineComponent({ } return; } + console.log("Successful login", body.token); + auth.storeToken(body.token); this.$router.push({ name: "Login" }); }) .catch(e => { diff --git a/src/server/Application.ts b/src/server/Application.ts index 8e0faef..1082da0 100644 --- a/src/server/Application.ts +++ b/src/server/Application.ts @@ -18,6 +18,8 @@ async function createRegisterToken() { return token; } +let instance: Application; + /** * The main application class */ @@ -53,10 +55,18 @@ export default class Application */ protected torrent!: TorrentClientIpc; + /** + * Return the current application instance + */ + public static instance() { + return instance; + } + /** * Create a new application instance */ public constructor(appKey: string) { + instance = this; this.APP_KEY = appKey; this.services = [ this.database = new Database(this), diff --git a/src/server/services/WebServer/index.ts b/src/server/services/WebServer/index.ts index 2045d69..fa4df1a 100644 --- a/src/server/services/WebServer/index.ts +++ b/src/server/services/WebServer/index.ts @@ -1,4 +1,5 @@ import fastify from "fastify"; +import fastifyCookie from "fastify-cookie"; import fastifyFormBody from "fastify-formbody"; import fastifyHttpProxy from "fastify-http-proxy"; import fastifyMultipart from "fastify-multipart"; @@ -8,6 +9,7 @@ import Service from "../Service"; import { join } from "path"; import routes from "./routes"; import "./validators"; +import RouteRegisterFactory, { RouteFactory } from "./routes/RouteRegisterFactory"; export default class WebServer extends Service { @@ -19,7 +21,7 @@ export default class WebServer extends Service /** * The internal webserver instance */ - protected server: ReturnType; + protected fastify: ReturnType; /** * Create a new webserver instance @@ -27,28 +29,35 @@ export default class WebServer extends Service public constructor(app: Application) { super("Web Server", app); this.port = parseInt(process.env["WEBSERVER_PORT"]); - this.server = fastify(); - this.registerPlugins(); + this.fastify = fastify(); } /** * Register required Fastify plugins */ protected registerPlugins() { - this.server.register(fastifyMultipart, { - limits: { - fileSize: 16*1024*1024, - files: 50 - } - }); - this.server.register(fastifyFormBody); + return Promise.all([ + this.fastify.register(fastifyCookie, { + secret: this.app.APP_KEY + }), + this.fastify.register(fastifyFormBody), + this.fastify.register(fastifyMultipart, { + limits: { + fileSize: 16*1024*1024, + files: 50 + } + }) + ]); } /** * Boot the webserver */ public async boot() { - // Register the available routes + // Install plugins + await this.registerPlugins(); + + // Register the routes this.registerRoutes(); } @@ -57,7 +66,7 @@ export default class WebServer extends Service */ public start() { // Start listening - this.server.listen(this.port, "0.0.0.0"); + this.fastify.listen(this.port, "0.0.0.0"); this.log("Webserver listening on port:", this.port); } @@ -66,7 +75,7 @@ export default class WebServer extends Service */ public async shutdown() { this.log("Webserver shutting down"); - await this.server.close(); + await this.fastify.close(); } // --------------------------------------------------------------------------------------------- @@ -76,8 +85,9 @@ export default class WebServer extends Service */ protected registerRoutes() { this.registerSpaRoutes(); - for (let routeGroup in routes) { - (routes)[routeGroup](this.server, this.app); + let factory = new RouteRegisterFactory(this.fastify, this.app); + for (let group in routes) { + (routes)[group](factory, this.app); } } @@ -91,15 +101,15 @@ export default class WebServer extends Service * NOTE: Static assets may be moved to nginx later... not sure yet */ if (process.env["NODE_ENV"] == "production") { - this.server.register(fastifyStatic, { + this.fastify.register(fastifyStatic, { root: join(__dirname, "../../../public") }); - this.server.setNotFoundHandler((request, reply) => { + this.fastify.setNotFoundHandler((request, reply) => { return reply.sendFile("index.html"); }); } else { console.log("Using Vite proxy"); - this.server.register(fastifyHttpProxy, { + this.fastify.register(fastifyHttpProxy, { upstream: "http://localhost:3001" }); } diff --git a/src/server/services/WebServer/middleware/auth.ts b/src/server/services/WebServer/middleware/auth.ts new file mode 100644 index 0000000..9a01a12 --- /dev/null +++ b/src/server/services/WebServer/middleware/auth.ts @@ -0,0 +1,47 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import Application from "@server/Application"; +import jwt from "jsonwebtoken"; +import { IteratorNext } from "."; + +/** + * Attempt to authenticate a client's JWT token + */ +function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): T | undefined { + // Verify headers + if (!request.headers["authorization"]) { + reply.status(401); + reply.send(); + return; + } + if (!request.headers["authorization"].startsWith("Bearer ")) { + reply.status(400); + reply.send(); + return; + } + // Construct the token string + let token = request.headers["authorization"].slice(7).trim(); + if ((token.match(/\./g)||[]).length < 2) { + token += '.' + (request.cookies.jwt_signature ?? "").trim(); + } + // Decode the token + let decoded: T; + try { + decoded = jwt.verify(token, Application.instance().APP_KEY); + } catch(e) { + reply.status(401); + reply.send(); + return; + } + return decoded; +} + +/** + * Ensure that a valid authentication token is provided + */ +export function auth(request: FastifyRequest, reply: FastifyReply, next: IteratorNext) { + let token = authenticateJwtToken(request, reply); + if (token === undefined) { + return; + } + next(); +} diff --git a/src/server/services/WebServer/middleware/index.ts b/src/server/services/WebServer/middleware/index.ts new file mode 100644 index 0000000..1ef35e1 --- /dev/null +++ b/src/server/services/WebServer/middleware/index.ts @@ -0,0 +1,23 @@ +import { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify"; + +export type HandlerMethod = (request: FastifyRequest, reply: FastifyReply) => void; +export type MiddlewareMethod = (request: FastifyRequest, reply: FastifyReply, next: () => void) => void; +export type IteratorNext = () => void; + +/** + * A route handler that supports middleware methods + */ +export function handleMiddleware(middleware: MiddlewareMethod[], handler: RouteHandlerMethod) { + return (async (request: FastifyRequest, reply: FastifyReply) => { + var iterator = middleware[Symbol.iterator](); + var next = async () => { + let result = iterator.next(); + if (result.done) { + (handler)(request, reply); + return; + } + result.value(request, reply, next); + }; + next(); + }); +} diff --git a/src/server/services/WebServer/routes/RouteRegisterFactory.ts b/src/server/services/WebServer/routes/RouteRegisterFactory.ts new file mode 100644 index 0000000..5f242f8 --- /dev/null +++ b/src/server/services/WebServer/routes/RouteRegisterFactory.ts @@ -0,0 +1,79 @@ +import { FastifyInstance, RouteHandlerMethod } from "fastify"; +import Application from "@server/Application"; +import { handleMiddleware, MiddlewareMethod } from "../middleware"; + +export type RouteFactory = ((factory: RouteRegisterFactory) => void) + | ((factory: RouteRegisterFactory, app: Application) => void); + +export default class RouteRegisterFactory +{ + /** + * The application instance + */ + protected readonly app: Application; + + /** + * The Fastify server instance + */ + protected readonly fastify: FastifyInstance; + + /** + * The list of middleware + */ + protected middleware: MiddlewareMethod[] = []; + + /** + * The current route prefix + */ + protected pathPrefix: string = ""; + + /** + * Create a new route factory + */ + public constructor(fastify: FastifyInstance, app: Application) { + this.app = app; + this.fastify = fastify; + } + + /** + * Register a group of routes under a common prefix and middleware + */ + public prefix(prefix: string, middleware: MiddlewareMethod[], factory: RouteFactory) { + let prefixBackup = this.pathPrefix; + this.pathPrefix = prefix; + this.group(middleware, factory); + this.pathPrefix = prefixBackup; + } + + /** + * Register a group of routes under common middleware + */ + public group(middleware: MiddlewareMethod[], factory: RouteFactory) { + let middlewareBackup = this.middleware; + this.middleware = this.middleware.concat(middleware); + factory(this, this.app); + this.middleware = middlewareBackup; + } + + /** + * Register a GET request + */ + public get(path: string, handler: RouteHandlerMethod): void; + public get(path: string, middleware: MiddlewareMethod[], handler: RouteHandlerMethod): void; + public get(path: string, middleware: RouteHandlerMethod|MiddlewareMethod[], handler?: RouteHandlerMethod) { + handler = (handler ?? middleware); + middleware = (middleware instanceof Array) ? this.middleware.concat(middleware) : this.middleware; + this.fastify.get(`${this.pathPrefix}${path}`, handleMiddleware(middleware, handler)); + } + + /** + * Register a POST request + */ + public post(path: string, handler: RouteHandlerMethod): void; + public post(path: string, middleware: MiddlewareMethod[], handler: RouteHandlerMethod): void; + public post(path: string, middleware: RouteHandlerMethod|MiddlewareMethod[], handler?: RouteHandlerMethod) { + handler = (handler ?? middleware); + middleware = (middleware instanceof Array) ? this.middleware.concat(middleware) : this.middleware; + this.fastify.post(`${this.pathPrefix}${path}`, handleMiddleware(middleware, handler)); + } +} diff --git a/src/server/services/WebServer/routes/auth.ts b/src/server/services/WebServer/routes/auth.ts index c305f12..abbf933 100644 --- a/src/server/services/WebServer/routes/auth.ts +++ b/src/server/services/WebServer/routes/auth.ts @@ -1,20 +1,27 @@ -import Application from "@server/Application"; -import { FastifyInstance } from "fastify"; import bcrypt from "bcrypt"; +import Application from "@server/Application"; import { RegisterToken, User } from "@server/database/entities"; import LoginRequest, { ILoginFormBody } from "../requests/LoginRequest"; import RegisterRequest, { IRegisterFormBody } from "../requests/RegisterRequest"; import handle from "../requests"; import jwt from "jsonwebtoken"; +import { auth } from "../middleware/auth"; +import { handleMiddleware } from "../middleware"; +import RouteRegisterFactory from "./RouteRegisterFactory"; /** * Register authentication routes */ -export default function register(server: FastifyInstance, app: Application) { +export default function register(factory: RouteRegisterFactory, app: Application) { + + factory.get("/auth/verify", handleMiddleware([auth], (request, reply) => { + console.log("Authentication has been verified"); + reply.send({ status: "success" }); + })); // Login --------------------------------------------------------------------------------------- - server.post("/auth/login", handle([LoginRequest], async (request, reply) => { + factory.post("/auth/login", handle([LoginRequest], async (request, reply) => { let body = request.body; let user = await User.findOne({ email: body.email }); if (user === undefined || !(await bcrypt.compare(body.password, user.password))) { @@ -23,7 +30,14 @@ export default function register(server: FastifyInstance, app: Application) { return } let token = jwt.sign({ id: (user).id }, app.APP_KEY, { expiresIn: 60*60*24 }); - reply.send({ "status": "success" }); + let [header, payload, signature] = token.split('.'); + reply.setCookie("jwt_signature", signature, { + httpOnly: true, + sameSite: true, + secure: true + }); + console.log(signature); + reply.send({ status: "success", token: `${header}.${payload}` }); })); // Registration -------------------------------------------------------------------------------- @@ -31,7 +45,7 @@ export default function register(server: FastifyInstance, app: Application) { /** * Register a user */ - server.post("/auth/register", handle([RegisterRequest], async (request, reply) => { + factory.post("/auth/register", handle([RegisterRequest], async (request, reply) => { let body = request.body; let user = new User(); user.isAdmin = false; @@ -46,7 +60,7 @@ export default function register(server: FastifyInstance, app: Application) { /** * Validate a registration token */ - server.post("/auth/register/validate_token/:token", async (request, reply) => { + factory.post("/auth/register/validate_token/:token", async (request, reply) => { let token: string = (request.params)["token"]; if (!(await RegisterToken.isValid(token))) { reply.status(422); @@ -59,7 +73,7 @@ export default function register(server: FastifyInstance, app: Application) { /** * Check if an email address is available (assumes it's correct) */ - server.post("/auth/register/available_email/:email", async (request, reply) => { + factory.post("/auth/register/available_email/:email", async (request, reply) => { let email: string = (request.params)["email"].trim(); if (!Boolean(email) || await User.count({email}) != 0) { reply.status(422); diff --git a/yarn.lock b/yarn.lock index 1e4603f..50ff59f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -796,6 +796,11 @@ content-disposition@^0.5.3: dependencies: safe-buffer "5.1.2" +cookie-signature@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.1.0.tgz#cc94974f91fb9a9c1bb485e95fc2b7f4b120aff2" + integrity sha512-Alvs19Vgq07eunykd3Xy2jF0/qSNv2u7KDbAek9H5liV1UMijbqFs5cycZvv5dVsvseT/U4H8/7/w8Koh35C4A== + cookie@^0.4.0: version "0.4.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" @@ -1155,6 +1160,15 @@ fast-safe-stringify@^2.0.7: resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== +fastify-cookie@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/fastify-cookie/-/fastify-cookie-5.3.0.tgz#412846d755a5a22a981cbd924a13ae489a5df610" + integrity sha512-aQpeddzkFlfsWZfSzjsgkU2S8wOUNiKehMaOcKNm48Sy14R57G5rd6adlZtPd1/zNdNcEaZAFsz3d4NrmJVBpA== + dependencies: + cookie "^0.4.0" + cookie-signature "^1.1.0" + fastify-plugin "^3.0.0" + fastify-error@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/fastify-error/-/fastify-error-0.3.0.tgz#08866323d521156375a8be7c7fcaf98df946fafb" @@ -1785,6 +1799,11 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +jwt-decode@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" + integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"