mirror of
https://github.com/detleph/server.git
synced 2026-09-04 08:36:06 +02:00
Merge pull request #18 from detleph/feature-user-auth
Add user authentication
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { AuthJWTPayload } from "./Controllers/admin_auth.controller";
|
||||||
|
|
||||||
|
declare module "express-serve-static-core" {
|
||||||
|
interface Request {
|
||||||
|
auth?: AuthJWTPayload & { isAuthenticated: boolean };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user