diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cabd3e2..7d830ce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,7 +17,6 @@ model Event { description String disciplines Discipline[] - admins Admin[] organisations Organisation[] campaign Campaign[] } @@ -47,14 +46,11 @@ model Link { model Admin { id Int @id @default(autoincrement()) pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid - name String + name String // TODO: This should maybe be unique password String // TODO: Probably specify hash size (VarChar or some other type) permission_level AdminLevel @default(STANDARD) - revision String + revision DateTime @default(now()) groups Group[] - - event Event @relation(fields: [eventId], references: [id]) - eventId Int } model Discipline { @@ -143,4 +139,4 @@ enum Gender { MALE FEMALE OTHER -} \ No newline at end of file +} diff --git a/src/Controllers/admin.controller.ts b/src/Controllers/admin.controller.ts new file mode 100644 index 0000000..714d98c --- /dev/null +++ b/src/Controllers/admin.controller.ts @@ -0,0 +1,234 @@ +import prisma from "../lib/prisma"; +import { Request, Response } from "express"; +import { AdminLevel } from "@prisma/client"; +import argon2 from "argon2"; +import { DataType, generateInvalidBodyError } from "./common"; +import { authClient } from "../lib/redis"; + +export const regenerateRevision = async (pid: string) => { + // TOOO: Add error handling + const { revision } = await prisma.admin.update({ + where: { pid }, + data: { revision: new Date() }, + select: { revision: true }, + }); + + await authClient.set(pid, revision.toISOString()); +}; + +const AUTH_ERROR = { + type: "failure", + payload: { + message: "The server was not able to validate your credentials; Please try again later", + }, +}; + +const createInsufficientPermissionsError = (required: AdminLevel = "ELEVATED") => ({ + type: "error", + payload: { + message: "You do not have sufficient permissions to use this feature", + required_level: required, + }, + _links: [ + { + rel: "authentication", + href: "/api/authentication", + type: "POST", + }, + ], +}); + +// requires: auth(elevated) +export const getAllAdmins = async (req: Request, res: Response) => { + if (!req.auth?.isAuthenticated) { + return res.status(500).json(AUTH_ERROR); + } + + if (req.auth.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); + } + + // TODO: Add exception handling + const users = await prisma.admin.findMany({ select: { pid: true, name: true, permission_level: true } }); + + res.status(200).json({ + type: "success", + payload: { + users, + }, + }); +}; + +interface CreateAdminBody { + name?: string; + password: string; + permission_level: string; + groups?: string[]; +} + +const PERMISSION_LEVELS: readonly AdminLevel[] = ["ELEVATED", "STANDARD"]; // TODO: Enforce completeness + +const isPermissionLevel = (level: string): level is AdminLevel => PERMISSION_LEVELS.includes(level as any); + +// requires: auth(elevated) +export const createAdmin = async (req: Request<{}, {}, CreateAdminBody>, res: Response) => { + if (!req.auth?.isAuthenticated) { + return res.status(500).json(AUTH_ERROR); + } + + if (req.auth.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); + } + + const { name, password, permission_level, groups } = req.body || {}; + + const groupsIsValid = groups ? groups.filter((e) => typeof e !== "string").length === 0 : true; + + if ( + !(typeof name === "string" && typeof password == "string" && isPermissionLevel(permission_level) && groupsIsValid) + ) { + return res.status(400).json( + generateInvalidBodyError({ + name: DataType.STRING, + password: DataType.STRING, + permission_level: DataType.PERMISSION_LEVEL, + }) + ); + } + + const password_hash = await argon2.hash(password, { type: argon2.argon2id }); + + // Check if all gropus exist + for (const groupId of groups || []) { + if (!(await prisma.group.findUnique({ where: { pid: groupId } }))) { + return res.status(404).json({ + type: "error", + payload: { + message: `The group with ID '${groupId}' could not be found!`, + }, + }); + } + } + + // TODO: Check for uniqueness of the name + const user = await prisma.admin.create({ + data: { + name, + password: password_hash, + permission_level, + groups: { connect: groups?.map((group) => ({ pid: group })) }, + }, + select: { pid: true, name: true, permission_level: true }, + }); + + res.status(201).json({ + type: "success", + payload: { + user, + }, + }); +}; + +// Expects a valid username (Should be tested beforehand) +const updatePasswordField = async (pid: string, new_password: string) => { + const new_password_hash = await argon2.hash(new_password, { type: argon2.argon2id }); + + await prisma.admin.update({ where: { pid }, data: { password: new_password_hash } }); +}; + +interface UpdateForeignPasswordBody { + new_password?: string; +} + +interface UpdateForeignPasswordQueryParams { + pid: string; +} + +export const updateForeignPassword = async ( + req: Request, + res: Response +) => { + if (!req.auth?.isAuthenticated) { + return res.status(500).json(AUTH_ERROR); + } + + if (typeof req.body.new_password !== "string") { + return res.status(400).json(generateInvalidBodyError({ new_password: DataType.STRING })); + } + + const user_to_upate = await prisma.admin.findUnique({ where: { pid: req.params.pid } }); + + if (!user_to_upate) { + // REVIEW: This allows potential attackers (which are authorized with some account) + // to test account names + return res.status(404).json({ + type: "error", + payload: { + message: "The requested user was not found", + }, + }); + } + + if (req.auth.permission_level == "ELEVATED" && user_to_upate.permission_level == "STANDARD") { + await updatePasswordField(req.params.pid, req.body.new_password); // REVIEW: Should this be awaited? + await regenerateRevision(req.params.pid); + + res.status(200).json({ + type: "success", + }); + } else { + res.status(403).json({ + type: "error", + payload: { + message: "Operation not permitted; Try logging in as another user", + }, + }); + } +}; + +interface UpdatePasswordBody { + password?: string; + new_password?: string; +} + +// requires: auth +export const updateOwnPassword = async (req: Request<{}, {}, UpdatePasswordBody>, res: Response) => { + if (!req.auth?.isAuthenticated) { + return res.status(500).json(AUTH_ERROR); + } + + const pid = req.auth.pid; + + if (typeof req.body.password !== "string" || typeof req.body.new_password !== "string") { + return res.status(400).json(generateInvalidBodyError({ password: DataType.STRING, new_password: DataType.STRING })); + } + + const user_to_upate = await prisma.admin.findUnique({ where: { pid } }); + + if (!user_to_upate) { + // REVIEW: This allows potential attackers (which are authorized with some account) + // to test account names + return res.status(404).json({ + type: "error", + payload: { + message: "The requested user was not found", + }, + }); + } + + if (await argon2.verify(user_to_upate.password, req.body.password, { type: argon2.argon2id })) { + await updatePasswordField(pid, req.body.new_password); + await regenerateRevision(pid); + + return res.status(200).json({ + type: "success", + }); + } + + return res.status(401).json({ + type: "error", + payload: { + message: "The provided password is not valid", + }, + }); +}; diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts index 97161d9..33ce37b 100644 --- a/src/Controllers/admin_auth.controller.ts +++ b/src/Controllers/admin_auth.controller.ts @@ -4,6 +4,7 @@ import { authClient } from "../lib/redis"; import prisma from "../lib/prisma"; import argon2 from "argon2"; import jwt from "jsonwebtoken"; +import { DataType, generateInvalidBodyError } from "./common"; const JWT_SECRET = process.env.JWT_SECRET || "secret"; const TOKEN_EXPIRY = "4 days"; @@ -21,7 +22,7 @@ function createAdminJWT(admin: Admin & { groups: Group[] }) { pid: admin.pid, name: admin.name, permission_level: admin.permission_level, - revision: admin.revision, + revision: admin.revision.toISOString(), groups: admin.groups.map((group) => group.pid), }; @@ -39,12 +40,7 @@ export const authenticateUser = async (req: Request<{}, {}, AuthenticateUserBody const { name, password } = req.body || {}; if (name == null || password == null) { - return res.status(400).json({ - type: "error", - payload: { - message: "The body must contain 'name' and 'password' attributes of type string", - }, - }); + return res.status(400).json(generateInvalidBodyError({ name: DataType.STRING, password: DataType.STRING })); } const user = await prisma.admin.findFirst({ where: { name }, include: { groups: true } }); @@ -64,7 +60,7 @@ export const authenticateUser = async (req: Request<{}, {}, AuthenticateUserBody if (await argon2.verify(user.password, password, { type: argon2.argon2id })) { // Set password revision ID in redis - await authClient.set(user.pid, user.revision); + await authClient.set(user.pid, user.revision.toISOString()); return res.status(200).json({ type: "success", diff --git a/src/Controllers/common.ts b/src/Controllers/common.ts new file mode 100644 index 0000000..2adc291 --- /dev/null +++ b/src/Controllers/common.ts @@ -0,0 +1,20 @@ +export enum DataType { + STRING = "string", + NUMBER = "number", + INTEGER = "integer", + PERMISSION_LEVEL = "'ELEVATED' | 'STANDARD'", +} + +interface Body { + [k: string]: DataType; +} + +export function generateInvalidBodyError(body: Body) { + return { + type: "error", + payload: { + message: "The body of your request did not conform to the requirements", + schema: { body }, + }, + }; +} diff --git a/src/Middleware/auth/auth.ts b/src/Middleware/auth/auth.ts index 37853b5..0b66952 100644 --- a/src/Middleware/auth/auth.ts +++ b/src/Middleware/auth/auth.ts @@ -62,7 +62,7 @@ export const requireAuthentication = async (req: Request, res: Response, next: N const user = await prisma.admin.findUnique({ where: { pid }, select: { revision: true } }); if (user) { - db_revision = user.revision; + db_revision = user.revision.toISOString(); await authClient.set(pid, db_revision); } diff --git a/src/Routes/admin.routes.ts b/src/Routes/admin.routes.ts new file mode 100644 index 0000000..b4be6ce --- /dev/null +++ b/src/Routes/admin.routes.ts @@ -0,0 +1,14 @@ +import express from "express"; +import { getAllAdmins, createAdmin, updateOwnPassword, updateForeignPassword } from "../Controllers/admin.controller"; +import { requireAuthentication } from "../Middleware/auth/auth"; + +const router = express.Router(); + +router.get("/", requireAuthentication, getAllAdmins); + +router.post("/", requireAuthentication, createAdmin); + +router.put("/current/password", requireAuthentication, updateOwnPassword); +router.put<"/:pid/password", { pid: string }>("/:pid/password", requireAuthentication, updateForeignPassword); + +export default router; diff --git a/src/Routes/admin_auth.routes.ts b/src/Routes/admin_auth.routes.ts new file mode 100644 index 0000000..e9408d3 --- /dev/null +++ b/src/Routes/admin_auth.routes.ts @@ -0,0 +1,8 @@ +import express from "express"; +import { authenticateUser } from "../Controllers/admin_auth.controller"; + +const router = express.Router(); + +router.post("/", authenticateUser); + +export default router; diff --git a/src/app.ts b/src/app.ts index 84e6cee..deffe8a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,21 +1,40 @@ import express from "express"; import prisma from "./lib/prisma"; import eventRouter from "./Routes/event.routes"; +import adminAuthRouter from "./Routes/admin_auth.routes"; +import argon2 from "argon2"; +import adminRouter from "./Routes/admin.routes"; require("dotenv").config(); // Load dotenv config const app = express(); async function main() { + // Dev + await prisma.admin.upsert({ + where: { id: 1 }, + create: { + name: "admin", + password: await argon2.hash("test", { type: argon2.argon2id }), + permission_level: "ELEVATED", + }, + update: {}, + }); + // Todo: Everything // Bodyparser and urlencoded to parse post request bodies app.use(express.urlencoded({ extended: true })); app.use(express.json()); + // Admin authentication endpoints + app.use("/api/authentication", adminAuthRouter); + // All API endpoints are behind /api/... app.use("/api/events", eventRouter); + app.use("/api/admins", adminRouter); + app.listen(process.env.PORT, () => { console.log(`Listening on Port: ${process.env.PORT}`); });