From e2b46becf06651123469110062ce44f4ccd696ee Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Mon, 7 Oct 2024 18:09:27 +0200 Subject: [PATCH 1/4] ownership --- src/command/userCommand.ts | 1 + src/command/userCommandHandler.ts | 1 + src/controller/authController.ts | 6 ++- src/controller/inviteController.ts | 10 +--- src/data-source.ts | 2 + src/entity/user.ts | 3 ++ src/factory/admin/user.ts | 1 + src/helpers/permissionHelper.ts | 6 ++- src/index.ts | 1 + src/middleware/authenticate.ts | 1 + src/migrations/1728313041449-ownership.ts | 56 +++++++++++++++++++++++ src/type/jwtTypes.ts | 3 +- src/viewmodel/admin/user.models.ts | 1 + 13 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 src/migrations/1728313041449-ownership.ts diff --git a/src/command/userCommand.ts b/src/command/userCommand.ts index a084115..634a993 100644 --- a/src/command/userCommand.ts +++ b/src/command/userCommand.ts @@ -4,6 +4,7 @@ export interface CreateUserCommand { firstname: string; lastname: string; secret: string; + isOwner: boolean; } export interface UpdateUserCommand { diff --git a/src/command/userCommandHandler.ts b/src/command/userCommandHandler.ts index 79834e2..876db24 100644 --- a/src/command/userCommandHandler.ts +++ b/src/command/userCommandHandler.ts @@ -22,6 +22,7 @@ export default abstract class UserCommandHandler { firstname: createUser.firstname, lastname: createUser.lastname, secret: createUser.secret, + isOwner: createUser.isOwner, }) .execute() .then((result) => { diff --git a/src/controller/authController.ts b/src/controller/authController.ts index cbd1a1d..f68aab2 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -22,7 +22,7 @@ export async function login(req: Request, res: Response): Promise { let username = req.body.username; let totp = req.body.totp; - let { id, secret, mail, firstname, lastname } = await UserService.getByUsername(username); + let { id, secret, mail, firstname, lastname, isOwner } = await UserService.getByUsername(username); let valid = speakeasy.totp.verify({ secret: secret, @@ -48,6 +48,7 @@ export async function login(req: Request, res: Response): Promise { username: username, firstname: firstname, lastname: lastname, + isOwner: isOwner, permissions: permissionObject, }; @@ -105,7 +106,7 @@ export async function refresh(req: Request, res: Response): Promise { throw new UnauthorizedRequestException("user not identified with token and refresh"); } - let { id, username, mail, firstname, lastname } = await UserService.getById(tokenUserId); + let { id, username, mail, firstname, lastname, isOwner } = await UserService.getById(tokenUserId); let permissions = await UserPermissionService.getByUser(id); let permissionStrings = permissions.map((e) => e.permission); @@ -117,6 +118,7 @@ export async function refresh(req: Request, res: Response): Promise { username: username, firstname: firstname, lastname: lastname, + isOwner: isOwner, permissions: permissionObject, }; diff --git a/src/controller/inviteController.ts b/src/controller/inviteController.ts index f9be5b3..0b2b207 100644 --- a/src/controller/inviteController.ts +++ b/src/controller/inviteController.ts @@ -124,23 +124,17 @@ export async function finishInvite(req: Request, res: Response, grantAdmin: bool lastname: lastname, mail: mail, secret: secret, + isOwner: grantAdmin, }; let id = await UserCommandHandler.create(createUser); - if (grantAdmin) { - let createPermission: CreateUserPermissionCommand = { - permission: "*", - userId: id, - }; - await UserPermissionCommandHandler.create(createPermission); - } - let jwtData: JWTToken = { userId: id, mail: mail, username: username, firstname: firstname, lastname: lastname, + isOwner: grantAdmin, permissions: { ...(grantAdmin ? { admin: true } : {}), }, diff --git a/src/data-source.ts b/src/data-source.ts index a78ab3c..2701efb 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -29,6 +29,7 @@ import { memberQualifications } from "./entity/memberQualifications"; import { membership } from "./entity/membership"; import { Memberdata1726301836849 } from "./migrations/1726301836849-memberdata"; import { CommunicationFields1727439800630 } from "./migrations/1727439800630-communicationFields"; +import { Ownership1728313041449 } from "./migrations/1728313041449-ownership"; const dataSource = new DataSource({ type: DB_TYPE as any, @@ -68,6 +69,7 @@ const dataSource = new DataSource({ MemberBaseData1725435669492, Memberdata1726301836849, CommunicationFields1727439800630, + Ownership1728313041449, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/user.ts b/src/entity/user.ts index fcb31c6..09435cd 100644 --- a/src/entity/user.ts +++ b/src/entity/user.ts @@ -22,6 +22,9 @@ export class user { @Column({ type: "varchar", length: 255 }) secret: string; + @Column({ type: "boolean", default: false }) + isOwner: boolean; + @ManyToMany(() => role, (role) => role.users, { nullable: false, onDelete: "CASCADE", diff --git a/src/factory/admin/user.ts b/src/factory/admin/user.ts index 2f7098c..ca8524f 100644 --- a/src/factory/admin/user.ts +++ b/src/factory/admin/user.ts @@ -21,6 +21,7 @@ export default abstract class UserFactory { firstname: record.firstname, lastname: record.lastname, mail: record.mail, + isOwner: record.isOwner, permissions: PermissionHelper.convertToObject(userPermissionStrings), roles: RoleFactory.mapToBase(record.roles), permissions_total: totalPermissions, diff --git a/src/helpers/permissionHelper.ts b/src/helpers/permissionHelper.ts index 2eb4def..63edffa 100644 --- a/src/helpers/permissionHelper.ts +++ b/src/helpers/permissionHelper.ts @@ -55,8 +55,9 @@ export default class PermissionHelper { ): (req: Request, res: Response, next: Function) => void { return (req: Request, res: Response, next: Function) => { const permissions = req.permissions; + const isOwner = req.isOwner; - if (this.can(permissions, requiredPermissions, section, module)) { + if (isOwner || this.can(permissions, requiredPermissions, section, module)) { next(); } else { throw new ForbiddenRequestException( @@ -74,8 +75,9 @@ export default class PermissionHelper { ): (req: Request, res: Response, next: Function) => void { return (req: Request, res: Response, next: Function) => { const permissions = req.permissions; + const isOwner = req.isOwner; - if (this.canSection(permissions, requiredPermissions, section)) { + if (isOwner || this.canSection(permissions, requiredPermissions, section)) { next(); } else { throw new ForbiddenRequestException( diff --git a/src/index.ts b/src/index.ts index d551a1e..6fd0f99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ declare global { export interface Request { userId: string; username: string; + isOwner: boolean; permissions: PermissionObject; } } diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts index a62c689..cfa1f56 100644 --- a/src/middleware/authenticate.ts +++ b/src/middleware/authenticate.ts @@ -31,6 +31,7 @@ export default async function authenticate(req: Request, res: Response, next: Fu req.userId = decoded.userId; req.username = decoded.username; + req.isOwner = decoded.isOwner; req.permissions = decoded.permissions; next(); diff --git a/src/migrations/1728313041449-ownership.ts b/src/migrations/1728313041449-ownership.ts new file mode 100644 index 0000000..bf744a2 --- /dev/null +++ b/src/migrations/1728313041449-ownership.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class Ownership1728313041449 implements MigrationInterface { + name = "Ownership1728313041449"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "user", + new TableColumn({ + name: "isOwner", + type: "tinyint", + default: 0, + isNullable: false, + }) + ); + + await queryRunner.manager + .createQueryBuilder() + .update("user") + .set({ isOwner: 1 }) + .where((qb) => { + const subQuery = queryRunner.manager + .createQueryBuilder() + .select("1") + .from("user_permission", "up") + .where("user.id = up.userId") + .andWhere("up.permission = '*'") + .getQuery(); + return `EXISTS (${subQuery})`; + }) + .execute(); + + await queryRunner.manager.createQueryBuilder().delete().from("user_permission").where("permission = '*'").execute(); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.manager + .createQueryBuilder() + .insert() + .into("user_permission") + .values( + await queryRunner.manager + .createQueryBuilder() + .select("user.id", "userId") + .addSelect("'*'", "permission") + .from("user", "user") + .where("user.isOwner = 1") + .execute() + ) + .execute(); + + await queryRunner.manager.createQueryBuilder().update("user").set({ isOwner: 0 }).where("isOwner = 1").execute(); + + await queryRunner.dropColumn("user", "isOwner"); + } +} diff --git a/src/type/jwtTypes.ts b/src/type/jwtTypes.ts index 83e9a44..1d65d38 100644 --- a/src/type/jwtTypes.ts +++ b/src/type/jwtTypes.ts @@ -1,7 +1,7 @@ import { PermissionObject } from "./permissionTypes"; export type JWTData = { - [key: string]: string | number | PermissionObject; + [key: string]: string | number | boolean | PermissionObject; }; export type JWTToken = { @@ -10,6 +10,7 @@ export type JWTToken = { username: string; firstname: string; lastname: string; + isOwner: boolean; permissions: PermissionObject; } & JWTData; diff --git a/src/viewmodel/admin/user.models.ts b/src/viewmodel/admin/user.models.ts index 39ee152..df6eaa3 100644 --- a/src/viewmodel/admin/user.models.ts +++ b/src/viewmodel/admin/user.models.ts @@ -7,6 +7,7 @@ export interface UserViewModel { mail: string; firstname: string; lastname: string; + isOwner: boolean; permissions: PermissionObject; roles: Array; permissions_total: PermissionObject; -- 2.45.2 From ba3c763a095aa653063d4e025747d5f1e1f3ded7 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Wed, 20 Nov 2024 09:32:43 +0100 Subject: [PATCH 2/4] pass user login data --- src/controller/userController.ts | 55 ++++++++++++++++++++++++++++++++ src/routes/index.ts | 2 ++ src/routes/user.ts | 14 ++++++++ 3 files changed, 71 insertions(+) create mode 100644 src/controller/userController.ts create mode 100644 src/routes/user.ts diff --git a/src/controller/userController.ts b/src/controller/userController.ts new file mode 100644 index 0000000..603b782 --- /dev/null +++ b/src/controller/userController.ts @@ -0,0 +1,55 @@ +import { Request, Response } from "express"; +import speakeasy from "speakeasy"; +import QRCode from "qrcode"; +import InternalException from "../exceptions/internalException"; +import { CLUB_NAME } from "../env.defaults"; +import UserService from "../service/userService"; + +/** + * @description get user totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getUserTotp(req: Request, res: Response): Promise { + const userId = parseInt(req.userId); + + let { secret } = await UserService.getById(userId); + + const url = `otpauth://totp/Mitgliederverwaltung ${CLUB_NAME}?secret=${secret}`; + + QRCode.toDataURL(url) + .then((result) => { + res.json({ + dataUrl: result, + otp: secret, + }); + }) + .catch((err) => { + throw new InternalException("QRCode not created", err); + }); +} + +/** + * @description verify user totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function verifyUserTotp(req: Request, res: Response): Promise { + const userId = parseInt(req.userId); + let totp = req.body.totp; + + let { secret } = await UserService.getById(userId); + let valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: totp, + window: 2, + }); + + if (!valid) { + throw new InternalException("Token not valid or expired"); + } + res.sendStatus(204); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 2dcff79..3cfb76c 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -9,6 +9,7 @@ import errorHandler from "../middleware/errorHandler"; import setup from "./setup"; import auth from "./auth"; import admin from "./admin/index"; +import user from "./user"; export default (app: Express) => { app.set("query parser", "extended"); @@ -25,5 +26,6 @@ export default (app: Express) => { app.use("/auth", auth); app.use(authenticate); app.use("/admin", admin); + app.use("/user", user); app.use(errorHandler); }; diff --git a/src/routes/user.ts b/src/routes/user.ts new file mode 100644 index 0000000..40cabae --- /dev/null +++ b/src/routes/user.ts @@ -0,0 +1,14 @@ +import express from "express"; +import { getUserTotp, verifyUserTotp } from "../controller/userController"; + +var router = express.Router({ mergeParams: true }); + +router.get("/totp", async (req, res) => { + await getUserTotp(req, res); +}); + +router.post("/verify", async (req, res) => { + await verifyUserTotp(req, res); +}); + +export default router; -- 2.45.2 From f87c7b4a7cfb11ad85462f9be674913f89616601 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Wed, 20 Nov 2024 10:02:34 +0100 Subject: [PATCH 3/4] edit my details --- src/controller/userController.ts | 49 +++++++++++++++++++++++++++++--- src/routes/user.ts | 14 +++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 603b782..2bfc0f2 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -4,14 +4,30 @@ import QRCode from "qrcode"; import InternalException from "../exceptions/internalException"; import { CLUB_NAME } from "../env.defaults"; import UserService from "../service/userService"; +import UserFactory from "../factory/admin/user"; +import { UpdateUserCommand } from "../command/userCommand"; +import UserCommandHandler from "../command/userCommandHandler"; /** - * @description get user totp + * @description get my by id * @param req {Request} Express req object * @param res {Response} Express res object * @returns {Promise<*>} */ -export async function getUserTotp(req: Request, res: Response): Promise { +export async function getMeById(req: Request, res: Response): Promise { + const id = parseInt(req.userId); + let user = await UserService.getById(id); + + res.json(UserFactory.mapToSingle(user)); +} + +/** + * @description get my totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getMyTotp(req: Request, res: Response): Promise { const userId = parseInt(req.userId); let { secret } = await UserService.getById(userId); @@ -31,12 +47,12 @@ export async function getUserTotp(req: Request, res: Response): Promise { } /** - * @description verify user totp + * @description verify my totp * @param req {Request} Express req object * @param res {Response} Express res object * @returns {Promise<*>} */ -export async function verifyUserTotp(req: Request, res: Response): Promise { +export async function verifyMyTotp(req: Request, res: Response): Promise { const userId = parseInt(req.userId); let totp = req.body.totp; @@ -53,3 +69,28 @@ export async function verifyUserTotp(req: Request, res: Response): Promise } res.sendStatus(204); } + +/** + * @description update my data + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function updateMe(req: Request, res: Response): Promise { + const id = parseInt(req.userId); + let mail = req.body.mail; + let firstname = req.body.firstname; + let lastname = req.body.lastname; + let username = req.body.username; + + let updateUser: UpdateUserCommand = { + id: id, + mail: mail, + firstname: firstname, + lastname: lastname, + username: username, + }; + await UserCommandHandler.update(updateUser); + + res.sendStatus(204); +} diff --git a/src/routes/user.ts b/src/routes/user.ts index 40cabae..47e8e7c 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,14 +1,22 @@ import express from "express"; -import { getUserTotp, verifyUserTotp } from "../controller/userController"; +import { getMeById, getMyTotp, updateMe, verifyMyTotp } from "../controller/userController"; var router = express.Router({ mergeParams: true }); +router.get("/me", async (req, res) => { + await getMeById(req, res); +}); + router.get("/totp", async (req, res) => { - await getUserTotp(req, res); + await getMyTotp(req, res); }); router.post("/verify", async (req, res) => { - await verifyUserTotp(req, res); + await verifyMyTotp(req, res); +}); + +router.patch("/me", async (req, res) => { + await updateMe(req, res); }); export default router; -- 2.45.2 From ea227433e6f191abe8b5ab5979d95f7f5721c44a Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Thu, 21 Nov 2024 15:58:47 +0100 Subject: [PATCH 4/4] transfer Ownership --- src/command/userCommand.ts | 5 ++++ src/command/userCommandHandler.ts | 40 ++++++++++++++++++++++++++++++- src/controller/userController.ts | 27 ++++++++++++++++++++- src/routes/user.ts | 6 ++++- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/command/userCommand.ts b/src/command/userCommand.ts index 634a993..b61b578 100644 --- a/src/command/userCommand.ts +++ b/src/command/userCommand.ts @@ -15,6 +15,11 @@ export interface UpdateUserCommand { lastname: string; } +export interface TransferUserOwnerCommand { + fromId: number; + toId: number; +} + export interface UpdateUserRolesCommand { id: number; roleIds: Array; diff --git a/src/command/userCommandHandler.ts b/src/command/userCommandHandler.ts index 876db24..a88979b 100644 --- a/src/command/userCommandHandler.ts +++ b/src/command/userCommandHandler.ts @@ -2,7 +2,13 @@ import { EntityManager } from "typeorm"; import { dataSource } from "../data-source"; import { user } from "../entity/user"; import InternalException from "../exceptions/internalException"; -import { CreateUserCommand, DeleteUserCommand, UpdateUserCommand, UpdateUserRolesCommand } from "./userCommand"; +import { + CreateUserCommand, + DeleteUserCommand, + TransferUserOwnerCommand, + UpdateUserCommand, + UpdateUserRolesCommand, +} from "./userCommand"; import UserService from "../service/userService"; export default abstract class UserCommandHandler { @@ -90,6 +96,38 @@ export default abstract class UserCommandHandler { return await manager.createQueryBuilder().relation(user, "roles").of(userId).remove(roleId); } + /** + * @description transfer ownership + * @param TransferUserOwnerCommand + * @returns {Promise} + */ + static async transferOwnership(transferOwnership: TransferUserOwnerCommand): Promise { + return await dataSource.manager + .transaction(async (manager) => { + manager + .createQueryBuilder() + .update(user) + .set({ + isOwner: false, + }) + .where("id = :id", { id: transferOwnership.fromId }) + .execute(); + + manager + .createQueryBuilder() + .update(user) + .set({ + isOwner: true, + }) + .where("id = :id", { id: transferOwnership.toId }) + .execute(); + }) + .then(() => {}) + .catch((err) => { + throw new InternalException("Failed transfering ownership", err); + }); + } + /** * @description delete user * @param DeleteUserCommand diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 2bfc0f2..ebea67a 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -5,8 +5,9 @@ import InternalException from "../exceptions/internalException"; import { CLUB_NAME } from "../env.defaults"; import UserService from "../service/userService"; import UserFactory from "../factory/admin/user"; -import { UpdateUserCommand } from "../command/userCommand"; +import { TransferUserOwnerCommand, UpdateUserCommand } from "../command/userCommand"; import UserCommandHandler from "../command/userCommandHandler"; +import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; /** * @description get my by id @@ -70,6 +71,30 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { res.sendStatus(204); } +/** + * @description transferOwnership + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function transferOwnership(req: Request, res: Response): Promise { + const userId = parseInt(req.userId); + let toId = req.body.toId; + + let { isOwner } = await UserService.getById(userId); + if (!isOwner) { + throw new ForbiddenRequestException("Action only allowed to owner."); + } + + let transfer: TransferUserOwnerCommand = { + toId: toId, + fromId: userId, + }; + await UserCommandHandler.transferOwnership(transfer); + + res.sendStatus(204); +} + /** * @description update my data * @param req {Request} Express req object diff --git a/src/routes/user.ts b/src/routes/user.ts index 47e8e7c..d196e16 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,5 +1,5 @@ import express from "express"; -import { getMeById, getMyTotp, updateMe, verifyMyTotp } from "../controller/userController"; +import { getMeById, getMyTotp, transferOwnership, updateMe, verifyMyTotp } from "../controller/userController"; var router = express.Router({ mergeParams: true }); @@ -15,6 +15,10 @@ router.post("/verify", async (req, res) => { await verifyMyTotp(req, res); }); +router.put("/transferOwner", async (req, res) => { + await transferOwnership(req, res); +}); + router.patch("/me", async (req, res) => { await updateMe(req, res); }); -- 2.45.2