diff --git a/server/controller/AccountController.ts b/server/controller/AccountController.ts index 4fbc36a..b346ef9 100644 --- a/server/controller/AccountController.ts +++ b/server/controller/AccountController.ts @@ -1,3 +1,4 @@ +import ChangeUsernameViewModel from "../models/account/ChangeUsernameViewModel"; import LoginViewModel from "../models/account/LoginViewModel"; import RegisterViewModel from "../models/account/RegisterViewModel"; import Session from "../objects/Session"; @@ -37,7 +38,12 @@ export default class AccountController extends Controller { } const username = registerViewModel.username.replaceAll("<", "<").replaceAll(">", ">"); - await UserService.CreateUser(0, username, registerViewModel.password); + if (!await UserService.CreateUser(0, username, registerViewModel.password)) { + registerViewModel.password = ""; + registerViewModel.message = "Sorry! That username is already taken."; + + return this.view(registerViewModel); + } const user = await UserService.GetUserByUsername(username); if (!user) { @@ -57,4 +63,22 @@ export default class AccountController extends Controller { return this.redirectToAction("index", "home"); } + + public async ChangeUsername_Get(changeUsernameViewModel:ChangeUsernameViewModel) { + return this.view(changeUsernameViewModel); + } + + public async ChangeUsername_Post(changeUsernameViewModel:ChangeUsernameViewModel) { + if (typeof(changeUsernameViewModel.username) !== "string") { + return this.badRequest(); + } + + const user = await UserService.SaveUsername(this.session.userId, changeUsernameViewModel.username); + if (!user) { + changeUsernameViewModel.message = "Sorry! That username is already taken."; + return this.view(changeUsernameViewModel); + } + + return this.redirectToAction("index", "home"); + } } \ No newline at end of file diff --git a/server/controller/AdminController.ts b/server/controller/AdminController.ts new file mode 100644 index 0000000..4e4a01c --- /dev/null +++ b/server/controller/AdminController.ts @@ -0,0 +1,33 @@ +import AdminIndexViewModel from "../models/admin/AdminIndexViewModel"; +import AdminPartiesViewModel from "../models/admin/AdminPartiesViewModel"; +import AdminUsersViewModel from "../models/admin/AdminUsersViewModel"; +import PartyService from "../services/PartyService"; +import UserService from "../services/UserService"; +import Controller from "./Controller"; + +export default class AdminController_Auth$Admin extends Controller { + public async Index_Get() { + const adminIndexViewModel: AdminIndexViewModel = { + userCount: await UserService.GetUserCount(), + partyCount: await PartyService.GetPartyCount() + }; + + return this.view(adminIndexViewModel); + } + + public async Users_Get() { + const adminUsersViewModel: AdminUsersViewModel = { + users: await UserService.GetAll() + }; + + return this.view(adminUsersViewModel); + } + + public async Parties_Get() { + const adminPartiesViewModel: AdminPartiesViewModel = { + parties: await PartyService.GetAll() + }; + + return this.view(adminPartiesViewModel); + } +} \ No newline at end of file diff --git a/server/controller/Controller.ts b/server/controller/Controller.ts index 279ceb9..fc08fad 100644 --- a/server/controller/Controller.ts +++ b/server/controller/Controller.ts @@ -3,6 +3,7 @@ import { Console } from "hsconsole"; import Session from "../objects/Session"; import SessionUser from "../objects/SessionUser"; import RequestCtx from "../objects/RequestCtx"; +import { UserLevel } from "../enums/UserLevel"; // prepare for ts-ignore :3 // TODO: figure out some runtime field / type checking so @@ -12,7 +13,19 @@ export default abstract class Controller { public constructor() { const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this)); - const controllerName = this.constructor.name.replace("Controller", "").toLowerCase(); + const rawControllerParts = this.constructor.name.split("_"); + const controllerName = rawControllerParts.splice(0, 1)[0].replace("Controller", "").toLowerCase(); + let controllerAuthLevel: UserLevel | undefined; + + for (const prop of rawControllerParts) { + if (prop.startsWith("Auth")) { + const userLevel = prop.split("$")[1]; + // @ts-ignore + controllerAuthLevel = UserLevel[userLevel]; + Console.printInfo(`Set Auth level requirement for ${this.constructor.name} to ${userLevel}`); + } + } + for (const method of methods) { if (method === "constructor" || method[0] !== method[0].toUpperCase()) { // * Anything that starts with lowercase we'll consider "private" continue; @@ -23,6 +36,7 @@ export default abstract class Controller { const methodName = methodNameRaw.toLowerCase(); const doAuth = !params.includes("AllowAnonymous"); + // @ts-ignore const controllerRequestHandler = this[method]; const requestHandler = (req:FastifyRequest, res:FastifyReply) => { @@ -30,6 +44,9 @@ export default abstract class Controller { if (doAuth && session === undefined) { return res.redirect(`/account/login?returnTo=${encodeURIComponent(req.url)}`); } + if (session !== undefined && controllerAuthLevel !== undefined && controllerAuthLevel !== session.userLevel) { + return res.status(403).send("Forbidden"); + } const requestCtx = new RequestCtx(req, res, controllerName, methodName, session); controllerRequestHandler.bind(requestCtx)(req.method === "GET" ? req.query : req.body); @@ -80,4 +97,5 @@ export default abstract class Controller { ok(message?:string) {} badRequest(message?:string) {} unauthorised(message?:string) {} + forbidden(message?:string) {} } \ No newline at end of file diff --git a/server/entities/User.ts b/server/entities/User.ts index 943d55a..690ccf4 100644 --- a/server/entities/User.ts +++ b/server/entities/User.ts @@ -1,5 +1,11 @@ +import { UserLevel } from "../enums/UserLevel"; + export default class User { public Id:number; + public UserLevel:UserLevel; + public get UserLevelString() { + return UserLevel[this.UserLevel]; + } public Username:string; public PasswordSalt:string; public PasswordHash:string; @@ -12,9 +18,10 @@ export default class User { public DeletedDatetime?:Date; public IsDeleted:boolean; - public constructor(id?:number, username?:string, passwordSalt?:string, passwordHash?:string, apiKey?:string, createdByUserId?:number, createdDateTime?:Date, lastModifiedByUserId?:number, lastModifiedDatetime?:Date, deletedByUserId?:number, deletedDatetime?:Date, isDeleted?:boolean) { - if (typeof(id) == "number" && typeof(username) == "string" && typeof(passwordHash) == "string" && typeof(passwordSalt) == "string" && typeof(apiKey) == "string" && typeof(createdByUserId) == "number" && createdDateTime instanceof Date && typeof(lastModifiedByUserId) == "number" && lastModifiedDatetime instanceof Date && typeof(deletedByUserId) == "number" && deletedDatetime instanceof Date && typeof(isDeleted) == "boolean") { + public constructor(id?:number, userLevel?:UserLevel, username?:string, passwordSalt?:string, passwordHash?:string, apiKey?:string, createdByUserId?:number, createdDateTime?:Date, lastModifiedByUserId?:number, lastModifiedDatetime?:Date, deletedByUserId?:number, deletedDatetime?:Date, isDeleted?:boolean) { + if (typeof(id) == "number" && typeof(userLevel) == "number" && typeof(username) == "string" && typeof(passwordHash) == "string" && typeof(passwordSalt) == "string" && typeof(apiKey) == "string" && typeof(createdByUserId) == "number" && createdDateTime instanceof Date && typeof(lastModifiedByUserId) == "number" && lastModifiedDatetime instanceof Date && typeof(deletedByUserId) == "number" && deletedDatetime instanceof Date && typeof(isDeleted) == "boolean") { this.Id = id; + this.UserLevel = userLevel; this.Username = username; this.PasswordHash = passwordHash; this.PasswordSalt = passwordSalt; @@ -28,6 +35,7 @@ export default class User { this.IsDeleted = isDeleted; } else { this.Id = Number.MIN_VALUE; + this.UserLevel = UserLevel.Unknown; this.Username = ""; this.PasswordHash = ""; this.PasswordSalt = ""; diff --git a/server/enums/UserLevel.ts b/server/enums/UserLevel.ts new file mode 100644 index 0000000..b2c67a8 --- /dev/null +++ b/server/enums/UserLevel.ts @@ -0,0 +1,5 @@ +export enum UserLevel { + Unknown = 0, + User = 10, + Admin = 999 +} \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index 02f2faa..a7cd889 100644 --- a/server/index.ts +++ b/server/index.ts @@ -40,6 +40,7 @@ import AccountController from "./controller/AccountController"; import PartyController from "./controller/PartyController"; import ApiController from "./controller/ApiController"; import PartyService from "./services/PartyService"; +import AdminController_Auth$Admin from "./controller/AdminController"; Console.customHeader(`MultiProbe server started at ${new Date()}`); @@ -72,6 +73,7 @@ new HomeController(); new AccountController(); new PartyController(); new ApiController(); +new AdminController_Auth$Admin(); // Websocket stuff diff --git a/server/models/account/ChangeUsernameViewModel.ts b/server/models/account/ChangeUsernameViewModel.ts new file mode 100644 index 0000000..5d186c0 --- /dev/null +++ b/server/models/account/ChangeUsernameViewModel.ts @@ -0,0 +1,4 @@ +export default interface ChangeUsernameViewModel { + username?: string + message?: string +} \ No newline at end of file diff --git a/server/models/admin/AdminIndexViewModel.ts b/server/models/admin/AdminIndexViewModel.ts new file mode 100644 index 0000000..22165a0 --- /dev/null +++ b/server/models/admin/AdminIndexViewModel.ts @@ -0,0 +1,4 @@ +export default interface AdminIndexViewModel { + userCount: number, + partyCount: number +} \ No newline at end of file diff --git a/server/models/admin/AdminPartiesViewModel.ts b/server/models/admin/AdminPartiesViewModel.ts new file mode 100644 index 0000000..b7e5f81 --- /dev/null +++ b/server/models/admin/AdminPartiesViewModel.ts @@ -0,0 +1,5 @@ +import Party from "../../entities/Party"; + +export default interface AdminPartiesViewModel { + parties: Array +} \ No newline at end of file diff --git a/server/models/admin/AdminUsersViewModel.ts b/server/models/admin/AdminUsersViewModel.ts new file mode 100644 index 0000000..a69e1cd --- /dev/null +++ b/server/models/admin/AdminUsersViewModel.ts @@ -0,0 +1,5 @@ +import User from "../../entities/User"; + +export default interface AdminUsersViewModel { + users: Array +} \ No newline at end of file diff --git a/server/objects/RequestCtx.ts b/server/objects/RequestCtx.ts index 7f767b2..51fefca 100644 --- a/server/objects/RequestCtx.ts +++ b/server/objects/RequestCtx.ts @@ -57,4 +57,8 @@ export default class RequestCtx { unauthorised(message?:string) { return this.res.status(401).send(message ?? ""); } + + forbidden(message?:string) { + return this.res.status(403).send(message ?? ""); + } } \ No newline at end of file diff --git a/server/objects/Session.ts b/server/objects/Session.ts index b292c6d..1b052d8 100644 --- a/server/objects/Session.ts +++ b/server/objects/Session.ts @@ -37,7 +37,7 @@ export default abstract class Session { validPeriod.setTime(validPeriod.getTime() + Config.session.validity); const key = randomBytes(Config.session.length).toString("hex"); - Session.Sessions.set(key, new SessionUser(user.Id, validPeriod)); + Session.Sessions.set(key, new SessionUser(user.Id, user.UserLevel, validPeriod)); res.setCookie("MP_SESSION", key, { path: "/", diff --git a/server/objects/SessionUser.ts b/server/objects/SessionUser.ts index 0de762f..d5a750f 100644 --- a/server/objects/SessionUser.ts +++ b/server/objects/SessionUser.ts @@ -1,9 +1,13 @@ +import { UserLevel } from "../enums/UserLevel"; + export default class SessionUser { public readonly userId:number; + public readonly userLevel:UserLevel; public readonly validityPeriod:Date; - constructor(userId:number, validityPeriod:Date) { + constructor(userId:number, userLevel:UserLevel, validityPeriod:Date) { this.userId = userId; + this.userLevel = userLevel; this.validityPeriod = validityPeriod; } } \ No newline at end of file diff --git a/server/repos/PartyRepo.ts b/server/repos/PartyRepo.ts index 2fcad19..2d85cfd 100644 --- a/server/repos/PartyRepo.ts +++ b/server/repos/PartyRepo.ts @@ -4,6 +4,19 @@ import User from "../entities/User"; import RepoBase from "./RepoBase"; export default class PartyRepo { + public static async selectAll() { + const dbUser = await Database.Instance.query("SELECT * FROM Party WHERE IsDeleted = 0"); + const parties = new Array(); + + for (const row of dbUser) { + const party = new Party(); + populatePartyFromDB(party, row); + parties.push(party); + } + + return parties; + } + public static async selectById(id:number) { const dbParty = await Database.Instance.query("SELECT * FROM Party WHERE Id = ? AND IsDeleted = 0 LIMIT 1", [id]); if (dbParty == null || dbParty.length === 0) { @@ -42,6 +55,12 @@ export default class PartyRepo { } } + public static async selectPartyCount() { + const countResult = await Database.Instance.query("SELECT COUNT(Id) FROM `Party` LIMIT 1"); + + return countResult[0]["COUNT(Id)"]; + } + public static async insertUpdate(party:Party) { if (party.Id === Number.MIN_VALUE) { await Database.Instance.query("INSERT Party (Name, PartyRef, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [ diff --git a/server/repos/UserRepo.ts b/server/repos/UserRepo.ts index 5969835..001f016 100644 --- a/server/repos/UserRepo.ts +++ b/server/repos/UserRepo.ts @@ -3,6 +3,19 @@ import User from "../entities/User"; import RepoBase from "./RepoBase"; export default class UserRepo { + public static async selectAll() { + const dbUser = await Database.Instance.query("SELECT * FROM User WHERE IsDeleted = 0"); + const users = new Array(); + + for (const row of dbUser) { + const user = new User(); + populateUserFromDB(user, row); + users.push(user); + } + + return users; + } + public static async selectById(id:number) { const dbUser = await Database.Instance.query("SELECT * FROM User WHERE Id = ? LIMIT 1", [id]); if (dbUser == null || dbUser.length === 0) { @@ -36,14 +49,20 @@ export default class UserRepo { } } + public static async selectUserCount() { + const countResult = await Database.Instance.query("SELECT COUNT(Id) FROM `User` LIMIT 1"); + + return countResult[0]["COUNT(Id)"]; + } + 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 ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted) + await Database.Instance.query("INSERT User (UserLevelId, Username, PasswordHash, PasswordSalt, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ + user.UserLevel, 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 = ?, APIKey = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ?, WHERE Id = ?`, [ - user.Username, user.PasswordHash, user.PasswordSalt, user.APIKey, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id + await Database.Instance.query(`UPDATE User SET UserLevelId = ?, Username = ?, PasswordHash = ?, PasswordSalt = ?, APIKey = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [ + user.UserLevel, user.Username, user.PasswordHash, user.PasswordSalt, user.APIKey, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id ]); } } @@ -51,6 +70,7 @@ export default class UserRepo { function populateUserFromDB(user:User, dbUser:any) { user.Id = dbUser.Id; + user.UserLevel = dbUser.UserLevelId; user.Username = dbUser.Username; user.PasswordHash = dbUser.PasswordHash; user.PasswordSalt = dbUser.PasswordSalt; diff --git a/server/services/PartyService.ts b/server/services/PartyService.ts index 95eeb67..ebd57e1 100644 --- a/server/services/PartyService.ts +++ b/server/services/PartyService.ts @@ -42,6 +42,15 @@ export default abstract class PartyService { } } + public static async GetAll() { + try { + return await PartyRepo.selectAll(); + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } + public static async GetPartyByPartyRef(partyRef:string) { try { return await PartyRepo.selectByPartyRef(partyRef); @@ -81,4 +90,13 @@ export default abstract class PartyService { throw e; } } + + public static async GetPartyCount() { + try { + return await PartyRepo.selectPartyCount(); + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } } \ No newline at end of file diff --git a/server/services/UserService.ts b/server/services/UserService.ts index e52d62d..a489d34 100644 --- a/server/services/UserService.ts +++ b/server/services/UserService.ts @@ -33,6 +33,15 @@ export default class UserService { } } + public static async GetAll() { + try { + return await UserRepo.selectAll(); + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } + public static async GetUserByUsername(username:string) { try { return await UserRepo.selectByUsername(username); @@ -62,6 +71,11 @@ export default class UserService { public static async CreateUser(currentUserId:number, username:string, password:string) { try { + const existingCheck = await UserRepo.selectByUsername(username); + if (existingCheck) { + return null; + } + const user = new User(); user.Username = username; user.PasswordSalt = PasswordUtility.GenerateSalt(); @@ -70,6 +84,8 @@ export default class UserService { user.CreatedDatetime = new Date(); await UserRepo.insertUpdate(user); + + return user; } catch (e) { Console.printError(`MultiProbe server service error:\n${e}`); throw e; @@ -150,4 +166,39 @@ export default class UserService { throw e; } } + + public static async SaveUsername(currentUserId:number, username:string) { + try { + const existingCheck = await UserRepo.selectByUsername(username) + if (existingCheck) { + return null; + } + + const user = await UserRepo.selectById(currentUserId); + if (!user) { + return null; + } + + user.LastModifiedByUserId = currentUserId; + user.LastModifiedDatetime = new Date(); + + user.Username = username; + + await UserRepo.insertUpdate(user); + + return user; + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } + + public static async GetUserCount() { + try { + return await UserRepo.selectUserCount(); + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } } \ No newline at end of file diff --git a/server/templates/account/changeusername.ejs b/server/templates/account/changeusername.ejs new file mode 100644 index 0000000..708c2d7 --- /dev/null +++ b/server/templates/account/changeusername.ejs @@ -0,0 +1,25 @@ +<%- include("../base/header", { title: "Login", userId: session.userId }) %> + +
+
+
+

