From eff8dd0860c6b47a0a5dd2b5c0fdc8a57c59d383 Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Tue, 9 Nov 2021 12:25:56 +0100 Subject: [PATCH 01/13] Add the redis server to docker-compose.yml --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 3edb025..78e6b35 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,10 @@ services: ports: - 5432:5432 container_name: "postgres" + redis: + image: redis + restart: always + #server: # build: . # restart: always From 3d961ab53df8ef8e7c1a81d809010a2dc666d87f Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Wed, 10 Nov 2021 08:19:17 +0100 Subject: [PATCH 02/13] Add controller for authenticating with admin name and password + Add function to create a JWT with the required payload --- package.json | 3 + src/Controllers/admin_auth.controller.ts | 74 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/Controllers/admin_auth.controller.ts diff --git a/package.json b/package.json index c5b93b5..04bfa10 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,13 @@ "homepage": "https://github.com/detleph/server#readme", "dependencies": { "@prisma/client": "^3.3.0", + "@types/jsonwebtoken": "^8.5.5", "@types/node": "^16.10.3", "@types/nodemailer": "^6.4.4", + "argon2": "^0.28.2", "dotenv": "^10.0.0", "express": "^4.17.1", + "jsonwebtoken": "^8.5.1", "nodemailer": "^6.7.0" }, "devDependencies": { diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts new file mode 100644 index 0000000..d65fc03 --- /dev/null +++ b/src/Controllers/admin_auth.controller.ts @@ -0,0 +1,74 @@ +import { Request, Response } from "express"; +import { Admin } from ".prisma/client"; +import prisma from "../lib/prisma"; +import argon2 from "argon2"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "secret"; + +function createAdminJWT(admin: Admin) { + let payload: { name: string; permission_level: number; revision: string; group?: string } = { + name: admin.name, + permission_level: admin.permission_level, + revision: "", // TODO: Revision in the schema is pending + }; + + if (admin.permission_level > 0) { + payload = { ...payload, group: "" }; // TODO: Group association for admins in the schema is pending + } + + return jwt.sign(payload, JWT_SECRET, { expiresIn: "4 hours" }); +} + +interface AuthenticateUserBody { + name?: string; + password?: string; +} + +export const authenticateUser = async (req: Request<{}, {}, AuthenticateUserBody>, res: Response) => { + // TODO: Provide more useful error messages (Maybe express-validator?) + + 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", + }, + }); + } + + const user = await prisma.admin.findFirst({ where: { name } }); + + // REVIEW: It is possible to initiate a timing attack here (To see which users exist) + // This should, however, not be of too much concern, as one can basically do nothing + // with only the username + + if (!user) { + return res.status(404).json({ + type: "error", + payload: { + message: `The provided credentials are not valid`, + }, + }); + } + + const param_password_hash = await argon2.hash(password, { type: argon2.argon2id }); + + if (param_password_hash === user.password) { + return { + type: "success", + payload: { + token: createAdminJWT(user), + }, + }; + } + + return { + type: "error", + payload: { + message: "The provided credentials are not valid", + }, + }; +}; From eb64432ad45187b380238cd831b32416931c1c73 Mon Sep 17 00:00:00 2001 From: Laurin <60652077+Flexla54@users.noreply.github.com> Date: Wed, 10 Nov 2021 10:04:00 +0100 Subject: [PATCH 03/13] Admin + small changes --- prisma/schema.prisma | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9be7150..b0560ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,7 @@ model Event { pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid // Public key date DateTime name String + description String disciplines Discipline[] admins Admin[] @@ -24,6 +25,7 @@ model Event { model Campaign { id Int @id @default(autoincrement()) pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid + start DateTime expiresAt DateTime event Event @relation(fields: [eventId], references: [id]) @@ -38,7 +40,7 @@ model Link { campaign Campaign @relation(fields: [campaignId], references: [id]) campaignId Int - group Group @relation(fields: [groupId], references: [id]) + group Group? @relation(fields: [groupId], references: [id]) groupId Int @unique } @@ -48,6 +50,8 @@ model Admin { name String password String // TODO: Probably specify hash size (VarChar or some other type) permission_level AdminLevel @default(STANDARD) + revision String + groups Group[] event Event @relation(fields: [eventId], references: [id]) eventId Int @@ -127,6 +131,7 @@ model Group { oragnisationId Int participants Participant[] link Link? + admins Admin[] } enum AdminLevel { @@ -137,5 +142,5 @@ enum AdminLevel { enum Gender { MALE FEMALE - OTHERS + OTHER } \ No newline at end of file From 0eb284a00daa5dbeee44a61fc0dfb3aa0cbcab92 Mon Sep 17 00:00:00 2001 From: Laurin <60652077+Flexla54@users.noreply.github.com> Date: Wed, 10 Nov 2021 10:40:06 +0100 Subject: [PATCH 04/13] formated last commit --- prisma/schema.prisma | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0560ee..cabd3e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,10 +10,10 @@ generator client { } model Event { - id Int @id @default(autoincrement()) // Primary key - pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid // Public key - date DateTime - name String + id Int @id @default(autoincrement()) // Primary key + pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid // Public key + date DateTime + name String description String disciplines Discipline[] @@ -28,9 +28,9 @@ model Campaign { start DateTime expiresAt DateTime - event Event @relation(fields: [eventId], references: [id]) links Link[] - eventId Int @unique + event Event @relation(fields: [eventId], references: [id]) + eventId Int @unique } model Link { @@ -121,11 +121,11 @@ model Organisation { } model Group { - id Int @id @default(autoincrement()) - pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid - name String - user_limit Int @default(40) - level Int + id Int @id @default(autoincrement()) + pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + user_limit Int @default(40) + level Int oragnisation Organisation @relation(fields: [oragnisationId], references: [id]) oragnisationId Int From aec6604d24aacfc993d9a51655372ca59f3ee472 Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Wed, 10 Nov 2021 11:15:51 +0100 Subject: [PATCH 05/13] Update the code to use the latest database schema --- src/Controllers/admin_auth.controller.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts index d65fc03..f4672ea 100644 --- a/src/Controllers/admin_auth.controller.ts +++ b/src/Controllers/admin_auth.controller.ts @@ -1,23 +1,21 @@ import { Request, Response } from "express"; -import { Admin } from ".prisma/client"; +import { Admin, AdminLevel, Group } from "@prisma/client"; import prisma from "../lib/prisma"; import argon2 from "argon2"; import jwt from "jsonwebtoken"; const JWT_SECRET = process.env.JWT_SECRET || "secret"; +const TOKEN_EXPIRY = "4 days"; -function createAdminJWT(admin: Admin) { - let payload: { name: string; permission_level: number; revision: string; group?: string } = { +function createAdminJWT(admin: Admin & { groups: Group[] }) { + const payload: { name: string; permission_level: AdminLevel; revision: string; groups: string[] } = { name: admin.name, permission_level: admin.permission_level, - revision: "", // TODO: Revision in the schema is pending + revision: admin.revision, + groups: admin.groups.map((group) => group.pid), }; - if (admin.permission_level > 0) { - payload = { ...payload, group: "" }; // TODO: Group association for admins in the schema is pending - } - - return jwt.sign(payload, JWT_SECRET, { expiresIn: "4 hours" }); + return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }); } interface AuthenticateUserBody { @@ -39,7 +37,7 @@ export const authenticateUser = async (req: Request<{}, {}, AuthenticateUserBody }); } - const user = await prisma.admin.findFirst({ where: { name } }); + const user = await prisma.admin.findFirst({ where: { name }, include: { groups: true } }); // REVIEW: It is possible to initiate a timing attack here (To see which users exist) // This should, however, not be of too much concern, as one can basically do nothing From 5ee5218d5aa478a2a00e501a6ac4c4ee6f6c4b82 Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Wed, 10 Nov 2021 11:31:22 +0100 Subject: [PATCH 06/13] Use the description attribute in add and get controllers --- src/Controllers/event.controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Controllers/event.controller.ts b/src/Controllers/event.controller.ts index 2cdaba3..814766e 100644 --- a/src/Controllers/event.controller.ts +++ b/src/Controllers/event.controller.ts @@ -5,6 +5,7 @@ const getAllEvents = async (req: Request, res: Response) => { const events = await prisma.event.findMany({ select: { name: true, + description: true, date: true, pid: true, id: false, @@ -26,6 +27,7 @@ const addEvent = async (req: Request, res: Response) => { data: { name: req.body.name, date: req.body.date, + description: req.body.description, }, select: { name: true, From fc78f730bd83569571fe686d8f7d924494428acb Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Wed, 10 Nov 2021 12:08:07 +0100 Subject: [PATCH 07/13] Add redis + Add a public lib file for redis + Add an indexed auth client + Add a function to create promisified partial clients + Add password revisions to the auth redis index --- package.json | 4 +++- src/Controllers/admin_auth.controller.ts | 4 ++++ src/lib/redis.ts | 27 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/lib/redis.ts diff --git a/package.json b/package.json index 04bfa10..4166d94 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,13 @@ "@types/jsonwebtoken": "^8.5.5", "@types/node": "^16.10.3", "@types/nodemailer": "^6.4.4", + "@types/redis": "^2.8.32", "argon2": "^0.28.2", "dotenv": "^10.0.0", "express": "^4.17.1", "jsonwebtoken": "^8.5.1", - "nodemailer": "^6.7.0" + "nodemailer": "^6.7.0", + "redis": "^3.1.2" }, "devDependencies": { "@types/chai": "^4.2.22", diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts index f4672ea..5fb01fa 100644 --- a/src/Controllers/admin_auth.controller.ts +++ b/src/Controllers/admin_auth.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from "express"; import { Admin, AdminLevel, Group } from "@prisma/client"; +import { authClient } from "../lib/redis"; import prisma from "../lib/prisma"; import argon2 from "argon2"; import jwt from "jsonwebtoken"; @@ -55,6 +56,9 @@ export const authenticateUser = async (req: Request<{}, {}, AuthenticateUserBody const param_password_hash = await argon2.hash(password, { type: argon2.argon2id }); if (param_password_hash === user.password) { + // Set password revision ID in redis + await authClient.set(user.pid, user.revision); + return { type: "success", payload: { diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..afe01f5 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,27 @@ +import redis from "redis"; +import { promisify } from "util"; + +const REDIS_INDICES = { + auth: 0, +}; + +const REDIS_HOST = process.env.REDIS_HOST || "redis"; +const REDIS_PORT = process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : 6379; + +// TODO: Extend +function createAsyncClient(client: redis.RedisClient) { + return { + get: promisify(client.get).bind(client), + set: promisify(client.set).bind(client), + }; +} + +// ----------------- // +// Redis auth client // +// ----------------- // + +const auth = redis.createClient({ host: REDIS_HOST, port: REDIS_PORT }); + +auth.select(REDIS_INDICES.auth); + +export const authClient = createAsyncClient(auth); From 1b7a40cf7c21a9ef1ec6cacc90fc180c91cb21be Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Wed, 10 Nov 2021 15:11:29 +0100 Subject: [PATCH 08/13] Add the pid to the JWT payload --- src/Controllers/admin_auth.controller.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts index 5fb01fa..3a190b6 100644 --- a/src/Controllers/admin_auth.controller.ts +++ b/src/Controllers/admin_auth.controller.ts @@ -8,8 +8,17 @@ import jwt from "jsonwebtoken"; const JWT_SECRET = process.env.JWT_SECRET || "secret"; const TOKEN_EXPIRY = "4 days"; +export interface AuthJWTPayload { + pid: string; + name: string; + permission_level: AdminLevel; + revision: string; + groups: string[]; +} + function createAdminJWT(admin: Admin & { groups: Group[] }) { - const payload: { name: string; permission_level: AdminLevel; revision: string; groups: string[] } = { + const payload: AuthJWTPayload = { + pid: admin.pid, name: admin.name, permission_level: admin.permission_level, revision: admin.revision, From c869863aeae7969965409961c94870c3f8b179f2 Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Wed, 10 Nov 2021 16:04:26 +0100 Subject: [PATCH 09/13] Add middleware to require authentication on an endpoint --- src/Middleware/auth/auth.ts | 88 +++++++++++++++++++++++++++++++++++++ src/custom.d.ts | 7 +++ 2 files changed, 95 insertions(+) create mode 100644 src/Middleware/auth/auth.ts create mode 100644 src/custom.d.ts diff --git a/src/Middleware/auth/auth.ts b/src/Middleware/auth/auth.ts new file mode 100644 index 0000000..ae8e63b --- /dev/null +++ b/src/Middleware/auth/auth.ts @@ -0,0 +1,88 @@ +import { NextFunction, Request, Response } from "express"; +import { AuthJWTPayload } from "../../Controllers/admin_auth.controller"; +import { authClient } from "../../lib/redis"; +import jwt, { JsonWebTokenError, JwtPayload } from "jsonwebtoken"; +import prisma from "../../lib/prisma"; + +const JWT_SECRET = process.env.JWT_SECRET || "secret"; + +const verifyAuthorizationFormat = (authorization: string) => /^Bearer .+$/.test(authorization); + +const getBearerToken = (authorization: string) => authorization.slice(7); + +const requireAuthentication = async (req: Request, res: Response, next: NextFunction) => { + const { authorization } = req.headers; + + if (!authorization) { + return res.status(403).send({ + type: "error", + payload: { + message: "The requeset did not include the Authorization header", + }, + }); + } + + if (!verifyAuthorizationFormat(authorization)) { + return res.status(400).send({ + type: "error", + payload: { + message: "Malformed Authorization header", + format: "Bearer ", + }, + }); + } + + let token_payload_: string | JwtPayload; + + try { + token_payload_ = jwt.verify(getBearerToken(authorization), JWT_SECRET); + } catch (e) { + if (e instanceof JsonWebTokenError) { + return res.status(403).json({ + type: "error", + payload: { + message: "Token could not be verified; It might be expired", + }, + }); + } + + throw e; + } + + const token_payload = token_payload_ as AuthJWTPayload; + + const { pid, revision } = token_payload; + + let db_revision = await authClient.get(pid); + + if (db_revision === null) { + // Load the revision ID from the main DB and cache it in redis + const user = await prisma.admin.findUnique({ where: { pid }, select: { revision: true } }); + + if (user) { + db_revision = user.revision; + + await authClient.set(pid, db_revision); + } + } + + if (revision !== db_revision || !revision || !db_revision) { + return res.status(403).json({ + type: "error", + payload: { + message: "Token could not be verified; It might be expired", + }, + }); + } + + req.auth = { + isAuthenticated: true, + pid: token_payload.pid, + name: token_payload.name, + permission_level: token_payload.permission_level, + groups: token_payload.groups, + revision: token_payload.revision, + }; + + next(); +}; diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 0000000..bb6296a --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,7 @@ +import { AuthJWTPayload } from "./Controllers/admin_auth.controller"; + +declare module "express-serve-static-core" { + interface Request { + auth?: AuthJWTPayload & { isAuthenticated: boolean }; + } +} From 60f7c8fff525325fab838804d2668d491ae40550 Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Wed, 10 Nov 2021 16:12:55 +0100 Subject: [PATCH 10/13] Export requireAuthentication --- src/Middleware/auth/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Middleware/auth/auth.ts b/src/Middleware/auth/auth.ts index ae8e63b..cac4a95 100644 --- a/src/Middleware/auth/auth.ts +++ b/src/Middleware/auth/auth.ts @@ -10,7 +10,7 @@ const verifyAuthorizationFormat = (authorization: string) => /^Bearer .+$/.test( const getBearerToken = (authorization: string) => authorization.slice(7); -const requireAuthentication = async (req: Request, res: Response, next: NextFunction) => { +export const requireAuthentication = async (req: Request, res: Response, next: NextFunction) => { const { authorization } = req.headers; if (!authorization) { From 4fd8a4c978b2746d1346ae5627ec776414af6326 Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Thu, 11 Nov 2021 11:57:36 +0100 Subject: [PATCH 11/13] Add reference to custom Request typings --- src/Middleware/auth/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Middleware/auth/auth.ts b/src/Middleware/auth/auth.ts index cac4a95..37853b5 100644 --- a/src/Middleware/auth/auth.ts +++ b/src/Middleware/auth/auth.ts @@ -1,3 +1,5 @@ +/// + import { NextFunction, Request, Response } from "express"; import { AuthJWTPayload } from "../../Controllers/admin_auth.controller"; import { authClient } from "../../lib/redis"; From 6c535b16d1829ce1e57dd838eb31cb76afa4cb05 Mon Sep 17 00:00:00 2001 From: Stephan <57194608+stephan418@users.noreply.github.com> Date: Thu, 11 Nov 2021 12:02:48 +0100 Subject: [PATCH 12/13] Bugfix authentication controller --- src/Controllers/admin_auth.controller.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts index 3a190b6..97161d9 100644 --- a/src/Controllers/admin_auth.controller.ts +++ b/src/Controllers/admin_auth.controller.ts @@ -36,7 +36,7 @@ interface AuthenticateUserBody { export const authenticateUser = async (req: Request<{}, {}, AuthenticateUserBody>, res: Response) => { // TODO: Provide more useful error messages (Maybe express-validator?) - const { name, password } = req.body; + const { name, password } = req.body || {}; if (name == null || password == null) { return res.status(400).json({ @@ -62,24 +62,22 @@ export const authenticateUser = async (req: Request<{}, {}, AuthenticateUserBody }); } - const param_password_hash = await argon2.hash(password, { type: argon2.argon2id }); - - if (param_password_hash === user.password) { + if (await argon2.verify(user.password, password, { type: argon2.argon2id })) { // Set password revision ID in redis await authClient.set(user.pid, user.revision); - return { + return res.status(200).json({ type: "success", payload: { token: createAdminJWT(user), }, - }; + }); } - return { + return res.status(403).json({ type: "error", payload: { message: "The provided credentials are not valid", }, - }; + }); }; From fcde6f222222efa1c3f40bafb9ce467c06734e4d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Nov 2021 15:39:04 +0100 Subject: [PATCH 13/13] [create-pull-request] push formatted files (#19) --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index cef3d4b..dc6dfea 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,4 +30,4 @@ services: - PORT=${PORT:-3000} - DATABASE_URL=postgresql://server:${DATABASE_PASSWORD}@postgres:5432/management?schema=public - DATABASE_USER=server - - DATABASE_PASSWORD=${DATABASE_PASSWORD} \ No newline at end of file + - DATABASE_PASSWORD=${DATABASE_PASSWORD}