Browse Source

Added server-side middleware. Added JWT auth token middleware. Added basic login functionality

master
David Ludwig 4 years ago
parent
commit
51309ee7eb
12 changed files with 306 additions and 29 deletions
  1. +2
    -0
      package.json
  2. +19
    -0
      src/app/auth.ts
  3. +54
    -3
      src/app/routes/index.ts
  4. +0
    -0
      src/app/validation.ts
  5. +3
    -0
      src/app/views/Login.vue
  6. +10
    -0
      src/server/Application.ts
  7. +28
    -18
      src/server/services/WebServer/index.ts
  8. +47
    -0
      src/server/services/WebServer/middleware/auth.ts
  9. +23
    -0
      src/server/services/WebServer/middleware/index.ts
  10. +79
    -0
      src/server/services/WebServer/routes/RouteRegisterFactory.ts
  11. +22
    -8
      src/server/services/WebServer/routes/auth.ts
  12. +19
    -0
      yarn.lock

+ 2
- 0
package.json View File

@ -19,11 +19,13 @@
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"discord.js": "^12.5.3", "discord.js": "^12.5.3",
"fastify": "^3.14.1", "fastify": "^3.14.1",
"fastify-cookie": "^5.3.0",
"fastify-formbody": "^5.0.0", "fastify-formbody": "^5.0.0",
"fastify-http-proxy": "^5.0.0", "fastify-http-proxy": "^5.0.0",
"fastify-multipart": "^4.0.3", "fastify-multipart": "^4.0.3",
"fastify-static": "^4.0.1", "fastify-static": "^4.0.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"jwt-decode": "^3.1.2",
"mysql": "^2.18.1", "mysql": "^2.18.1",
"node-ipc": "^9.1.4", "node-ipc": "^9.1.4",
"tvdb-v4": "^1.0.0", "tvdb-v4": "^1.0.0",


+ 19
- 0
src/app/auth.ts View File

@ -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);
}

+ 54
- 3
src/app/routes/index.ts View File

