start work on logged in pages + add auth

This commit is contained in:
Holly Stubbs 2024-04-23 00:58:07 +01:00
parent 88e92051e3
commit 1183f0f9b6
Signed by: tgpholly
GPG key ID: B8583C4B7D18119E
17 changed files with 300 additions and 23 deletions

View file

@ -3,6 +3,11 @@
"http": 38194,
"ws": 39195
},
"session": {
"validity": 86400,
"length": 64,
"secret": "changeme"
},
"database": {
"address": "localhost",
"port": 3306,

View file

@ -1,6 +1,8 @@
import { createReader, createWriter, Endian } from "bufferstuff";
import { WebSocketServer } from "ws";
import Fastify from "fastify";
import FastifyFormBody from "@fastify/formbody";
import FastifyCookie from "@fastify/cookie";
import FastifyView from "@fastify/view";
import EJS from "ejs";
import Config from "./objects/Config";
@ -9,6 +11,11 @@ import RemoteUser from "./objects/RemoteUser";
import { MessageType } from "./enums/MessageType";
import Database from "./objects/Database";
import { Console } from "hsconsole";
import UserService from "./services/UserService";
import UsernameData from "./interfaces/UsernameData";
import { randomBytes } from "crypto";
import SessionUser from "./objects/SessionUser";
import PasswordUtility from "./utilities/PasswordUtility";
Console.customHeader(`MultiProbe server started at ${new Date()}`);
@ -18,6 +25,17 @@ new Database(Config.database.address, Config.database.port, Config.database.user
// Web stuff
const sessions = new FunkyArray<string, SessionUser>();
const sessionExpiryInterval = setInterval(() => {
const currentTime = Date.now();
for (const key of sessions.keys) {
const session = sessions.get(key);
if (!session || (session && currentTime >= session.validityPeriod.getTime())) {
sessions.remove(key);
}
}
}, 3600000);
const fastify = Fastify({
logger: false
});
@ -26,9 +44,43 @@ fastify.register(FastifyView, {
engine: {
ejs: EJS
}
})
});
fastify.register(FastifyFormBody);
fastify.register(FastifyCookie, {
secret: Config.session.secret,
parseOptions: {
path: "/",
secure: true
}
});
// Get Methods
function validateSession(cookies:{ [cookieName: string]: string | undefined }) {
if ("MP_SESSION" in cookies && typeof(cookies["MP_SESSION"]) === "string") {
const key = FastifyCookie.unsign(cookies["MP_SESSION"], Config.session.secret);
if (key.valid && sessions.has(key.value ?? "badkey")) {
return sessions.get(key.value ?? "badkey");
}
}
return undefined;
}
fastify.get("/", async (req, res) => {
let session:SessionUser | undefined;
if (session = validateSession(req.cookies)) {
const user = await UserService.GetUser(session.userId);
//const groups = await UserService.GetUserParties(session.userId);
if (user) {
return res.view("templates/home.ejs", { user, parties: [] });
}
return res.view("templates/index.ejs", { });
}
return res.view("templates/index.ejs", { });
});
@ -45,7 +97,66 @@ fastify.get("/account/register", async (req, res) => {
});
fastify.setNotFoundHandler(async (req, res) => {
return res.view("templates/404.ejs", { });
return res.status(404).view("templates/404.ejs", { });
});
// Post Methods
fastify.post("/account/register", async (req, res) => {
const data = req.body as UsernameData;
if (typeof(data.username) !== "string" || typeof(data.password) !== "string" || data.username.length > 32 || data.password.length < 8) {
return res.view("templates/account/register.ejs", { });
}
const username = data.username.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
await UserService.CreateUser(0, username, data.password);
const user = await UserService.GetUserByUsername(username);
if (!user) {
return res.view("templates/account/register.ejs", { });
}
const validPeriod = new Date();
validPeriod.setTime(validPeriod.getTime() + Config.session.validity);
const key = randomBytes(Config.session.length).toString("hex");
sessions.set(key, new SessionUser(user.Id, validPeriod));
res.setCookie("MP_SESSION", key, {
path: "/",
signed: true
});
return res.redirect(302, "/");
});
fastify.post("/account/login", async (req, res) => {
const data = req.body as UsernameData;
if (typeof(data.username) !== "string" || typeof(data.password) !== "string" || data.username.length > 32 || data.password.length < 8) {
return res.view("templates/account/login.ejs", { });
}
const user = await UserService.GetUserByUsername(data.username);
if (!user) {
return res.view("templates/account/login.ejs", { });
}
if (await PasswordUtility.ValidatePassword(user.PasswordHash, user.PasswordSalt, data.password)) {
const validPeriod = new Date();
validPeriod.setTime(validPeriod.getTime() + Config.session.validity);
const key = randomBytes(Config.session.length).toString("hex");
sessions.set(key, new SessionUser(user.Id, validPeriod));
res.setCookie("MP_SESSION", key, {
path: "/",
signed: true
});
return res.redirect(302, "/");
}
return res.view("templates/account/login.ejs", { });
});
// Websocket stuff
@ -175,6 +286,8 @@ function shutdown() {
Console.printInfo("Shutting down...");
websocketServer.close(async () => {
await fastify.close();
clearInterval(sessionExpiryInterval);
Console.cleanup();
console.log("Goodbye!");
});

View file

@ -0,0 +1,4 @@
export default interface UsernameData {
username?: string,
password?: string
}

View file

@ -6,6 +6,7 @@ export default class Config {
public constructor() { throw new Error("Static Class"); }
public static ports:ConfigPorts = config.ports;
public static session:ConfigSession = config.session;
public static database:ConfigDatabase = config.database;
}
@ -14,6 +15,12 @@ interface ConfigPorts {
ws: number
}
interface ConfigSession {
validity: number,
length: number,
secret: string
}
interface ConfigDatabase {
address: string,
port: number,

View file

@ -0,0 +1,9 @@
export default class SessionUser {
public readonly userId:number;
public readonly validityPeriod:Date;
constructor(userId:number, validityPeriod:Date) {
this.userId = userId;
this.validityPeriod = validityPeriod;
}
}

View file

@ -9,6 +9,8 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/formbody": "^7.4.0",
"@fastify/view": "^9.0.0",
"bufferstuff": "^1.5.1",
"ejs": "^3.1.10",
@ -52,6 +54,15 @@
"fast-uri": "^2.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.3.1.tgz",
"integrity": "sha512-h1NAEhB266+ZbZ0e9qUE6NnNR07i7DnNXWG9VbbZ8uC6O/hxHpl+Zoe5sw1yfdZ2U6XhToUGDnzQtWJdCaPwfg==",
"dependencies": {
"cookie-signature": "^1.1.0",
"fastify-plugin": "^4.0.0"
}
},
"node_modules/@fastify/error": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
@ -65,6 +76,15 @@
"fast-json-stringify": "^5.7.0"
}
},
"node_modules/@fastify/formbody": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-7.4.0.tgz",
"integrity": "sha512-H3C6h1GN56/SMrZS8N2vCT2cZr7mIHzBHzOBa5OPpjfB/D6FzP9mMpE02ZzrFX0ANeh0BAJdoXKOF2e7IbV+Og==",
"dependencies": {
"fast-querystring": "^1.0.0",
"fastify-plugin": "^4.0.0"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz",
@ -598,6 +618,14 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.1.tgz",
"integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",

View file

@ -15,6 +15,8 @@
"author": "tgpholly",
"license": "MIT",
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/formbody": "^7.4.0",
"@fastify/view": "^9.0.0",
"bufferstuff": "^1.5.1",
"ejs": "^3.1.10",

View file

@ -29,11 +29,11 @@ export default class PartyRepo {
public static async insertUpdate(user:User) {
if (user.Id === Number.MIN_VALUE) {
await Database.Instance.query("INSERT Party (Username, PasswordHash, PasswordSalt, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId, user.LastModifiedDatetime?.getTime(), user.DeletedByUserId, user.DeletedDatetime?.getTime(), Number(user.IsDeleted)
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted)
]);
} else {
await Database.Instance.query(`UPDATE Party SET Username = ?, PasswordHash = ?, PasswordSalt = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ?, WHERE Id = ?`, [
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId, user.LastModifiedDatetime?.getTime(), user.DeletedByUserId, user.DeletedDatetime?.getTime(), Number(user.IsDeleted), user.Id
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id
]);
}
}

