diff --git a/src/command/userCommand.ts b/src/command/userCommand.ts index a084115..b61b578 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 { @@ -14,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 79834e2..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 { @@ -22,6 +28,7 @@ export default abstract class UserCommandHandler { firstname: createUser.firstname, lastname: createUser.lastname, secret: createUser.secret, + isOwner: createUser.isOwner, }) .execute() .then((result) => { @@ -89,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/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/controller/userController.ts b/src/controller/userController.ts new file mode 100644 index 0000000..ebea67a --- /dev/null +++ b/src/controller/userController.ts @@ -0,0 +1,121 @@ +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"; +import UserFactory from "../factory/admin/user"; +import { TransferUserOwnerCommand, UpdateUserCommand } from "../command/userCommand"; +import UserCommandHandler from "../command/userCommandHandler"; +import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; + +/** + * @description get my by id + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {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); + + 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 my totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function verifyMyTotp(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); +} + +/** + * @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 + * @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/data-source.ts b/src/data-source.ts index a38def0..3801455 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"; import { protocol } from "./entity/protocol"; import { protocolAgenda } from "./entity/protocolAgenda"; import { protocolDecision } from "./entity/protocolDecision"; @@ -86,6 +87,7 @@ const dataSource = new DataSource({ MemberBaseData1725435669492, Memberdata1726301836849, CommunicationFields1727439800630, + Ownership1728313041449, Protocol1729347911107, Calendar1729947763295, ], 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/routes/index.ts b/src/routes/index.ts index 070508c..b9281d0 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -10,6 +10,7 @@ import publicAvailable from "./public"; 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"); @@ -27,5 +28,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..d196e16 --- /dev/null +++ b/src/routes/user.ts @@ -0,0 +1,26 @@ +import express from "express"; +import { getMeById, getMyTotp, transferOwnership, 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 getMyTotp(req, res); +}); + +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); +}); + +export default router; 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;