Merge pull request #18 from detleph/feature-user-auth

Add user authentication
This commit is contained in:
Stephan
2021-11-17 15:45:10 +01:00
committed by GitHub
8 changed files with 236 additions and 14 deletions
+3
View File
@@ -17,6 +17,9 @@ services:
- POSTGRES_USER=server - POSTGRES_USER=server
- POSTGRES_PASSWORD=${DATABASE_PASSWORD} - POSTGRES_PASSWORD=${DATABASE_PASSWORD}
container_name: "postgres" container_name: "postgres"
redis:
image: redis
restart: always
server: server:
build: . build: .
restart: always restart: always
+6 -1
View File
@@ -20,11 +20,16 @@
"homepage": "https://github.com/detleph/server#readme", "homepage": "https://github.com/detleph/server#readme",
"dependencies": { "dependencies": {
"@prisma/client": "^3.3.0", "@prisma/client": "^3.3.0",
"@types/jsonwebtoken": "^8.5.5",
"@types/node": "^16.10.3", "@types/node": "^16.10.3",
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/redis": "^2.8.32",
"argon2": "^0.28.2",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"express": "^4.17.1", "express": "^4.17.1",
"nodemailer": "^6.7.0" "jsonwebtoken": "^8.5.1",
"nodemailer": "^6.7.0",
"redis": "^3.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.2.22", "@types/chai": "^4.2.22",
+18 -13
View File
@@ -10,10 +10,11 @@ generator client {
} }
model Event { model Event {
id Int @id @default(autoincrement()) // Primary key id Int @id @default(autoincrement()) // Primary key
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid // Public key pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid // Public key
date DateTime date DateTime
name String name String
description String
disciplines Discipline[] disciplines Discipline[]
admins Admin[] admins Admin[]
@@ -24,11 +25,12 @@ model Event {
model Campaign { model Campaign {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
start DateTime
expiresAt DateTime expiresAt DateTime
event Event @relation(fields: [eventId], references: [id])
links Link[] links Link[]
eventId Int @unique event Event @relation(fields: [eventId], references: [id])
eventId Int @unique
} }
model Link { model Link {
@@ -38,7 +40,7 @@ model Link {
campaign Campaign @relation(fields: [campaignId], references: [id]) campaign Campaign @relation(fields: [campaignId], references: [id])
campaignId Int campaignId Int
group Group @relation(fields: [groupId], references: [id]) group Group? @relation(fields: [groupId], references: [id])
groupId Int @unique groupId Int @unique
} }
@@ -48,6 +50,8 @@ model Admin {
name String name String
password String // TODO: Probably specify hash size (VarChar or some other type) password String // TODO: Probably specify hash size (VarChar or some other type)
permission_level AdminLevel @default(STANDARD) permission_level AdminLevel @default(STANDARD)
revision String
groups Group[]
event Event @relation(fields: [eventId], references: [id]) event Event @relation(fields: [eventId], references: [id])
eventId Int eventId Int
@@ -117,16 +121,17 @@ model Organisation {
} }
model Group { model Group {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String name String
user_limit Int @default(40) user_limit Int @default(40)
level Int level Int
oragnisation Organisation @relation(fields: [oragnisationId], references: [id]) oragnisation Organisation @relation(fields: [oragnisationId], references: [id])
oragnisationId Int oragnisationId Int
participants Participant[] participants Participant[]
link Link? link Link?
admins Admin[]
} }
enum AdminLevel { enum AdminLevel {
@@ -137,5 +142,5 @@ enum AdminLevel {
enum Gender { enum Gender {
MALE MALE
FEMALE FEMALE
OTHERS OTHER
} }
+83
View File
@@ -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",
},
});
};
+2
View File
@@ -5,6 +5,7 @@ const getAllEvents = async (req: Request, res: Response) => {
const events = await prisma.event.findMany({ const events = await prisma.event.findMany({
select: { select: {
name: true, name: true,
description: true,
date: true, date: true,
pid: true, pid: true,
id: false, id: false,
@@ -26,6 +27,7 @@ const addEvent = async (req: Request, res: Response) => {
data: { data: {
name: req.body.name, name: req.body.name,
date: req.body.date, date: req.body.date,
description: req.body.description,
}, },
select: { select: {
name: true, name: true,
+90
View File
@@ -0,0 +1,90 @@
/// <reference path="../../custom.d.ts" />
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 <token>",
},
});
}
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();
};
+7
View File
@@ -0,0 +1,7 @@
import { AuthJWTPayload } from "./Controllers/admin_auth.controller";
declare module "express-serve-static-core" {
interface Request {
auth?: AuthJWTPayload & { isAuthenticated: boolean };
}
}
+27
View File
@@ -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);