Change Username

+ <% if (typeof(message) === "string") { %> + + <% } %> +
+ " required autocomplete="one-time-code" /> +
+
+ Cancel +
+
+ +
+
+
+
+
+
+ +<%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/admin/index.ejs b/server/templates/admin/index.ejs new file mode 100644 index 0000000..daa5fb0 --- /dev/null +++ b/server/templates/admin/index.ejs @@ -0,0 +1,53 @@ +<%- include("../base/header", { title: "Admin Dashboard", userId: session.userId, isAdmin: true }) %> + +
+
+ +
+
+ +
+
+

Admin Dashboard

+
+
+ +
+
+
+
+

Users

+

<%= userCount %>

+
+
+
+
+
+
+

Parties

+

<%= partyCount %>

+
+
+
+
+
+
+

Badges

+

<%= 0 %>

+
+
+
+
+ + +<%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/admin/parties.ejs b/server/templates/admin/parties.ejs new file mode 100644 index 0000000..e8066a7 --- /dev/null +++ b/server/templates/admin/parties.ejs @@ -0,0 +1,45 @@ +<%- include("../base/header", { title: "Party Management", userId: session.userId, isAdmin: true }) %> + +
+
+ +
+
+ +
+
+

Party Management

+
+
+ +
+
+ + + + + + + + + <% for (const party of parties) { %> + + + + + + + <% } %> + +
#Party RefName 
<%= party.Id %><%= "redacted" %><%= party.Name %> + Edit + Delete +
+
+
+<%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/admin/users.ejs b/server/templates/admin/users.ejs new file mode 100644 index 0000000..eb841cc --- /dev/null +++ b/server/templates/admin/users.ejs @@ -0,0 +1,45 @@ +<%- include("../base/header", { title: "User Management", userId: session.userId, isAdmin: true }) %> + +
+
+ +
+
+ +
+
+

User Management

+
+
+ +
+
+ + + + + + + + + <% for (const user of users) { %> + + + + + + + <% } %> + +
#UsernamePermissions 
<%= user.Id %><%= user.Username %><%= user.UserLevelString %> + Edit + Delete +
+
+
+<%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/base/header.ejs b/server/templates/base/header.ejs index b42b907..02071d0 100644 --- a/server/templates/base/header.ejs +++ b/server/templates/base/header.ejs @@ -14,7 +14,7 @@