From 2f5d9d3f0160f6b247299e309922f3308d893673 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Mon, 26 Aug 2024 13:47:08 +0200 Subject: [PATCH] permission system - permission formatting --- src/command/permissionCommand.ts | 10 ++ src/command/permissionCommandHandler.ts | 48 ++++++++ src/controller/authController.ts | 30 +++-- src/controller/inviteController.ts | 19 ++- src/data-source.ts | 8 +- src/entity/permission.ts | 15 +++ src/exceptions/forbiddenRequestException.ts | 7 ++ src/helpers/permissionHelper.ts | 124 ++++++++++++++++++++ src/index.ts | 3 +- src/migrations/1724661484664-permissions.ts | 46 ++++++++ src/routes/setup.ts | 2 +- src/service/permissionService.ts | 24 ++++ src/type/jwtTypes.ts | 9 +- src/type/permissionTypes.ts | 24 ++++ src/viewmodel/permissionViewModel.ts | 1 + 15 files changed, 352 insertions(+), 18 deletions(-) create mode 100644 src/command/permissionCommand.ts create mode 100644 src/command/permissionCommandHandler.ts create mode 100644 src/entity/permission.ts create mode 100644 src/exceptions/forbiddenRequestException.ts create mode 100644 src/helpers/permissionHelper.ts create mode 100644 src/migrations/1724661484664-permissions.ts create mode 100644 src/service/permissionService.ts create mode 100644 src/type/permissionTypes.ts create mode 100644 src/viewmodel/permissionViewModel.ts diff --git a/src/command/permissionCommand.ts b/src/command/permissionCommand.ts new file mode 100644 index 0000000..abc7e30 --- /dev/null +++ b/src/command/permissionCommand.ts @@ -0,0 +1,10 @@ +import { PermissionString } from "../type/permissionTypes"; + +export interface CreatePermissionCommand { + permission: PermissionString; + userId: number; +} + +export interface DeletePermissionCommand { + id: number; +} diff --git a/src/command/permissionCommandHandler.ts b/src/command/permissionCommandHandler.ts new file mode 100644 index 0000000..7e9b5a0 --- /dev/null +++ b/src/command/permissionCommandHandler.ts @@ -0,0 +1,48 @@ +import { dataSource } from "../data-source"; +import { permission } from "../entity/permission"; +import InternalException from "../exceptions/internalException"; +import UserService from "../service/userService"; +import { CreatePermissionCommand, DeletePermissionCommand } from "./permissionCommand"; + +export default abstract class PermissionCommandHandler { + /** + * @description grant permission to user + * @param CreatePermissionCommand + * @returns {Promise} + */ + static async create(createPermission: CreatePermissionCommand): Promise { + return await dataSource + .createQueryBuilder() + .insert() + .into(permission) + .values({ + permission: createPermission.permission, + user: await UserService.getById(createPermission.userId), + }) + .execute() + .then((result) => { + return result.identifiers[0].id; + }) + .catch((err) => { + throw new InternalException("Failed saving permission"); + }); + } + + /** + * @description remove permission to user + * @param DeletePermissionCommand + * @returns {Promise} + */ + static async deleteByToken(deletePermission: DeletePermissionCommand): Promise { + return await dataSource + .createQueryBuilder() + .delete() + .from(permission) + .where("permission.id = :id", { id: deletePermission.id }) + .execute() + .then((res) => {}) + .catch((err) => { + throw new InternalException("failed permission removal"); + }); + } +} diff --git a/src/controller/authController.ts b/src/controller/authController.ts index b77d9ba..f6d15b9 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -1,17 +1,15 @@ import { Request, Response } from "express"; import { JWTHelper } from "../helpers/jwtHelper"; -import { JWTData, JWTToken } from "../type/jwtTypes"; +import { JWTToken } from "../type/jwtTypes"; import InternalException from "../exceptions/internalException"; import RefreshCommandHandler from "../command/refreshCommandHandler"; import { CreateRefreshCommand, DeleteRefreshCommand } from "../command/refreshCommand"; import UserService from "../service/userService"; import speakeasy from "speakeasy"; import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; -import QRCode from "qrcode"; -import { CreateUserCommand } from "../command/userCommand"; -import UserCommandHandler from "../command/userCommandHandler"; import RefreshService from "../service/refreshService"; -import BadRequestException from "../exceptions/badRequestException"; +import PermissionService from "../service/permissionService"; +import PermissionHelper from "../helpers/permissionHelper"; /** * @description Check authentication status by token @@ -23,7 +21,7 @@ export async function login(req: Request, res: Response): Promise { let username = req.body.username; let totp = req.body.totp; - let { id, secret } = await UserService.getByUsername(username); + let { id, secret, mail, firstname, lastname } = await UserService.getByUsername(username); let valid = speakeasy.totp.verify({ secret: secret, @@ -36,10 +34,17 @@ export async function login(req: Request, res: Response): Promise { throw new UnauthorizedRequestException("Token not valid or expired"); } + let permissions = await PermissionService.getByUser(id); + let permissionStrings = permissions.map((e) => e.permission); + let permissionObject = PermissionHelper.convertToObject(permissionStrings); + let jwtData: JWTToken = { userId: id, + mail: mail, username: username, - rights: [], + firstname: firstname, + lastname: lastname, + permissions: permissionObject, }; let accessToken: string; @@ -96,12 +101,19 @@ export async function refresh(req: Request, res: Response): Promise { throw new UnauthorizedRequestException("user not identified with token and refresh"); } - let { id, username } = await UserService.getById(tokenUserId); + let { id, username, mail, firstname, lastname } = await UserService.getById(tokenUserId); + + let permissions = await PermissionService.getByUser(id); + let permissionStrings = permissions.map((e) => e.permission); + let permissionObject = PermissionHelper.convertToObject(permissionStrings); let jwtData: JWTToken = { userId: id, + mail: mail, username: username, - rights: [], + firstname: firstname, + lastname: lastname, + permissions: permissionObject, }; let accessToken: string; diff --git a/src/controller/inviteController.ts b/src/controller/inviteController.ts index 6fd709c..23c1993 100644 --- a/src/controller/inviteController.ts +++ b/src/controller/inviteController.ts @@ -16,6 +16,8 @@ import InviteService from "../service/inviteService"; import UserService from "../service/userService"; import CustomRequestException from "../exceptions/customRequestException"; import { CLUB_NAME } from "../env.defaults"; +import { CreatePermissionCommand } from "../command/permissionCommand"; +import PermissionCommandHandler from "../command/permissionCommandHandler"; /** * @description start first user @@ -98,7 +100,7 @@ export async function verifyInvite(req: Request, res: Response): Promise { * @param res {Response} Express res object * @returns {Promise<*>} */ -export async function finishInvite(req: Request, res: Response): Promise { +export async function finishInvite(req: Request, res: Response, grantAdmin: boolean = false): Promise { let mail = req.body.mail; let token = req.body.token; let totp = req.body.totp; @@ -127,10 +129,23 @@ export async function finishInvite(req: Request, res: Response): Promise { }; let id = await UserCommandHandler.create(createUser); + if (grantAdmin) { + let createPermission: CreatePermissionCommand = { + permission: "*", + userId: id, + }; + await PermissionCommandHandler.create(createPermission); + } + let jwtData: JWTToken = { userId: id, + mail: mail, username: username, - rights: [], + firstname: firstname, + lastname: lastname, + permissions: { + ...(grantAdmin ? { admin: true } : {}), + }, }; let accessToken: string; diff --git a/src/data-source.ts b/src/data-source.ts index 1fea665..998f766 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -1,15 +1,17 @@ import "dotenv/config"; import "reflect-metadata"; import { DataSource } from "typeorm"; +import { DB_HOST, DB_USERNAME, DB_PASSWORD, DB_NAME } from "./env.defaults"; import { user } from "./entity/user"; import { refresh } from "./entity/refresh"; import { invite } from "./entity/invite"; +import { permission } from "./entity/permission"; import { Initial1724317398939 } from "./migrations/1724317398939-initial"; import { RefreshPrimaryChange1724573307851 } from "./migrations/1724573307851-refreshPrimaryChange"; import { Invite1724579024939 } from "./migrations/1724579024939-invite"; -import { DB_HOST, DB_USERNAME, DB_PASSWORD, DB_NAME } from "./env.defaults"; +import { Permissions1724661484664 } from "./migrations/1724661484664-permissions"; const dataSource = new DataSource({ type: "mysql", @@ -21,8 +23,8 @@ const dataSource = new DataSource({ synchronize: false, logging: process.env.NODE_ENV ? true : ["schema", "error", "warn", "log", "migration"], bigNumberStrings: false, - entities: [user, refresh, invite], - migrations: [Initial1724317398939, RefreshPrimaryChange1724573307851, Invite1724579024939], + entities: [user, refresh, invite, permission], + migrations: [Initial1724317398939, RefreshPrimaryChange1724573307851, Invite1724579024939, Permissions1724661484664], migrationsRun: true, migrationsTransactionMode: "each", subscribers: [], diff --git a/src/entity/permission.ts b/src/entity/permission.ts new file mode 100644 index 0000000..4b642e5 --- /dev/null +++ b/src/entity/permission.ts @@ -0,0 +1,15 @@ +import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm"; +import { user } from "./user"; +import { PermissionObject, PermissionString } from "../type/permissionTypes"; + +@Entity() +export class permission { + @PrimaryColumn({ type: "int" }) + userId: number; + + @PrimaryColumn({ type: "varchar", length: 255 }) + permission: PermissionString; + + @ManyToOne(() => user) + user: user; +} diff --git a/src/exceptions/forbiddenRequestException.ts b/src/exceptions/forbiddenRequestException.ts new file mode 100644 index 0000000..9fe90e1 --- /dev/null +++ b/src/exceptions/forbiddenRequestException.ts @@ -0,0 +1,7 @@ +import CustomRequestException from "./customRequestException"; + +export default class ForbiddenRequestException extends CustomRequestException { + constructor(msg: string) { + super(403, msg); + } +} diff --git a/src/helpers/permissionHelper.ts b/src/helpers/permissionHelper.ts new file mode 100644 index 0000000..9659ad0 --- /dev/null +++ b/src/helpers/permissionHelper.ts @@ -0,0 +1,124 @@ +import { Request, Response } from "express"; +import { + PermissionModule, + permissionModules, + PermissionObject, + PermissionSection, + PermissionString, + PermissionType, + permissionTypes, +} from "../type/permissionTypes"; +import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; + +export default class PermissionHelper { + static passCheckMiddleware( + section: PermissionSection, + module: PermissionModule, + requiredPermissions: Array | "*" + ): (req: Request, res: Response, next: Function) => void { + return (req: Request, res: Response, next: Function) => { + const permissions = req.rights; + + if (permissions.admin) { + next(); + } else if (permissions?.[section]?.all) { + next(); + } else if (permissions?.[section]?.all) { + next(); + } else if (permissions?.[section]?.[module] == "*") { + next(); + } else if ( + (permissions?.[section]?.[module] as Array).some((e: PermissionType) => + requiredPermissions.includes(e) + ) + ) { + next(); + } else { + throw new ForbiddenRequestException( + `missing permission for ${section}.${module}.${ + Array.isArray(requiredPermissions) ? requiredPermissions.join("|") : requiredPermissions + }` + ); + } + }; + } + + static convertToObject(permissions: Array): PermissionObject { + if (permissions.includes("*")) { + return { + admin: true, + }; + } + let output: PermissionObject = {}; + let splitPermissions = permissions.map((e) => e.split(".")) as Array< + [PermissionSection, PermissionModule | PermissionType | "*", PermissionType | "*"] + >; + for (let split of splitPermissions) { + if (!output[split[0]]) { + output[split[0]] = {}; + } + if (split[1] == "*" || output[split[0]].all == "*") { + output[split[0]] = { all: "*" }; + } else if (permissionTypes.includes(split[1] as PermissionType)) { + if (!output[split[0]].all || !Array.isArray(output[split[0]].all)) { + output[split[0]].all = []; + } + const permissionIndex = permissionTypes.indexOf(split[1] as PermissionType); + const appliedPermissions = permissionTypes.slice(0, permissionIndex + 1); + output[split[0]].all = appliedPermissions; + } else { + if (split[2] == "*" || output[split[0]][split[1] as PermissionModule] == "*") { + output[split[0]][split[1] as PermissionModule] = "*"; + } else { + if ( + !output[split[0]][split[1] as PermissionModule] || + !Array.isArray(output[split[0]][split[1] as PermissionModule]) + ) { + output[split[0]][split[1] as PermissionModule] = []; + } + const permissionIndex = permissionTypes.indexOf(split[2] as PermissionType); + const appliedPermissions = permissionTypes.slice(0, permissionIndex + 1); + output[split[0]][split[1] as PermissionModule] = appliedPermissions; + } + } + } + return output; + } + + static convertToStringArray(permissions: PermissionObject): Array { + if (permissions.admin) { + return ["*"]; + } + let output: Array = []; + let sections = Object.keys(permissions) as Array; + for (let section of sections) { + if (permissions[section].all) { + let types = permissions[section].all; + if (types == "*") { + output.push(`${section}.*`); + } else { + for (let type of types) { + output.push(`${section}.${type}`); + } + } + } else { + let modules = Object.keys(permissions[section]) as Array; + for (let module of modules) { + let types = permissions[section][module]; + if (types == "*") { + output.push(`${section}.${module}.*`); + } else { + for (let type of types) { + output.push(`${section}.${module}.${type}`); + } + } + } + } + } + return output; + } + + static getWhatToAdd() {} + + static getWhatToRemove() {} +} diff --git a/src/index.ts b/src/index.ts index 0c5333e..3bd269a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ declare global { export interface Request { userId: string; username: string; - rights: Array; + rights: PermissionObject; } } } @@ -20,6 +20,7 @@ dataSource.initialize(); const app = express(); import router from "./routes/index"; +import { PermissionObject } from "./type/permissionTypes"; router(app); app.listen(SERVER_PORT, () => { console.log(`listening on *:${SERVER_PORT}`); diff --git a/src/migrations/1724661484664-permissions.ts b/src/migrations/1724661484664-permissions.ts new file mode 100644 index 0000000..a54686f --- /dev/null +++ b/src/migrations/1724661484664-permissions.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from "typeorm"; + +export class Permissions1724661484664 implements MigrationInterface { + name = "Permissions1724661484664"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "permission", + columns: [ + { + name: "permission", + type: "varchar", + length: "255", + isPrimary: true, + isNullable: false, + }, + { + name: "userId", + type: "int", + isPrimary: true, + isNullable: false, + }, + ], + }), + true + ); + + await queryRunner.createForeignKey( + "permission", + new TableForeignKey({ + columnNames: ["userId"], + referencedColumnNames: ["id"], + referencedTableName: "user", + onDelete: "No Action", + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable("permission"); + const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.indexOf("userId") !== -1); + await queryRunner.dropForeignKey("permission", foreignKey); + await queryRunner.dropTable("permission"); + } +} diff --git a/src/routes/setup.ts b/src/routes/setup.ts index 9f228ae..b603488 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -22,7 +22,7 @@ router.post( ); router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishInvite(req, res); + await finishInvite(req, res, true); }); export default router; diff --git a/src/service/permissionService.ts b/src/service/permissionService.ts new file mode 100644 index 0000000..ad5a50a --- /dev/null +++ b/src/service/permissionService.ts @@ -0,0 +1,24 @@ +import { dataSource } from "../data-source"; +import { permission } from "../entity/permission"; +import InternalException from "../exceptions/internalException"; + +export default abstract class PermissionService { + /** + * @description get permission by user + * @param user number + * @returns {Promise>} + */ + static async getByUser(userId: number): Promise> { + return await dataSource + .getRepository(permission) + .createQueryBuilder("permission") + .where("permission.userId = :userId", { userId: userId }) + .getMany() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("permission not found by user"); + }); + } +} diff --git a/src/type/jwtTypes.ts b/src/type/jwtTypes.ts index f6ce334..83e9a44 100644 --- a/src/type/jwtTypes.ts +++ b/src/type/jwtTypes.ts @@ -1,11 +1,16 @@ +import { PermissionObject } from "./permissionTypes"; + export type JWTData = { - [key: string]: string | number | Array; + [key: string]: string | number | PermissionObject; }; export type JWTToken = { userId: number; + mail: string; username: string; - rights: Array; + firstname: string; + lastname: string; + permissions: PermissionObject; } & JWTData; export type JWTRefresh = { diff --git a/src/type/permissionTypes.ts b/src/type/permissionTypes.ts new file mode 100644 index 0000000..ddac15e --- /dev/null +++ b/src/type/permissionTypes.ts @@ -0,0 +1,24 @@ +export type PermissionSection = "club" | "settings" | "user"; + +export type PermissionModule = "protocoll" | "user"; + +export type PermissionType = "read" | "create" | "update" | "delete"; + +export type PermissionString = + | `${PermissionSection}.${PermissionModule}.${PermissionType}` // für spezifische Berechtigungen + | `${PermissionSection}.${PermissionModule}.*` // für alle Berechtigungen in einem Modul + | `${PermissionSection}.${PermissionType}` // für spezifische Berechtigungen in einem Abschnitt + | `${PermissionSection}.*` // für alle Berechtigungen in einem Abschnitt + | "*"; // für Admin + +export type PermissionObject = { + [section in PermissionSection]?: { + [module in PermissionModule]?: Array | "*"; + } & { all?: Array | "*" }; +} & { + admin?: boolean; +}; + +export const permissionSections: Array = ["club", "settings", "user"]; +export const permissionModules: Array = ["protocoll", "user"]; +export const permissionTypes: Array = ["read", "create", "update", "delete"]; diff --git a/src/viewmodel/permissionViewModel.ts b/src/viewmodel/permissionViewModel.ts new file mode 100644 index 0000000..7a7f8fb --- /dev/null +++ b/src/viewmodel/permissionViewModel.ts @@ -0,0 +1 @@ +export interface PermissionViewModel {}