View file

@ -28,11 +28,11 @@ export default class UserRepo {
public static async insertUpdate(user:User) {
if (user.Id === Number.MIN_VALUE) {
await Database.Instance.query("INSERT User (Username, PasswordHash, PasswordSalt, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId, user.LastModifiedDatetime?.getTime(), user.DeletedByUserId, user.DeletedDatetime?.getTime(), Number(user.IsDeleted)
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted)
]);
} else {
await Database.Instance.query(`UPDATE User SET Username = ?, PasswordHash = ?, PasswordSalt = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ?, WHERE Id = ?`, [
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId, user.LastModifiedDatetime?.getTime(), user.DeletedByUserId, user.DeletedDatetime?.getTime(), Number(user.IsDeleted), user.Id
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id
]);
}
}

View file

@ -13,6 +13,14 @@ export default class UserService {
}
}
public static async GetUserByUsername(username:string) {
try {
return await UserRepo.selectByUsername(username);
} catch (e) {
Console.printError(`MultiProbe server service error:\n${e}`);
}
}
public static async GetParty(id:number) {
try {
return await PartyRepo.selectById(id);

View file

@ -1,3 +1,3 @@
<%- include("base/header") -%>
<%- include("base/header", { title: "404"}) %>
<h1 class="text-center" style="font-size:10rem">404!</h1>
<%- include("base/footer") -%>
<%- include("base/footer") %>

View file

@ -1,22 +1,27 @@
<%- include("../base/header") -%>
<%- include("../base/header", { title: "Login" }) %>
<h1 class="text-center mb-5">Login</h1>
<form>
<form method="post">
<div class="row">
<div class="col-4"></div>
<div class="col-4">
<div class="form-group">
<label class="form-label">Username</label>
<input class="form-control" type="text" name="u" maxlength="32" required />
<input class="form-control" type="text" name="username" maxlength="32" required />
</div>
<div class="form-group mt-3">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="p" required />
<input class="form-control" type="password" name="password" required />
</div>
<div class="text-center mt-3">
<input class="btn btn-primary" type="submit" value="Login" />
<a href="/account/register">I don't have an account.</a>
</div>
<div class="text-center mt-3">
<input class="btn btn-primary" type="submit" value="Continue" />
</div>
</div>
<div class="col-4"></div>
</div>
</form>
<%- include("../base/footer") -%>
<%- include("../base/footer") %>

View file

@ -0,0 +1,27 @@
<%- include("../base/header", { title: "Register" }) %>
<h1 class="text-center mb-5">Register</h1>
<form method="post">
<div class="row">
<div class="col-4"></div>
<div class="col-4">
<div class="form-group">
<label class="form-label">Username</label>
<input class="form-control" type="text" name="username" maxlength="32" required />
</div>
<div class="form-group mt-3">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="password" minlength="8" autocomplete="new-password" required />
</div>
<div class="text-center mt-3">
<a href="/account/login">I already have an account!</a>
</div>
<div class="text-center mt-3">
<input class="btn btn-primary" type="submit" value="Continue" />
</div>
</div>
<div class="col-4"></div>
</div>
</form>
<%- include("../base/footer") %>

View file

@ -1,4 +1,22 @@
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js" integrity="sha512-ykZ1QQr0Jy/4ZkvKuqWn4iF3lqPZyij9iRv6sGqLRdTPkY69YX6+7wvVGmsdBbiIfN/8OdsI7HABjvEok6ZopQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cookieconsent/3.1.1/cookieconsent.min.js" integrity="sha512-yXXqOFjdjHNH1GND+1EO0jbvvebABpzGKD66djnUfiKlYME5HGMUJHoCaeE4D5PTG2YsSJf6dwqyUUvQvS0vaA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
window.cookieconsent.initialise({
"palette": {
"popup": {
"background": "#0b5ed7",
"text": "#fff"
},
"button": {
"background": "#198754",
"text": "#fff"
}
},
"content": {
"message": "This site uses cookies to retain your login, no more, no less."
}
});
</script>
</body>
</html>

View file

@ -3,16 +3,19 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<title><%= title %> - MultiProbe</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css" integrity="sha512-jnSuA4Ss2PkkikSOLtYs8BlYIeeIK1h99ty4YfvRPAlzr377vr3CXDb7sb7eEEBYjDtcYj+AjBH3FLv5uSJuXg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cookieconsent/3.1.1/cookieconsent.min.css" integrity="sha512-LQ97camar/lOliT/MqjcQs5kWgy6Qz/cCRzzRzUCfv0fotsCTC9ZHXaPQmJV8Xu/PVALfJZ7BDezl5lW3/qBxg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">MultiProbe</a>
<a class="navbar-brand" href="/">
MultiProbe
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
@ -20,7 +23,20 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
</ul>
<a class="nav-link active" aria-current="page" href="/account/login">Login</a>
<ul class="navbar-nav">
<% if (typeof(userId) !== "undefined") { %>
<div class="nav-item float-end">
<a class="nav-link" href="/account/logout">Logout</a>
</div>
<% } else { %>
<div class="nav-item float-end">
<a class="nav-link" href="/account/login">Login</a>
</div>
<div class="nav-item float-end">
<a class="nav-link" href="/account/register">Register</a>
</div>
<% } %>
</ul>
</div>
</div>
</nav>

View file

@ -1,3 +1,33 @@
<%- include("base/header") -%>
<h1>Welcome back USER!</h1>
<%- include("base/footer") -%>
<%- include("base/header", { title: "Home", userId: user.Id }) %>
<div class="row">
<div class="col">
<h1>Welcome back <%= user.Username %>!</h1>
<h3>What would you like to do?</h3>
<div class="mt-3">
<div>
<a class="btn btn-primary btn-lg me-2" href="/account/username">Change Username</a>
<a class="btn btn-primary btn-lg me-2" href="/account/password">Change Password</a>
</div>
<div class="mt-3">
<a class="btn btn-primary btn-lg me-2" href="/account/password">Create Party</a>
<a class="btn btn-primary btn-lg me-2" href="/account/username">Join Party</a>
</div>
</div>
</div>
<div class="col">
<h3>Your Parties</h3>
<% if (parties.length > 0) { %>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col"></th>
</tr>
</thead>
</table>
<% } else { %>
<div class="alert alert-primary" role="alert">You are not in any parties.</div>
<% } %>
</div>
</div>
<%- include("base/footer") %>

View file

@ -1,3 +1,8 @@
<%- include("base/header") -%>
<h1>Hello world!</h1>
<%- include("base/footer") -%>
<%- include("base/header", { title: "Home" }) %>
<h1><b>MultiProbe</b></h1>
<h3>A way to explore <a href="https://angusnicneven.com">Terminal 00</a> with friends.</h3>
<div class="mt-3">
<a type="button" class="btn btn-primary btn-lg me-2" href="/account/register">Register</a>
<a type="button" class="btn btn-secondary btn-lg" href="/account/login">Login</a>
</div>
<%- include("base/footer") %>