diff --git a/docker-compose.yml b/docker-compose.yml index 72d2e5c..dc6dfea 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,9 @@ services: - POSTGRES_USER=server - POSTGRES_PASSWORD=${DATABASE_PASSWORD} container_name: "postgres" + redis: + image: redis + restart: always server: build: . restart: always diff --git a/package.json b/package.json index c5b93b5..4166d94 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,16 @@ "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", + "@types/redis": "^2.8.32", + "argon2": "^0.28.2", "dotenv": "^10.0.0", "express": "^4.17.1", - "nodemailer": "^6.7.0" + "jsonwebtoken": "^8.5.1", + "nodemailer": "^6.7.0", + "redis": "^3.1.2" }, "devDependencies": { "@types/chai": "^4.2.22", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9be7150..cabd3e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,10 +10,11 @@ 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[] admins Admin[] @@ -24,11 +25,12 @@ 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]) links Link[] - eventId Int @unique + event Event @relation(fields: [eventId], references: [id]) + eventId Int @unique } model Link { @@ -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 @@ -117,16 +121,17 @@ 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 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 diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts new file mode 100644 index 0000000..97161d9 --- /dev/null +++ b/src/Controllers/admin_auth.controller.ts @@ -0,0 +1,83 @@ +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"; + +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: AuthJWTPayload = { + pid: admin.pid, + name: admin.name, + permission_level: admin.permission_level, + revision: admin.revision, + groups: admin.groups.map((group) => group.pid), + }; + + return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }); +} + +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 }, 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 + // with only the username + + if (!user) { + return res.status(404).json({ + type: "error", + payload: { + message: `The provided credentials are not valid`, + }, + }); + } + + if (await argon2.verify(user.password, password, { type: argon2.argon2id })) { + // Set password revision ID in redis + await authClient.set(user.pid, user.revision); + + return res.status(200).json({ + type: "success", + payload: { + token: createAdminJWT(user), + }, + }); + } + + return res.status(403).json({ + type: "error", + payload: { + message: "The provided credentials are not valid", + }, + }); +}; 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, diff --git a/src/Middleware/auth/auth.ts b/src/Middleware/auth/auth.ts new file mode 100644 index 0000000..37853b5 --- /dev/null +++ b/src/Middleware/auth/auth.ts @@ -0,0 +1,90 @@ +/// + +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); + +export 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 }; + } +} 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);