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",
"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",


+ 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[] = [
{
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(<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;

+ 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 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 => {


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

@ -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),


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

@ -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<typeof fastify>;
protected fastify: ReturnType<typeof fastify>;
/**
* 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(<string>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) {
(<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
*/
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"
});
}


+ 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 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 = <ILoginFormBody>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>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 = <IRegisterFormBody>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 = (<any>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 = (<any>request.params)["email"].trim();
if (!Boolean(email) || await User.count({email}) != 0) {
reply.status(422);


+ 19
- 0
yarn.lock View File

@ -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"


Loading…
Cancel
Save