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);