@ -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[] = [ const routes: RouteRecordRaw[] = [
{ {
path: "/", path: "/",
name: "Home", name: "Home",
component: () => import("../views/Home.vue")
component: () => import("../views/Home.vue"),
beforeEnter: requiresAuth
}, },
{ {
path: "/login", path: "/login",
name: "Login", name: "Login",
component: () => import("../views/Login.vue")
component: () => import("../views/Login.vue"),
beforeEnter: requiresGuest
}, },
{ {
path: "/register", path: "/register",
name: "Register", name: "Register",
component: () => import("../views/Register.vue"), component: () => import("../views/Register.vue"),
beforeEnter: requiresGuest
} }
]; ];
@ -23,4 +49,29 @@ const router = createRouter({
routes, 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(<any>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; export default router;

+ 0
- 0
src/app/validation.ts View File


+ 3
- 0
src/app/views/Login.vue View File

@ -27,6 +27,7 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import CheckBox from "../components/CheckBox.vue"; import CheckBox from "../components/CheckBox.vue";
import TextBox from "../components/TextBox.vue"; import TextBox from "../components/TextBox.vue";
import * as auth from "../auth";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -69,6 +70,8 @@ export default defineComponent({
} }
return; return;
} }
console.log("Successful login", body.token);
auth.storeToken(body.token);
this.$router.push({ name: "Login" }); this.$router.push({ name: "Login" });
}) })
.catch(e => { .catch(e => {


+ 10
- 0
src/server/Application.ts View File

@ -18,6 +18,8 @@ async function createRegisterToken() {
return token; return token;
} }
let instance: Application;
/** /**
* The main application class * The main application class
*/ */
@ -53,10 +55,18 @@ export default class Application
*/ */
protected torrent!: TorrentClientIpc; protected torrent!: TorrentClientIpc;
/**
* Return the current application instance
*/
public static instance() {
return instance;
}
/** /**
* Create a new application instance * Create a new application instance
*/ */
public constructor(appKey: string) { public constructor(appKey: string) {
instance = this;
this.APP_KEY = appKey; this.APP_KEY = appKey;
this.services = [ this.services = [
this.database = new Database(this), this.database = new Database(this),


+ 28
- 18
src/server/services/WebServer/index.ts View File

@ -1,4 +1,5 @@
import fastify from "fastify"; import fastify from "fastify";
import fastifyCookie from "fastify-cookie";
import fastifyFormBody from "fastify-formbody"; import fastifyFormBody from "fastify-formbody";
import fastifyHttpProxy from "fastify-http-proxy"; import fastifyHttpProxy from "fastify-http-proxy";
import fastifyMultipart from "fastify-multipart"; import fastifyMultipart from "fastify-multipart";
@ -8,6 +9,7 @@ import Service from "../Service";
import { join } from "path"; import { join } from "path";
import routes from "./routes"; import routes from "./routes";
import "./validators"; import "./validators";
import RouteRegisterFactory, { RouteFactory } from "./routes/RouteRegisterFactory";
export default class WebServer extends Service export default class WebServer extends Service
{ {
@ -19,7 +21,7 @@ export default class WebServer extends Service
/** /**
* The internal webserver instance * The internal webserver instance
*/ */
protected server: ReturnType<typeof fastify>;
protected fastify: ReturnType<typeof fastify>;
/** /**
* Create a new webserver instance * Create a new webserver instance
@ -27,28 +29,35 @@ export default class WebServer extends Service
public constructor(app: Application) { public constructor(app: Application) {
super("Web Server", app); super("Web Server", app);
this.port = parseInt(<string>process.env["WEBSERVER_PORT"]); this.port = parseInt(<string>process.env["WEBSERVER_PORT"]);
this.server = fastify();
this.registerPlugins();
this.fastify = fastify();
} }
/** /**
* Register required Fastify plugins * Register required Fastify plugins
*/ */
protected registerPlugins() { 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 * Boot the webserver
*/ */
public async boot() { public async boot() {
// Register the available routes
// Install plugins
await this.registerPlugins();
// Register the routes
this.registerRoutes(); this.registerRoutes();
} }
@ -57,7 +66,7 @@ export default class WebServer extends Service
*/ */
public start() { public start() {
// Start listening // 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); this.log("Webserver listening on port:", this.port);
} }
@ -66,7 +75,7 @@ export default class WebServer extends Service
*/ */
public async shutdown() { public async shutdown() {
this.log("Webserver shutting down"); 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() { protected registerRoutes() {
this.registerSpaRoutes(); this.registerSpaRoutes();
for (let routeGroup in routes) {
(<any>routes)[routeGroup](this.server, this.app);
let factory = new RouteRegisterFactory(this.fastify, this.app);
for (let group in routes) {
<RouteFactory>(<any>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 * NOTE: Static assets may be moved to nginx later... not sure yet
*/ */
if (process.env["NODE_ENV"] == "production") { if (process.env["NODE_ENV"] == "production") {
this.server.register(fastifyStatic, {
this.fastify.register(fastifyStatic, {
root: join(__dirname, "../../../public") root: join(__dirname, "../../../public")
}); });
this.server.setNotFoundHandler((request, reply) => {
this.fastify.setNotFoundHandler((request, reply) => {
return reply.sendFile("index.html"); return reply.sendFile("index.html");
}); });
} else { } else {
console.log("Using Vite proxy"); console.log("Using Vite proxy");
this.server.register(fastifyHttpProxy, {
this.fastify.register(fastifyHttpProxy, {
upstream: "http://localhost:3001" upstream: "http://localhost:3001"
}); });
} }


+ 47
- 0
src/server/services/WebServer/middleware/auth.ts View File

@ -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<T = any>(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 = <any>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();
}

+ 23
- 0
src/server/services/WebServer/middleware/index.ts View File

@ -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 <HandlerMethod> (async (request: FastifyRequest, reply: FastifyReply) => {
var iterator = middleware[Symbol.iterator]();
var next = async () => {
let result = iterator.next();
if (result.done) {
(<any>handler)(request, reply);
return;
}
result.value(request, reply, next);
};
next();
});
}

+ 79
- 0
src/server/services/WebServer/routes/RouteRegisterFactory.ts View File

@ -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 ?? <RouteHandlerMethod>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 ?? <RouteHandlerMethod>middleware);
middleware = (middleware instanceof Array) ? this.middleware.concat(middleware) : this.middleware;
this.fastify.post(`${this.pathPrefix}${path}`, handleMiddleware(middleware, handler));
}
}

+ 22
- 8
src/server/services/WebServer/routes/auth.ts View File

@ -1,20 +1,27 @@
import Application from "@server/Application";
import { FastifyInstance } from "fastify";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import Application from "@server/Application";
import { RegisterToken, User } from "@server/database/entities"; import { RegisterToken, User } from "@server/database/entities";
import LoginRequest, { ILoginFormBody } from "../requests/LoginRequest"; import LoginRequest, { ILoginFormBody } from "../requests/LoginRequest";
import RegisterRequest, { IRegisterFormBody } from "../requests/RegisterRequest"; import RegisterRequest, { IRegisterFormBody } from "../requests/RegisterRequest";
import handle from "../requests"; import handle from "../requests";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { auth } from "../middleware/auth";
import { handleMiddleware } from "../middleware";
import RouteRegisterFactory from "./RouteRegisterFactory";
/** /**
* Register authentication routes * 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 --------------------------------------------------------------------------------------- // Login ---------------------------------------------------------------------------------------
server.post("/auth/login", handle([LoginRequest], async (request, reply) => {
factory.post("/auth/login", handle([LoginRequest], async (request, reply) => {
let body = <ILoginFormBody>request.body; let body = <ILoginFormBody>request.body;
let user = await User.findOne({ email: body.email }); let user = await User.findOne({ email: body.email });
if (user === undefined || !(await bcrypt.compare(body.password, user.password))) { if (user === undefined || !(await bcrypt.compare(body.password, user.password))) {
@ -23,7 +30,14 @@ export default function register(server: FastifyInstance, app: Application) {
return return
} }
let token = jwt.sign({ id: (<User>user).id }, app.APP_KEY, { expiresIn: 60*60*24 }); let token = jwt.sign({ id: (<User>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 -------------------------------------------------------------------------------- // Registration --------------------------------------------------------------------------------
@ -31,7 +45,7 @@ export default function register(server: FastifyInstance, app: Application) {
/** /**
* Register a user * Register a user
*/ */
server.post("/auth/register", handle([RegisterRequest], async (request, reply) => {
factory.post("/auth/register", handle([RegisterRequest], async (request, reply) => {
let body = <IRegisterFormBody>request.body; let body = <IRegisterFormBody>request.body;
let user = new User(); let user = new User();
user.isAdmin = false; user.isAdmin = false;
@ -46,7 +60,7 @@ export default function register(server: FastifyInstance, app: Application) {
/** /**
* Validate a registration token * 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 = (<any>request.params)["token"]; let token: string = (<any>request.params)["token"];
if (!(await RegisterToken.isValid(token))) { if (!(await RegisterToken.isValid(token))) {
reply.status(422); 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) * 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 = (<any>request.params)["email"].trim(); let email: string = (<any>request.params)["email"].trim();
if (!Boolean(email) || await User.count({email}) != 0) { if (!Boolean(email) || await User.count({email}) != 0) {
reply.status(422); reply.status(422);


+ 19
- 0
yarn.lock View File

@ -796,6 +796,11 @@ content-disposition@^0.5.3:
dependencies: dependencies:
safe-buffer "5.1.2" 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: cookie@^0.4.0:
version "0.4.1" version "0.4.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" 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" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== 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: fastify-error@^0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/fastify-error/-/fastify-error-0.3.0.tgz#08866323d521156375a8be7c7fcaf98df946fafb" 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" jwa "^1.4.1"
safe-buffer "^5.0.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: keyv@^3.0.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"


Loading…
Cancel
Save