diff --git a/.env.example b/.env.example index 28b487d..c3fd1f3 100644 --- a/.env.example +++ b/.env.example @@ -18,5 +18,5 @@ MAIL_HOST = mail_hoststring MAIL_PORT = mail_portnumber MAIL_SECURE (true|false) // true for port 465, fals for other ports -CLUB_NAME = clubname +CLUB_NAME = clubname #default FF Admin CLUB_WEBSITE = https://my-club-website-url \ No newline at end of file diff --git a/README.md b/README.md index 3dcc570..17ee5d8 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ services: - MAIL_HOST= - MAIL_PORT= # default ist auf 578 gesetzt - MAIL_SECURE= # default ist auf false gesetzt - - CLUB_NAME= + - CLUB_NAME= # default ist auf FF Admin gesetzt - CLUB_WEBSITE= volumes: - :/app/files diff --git a/src/command/club/member/memberCommandHandler.ts b/src/command/club/member/memberCommandHandler.ts index d0fd59a..2bea5b5 100644 --- a/src/command/club/member/memberCommandHandler.ts +++ b/src/command/club/member/memberCommandHandler.ts @@ -33,7 +33,10 @@ export default abstract class MemberCommandHandler { return result.identifiers[0].id; }) .catch((err) => { - throw new InternalException("Failed creating member", err); + throw new InternalException( + `Failed creating member${err.code.includes("ER_DUP_ENTRY") ? " due to duplicate entry for column" : ""}`, + err + ); }); } @@ -58,7 +61,10 @@ export default abstract class MemberCommandHandler { .execute() .then(() => {}) .catch((err) => { - throw new InternalException("Failed updating member", err); + throw new InternalException( + `Failed updating member${err.code.includes("ER_DUP_ENTRY") ? " due to duplicate entry for column" : ""}`, + err + ); }); } @@ -83,7 +89,7 @@ export default abstract class MemberCommandHandler { .execute() .then(() => {}) .catch((err) => { - throw new InternalException("Failed updating member", err); + throw new InternalException(`Failed updating member`, err); }); } diff --git a/src/command/user/webapi/webapiCommand.ts b/src/command/user/webapi/webapiCommand.ts new file mode 100644 index 0000000..4415747 --- /dev/null +++ b/src/command/user/webapi/webapiCommand.ts @@ -0,0 +1,19 @@ +export interface CreateWebapiCommand { + title: string; + token: string; + expiry?: Date; +} + +export interface UpdateWebapiCommand { + id: number; + title: string; + expiry?: Date; +} + +export interface UpdateLastUsageWebapiCommand { + id: number; +} + +export interface DeleteWebapiCommand { + id: number; +} diff --git a/src/command/user/webapi/webapiCommandHandler.ts b/src/command/user/webapi/webapiCommandHandler.ts new file mode 100644 index 0000000..b49f7a6 --- /dev/null +++ b/src/command/user/webapi/webapiCommandHandler.ts @@ -0,0 +1,100 @@ +import { dataSource } from "../../../data-source"; +import { webapi } from "../../../entity/user/webapi"; +import InternalException from "../../../exceptions/internalException"; +import { + CreateWebapiCommand, + DeleteWebapiCommand, + UpdateLastUsageWebapiCommand, + UpdateWebapiCommand, +} from "./webapiCommand"; + +export default abstract class WebapiCommandHandler { + /** + * @description create api + * @param {CreateWebapiCommand} createWebapi + * @returns {Promise} + */ + static async create(createWebapi: CreateWebapiCommand): Promise { + return await dataSource + .createQueryBuilder() + .insert() + .into(webapi) + .values({ + token: createWebapi.token, + title: createWebapi.title, + expiry: createWebapi.expiry, + }) + .execute() + .then((result) => { + return result.identifiers[0].token; + }) + .catch((err) => { + throw new InternalException( + `Failed creating api${err.code.includes("ER_DUP_ENTRY") ? " due to duplicate entry for column" : ""}`, + err + ); + }); + } + + /** + * @description update api + * @param {UpdateWebapiCommand} updateWebapi + * @returns {Promise} + */ + static async update(updateWebapi: UpdateWebapiCommand): Promise { + return await dataSource + .createQueryBuilder() + .update(webapi) + .set({ + title: updateWebapi.title, + expiry: updateWebapi.expiry, + }) + .where("id = :id", { id: updateWebapi.id }) + .execute() + .then(() => {}) + .catch((err) => { + throw new InternalException( + `Failed updating api${err.code.includes("ER_DUP_ENTRY") ? " due to duplicate entry for column" : ""}`, + err + ); + }); + } + + /** + * @description update api usage + * @param {UpdateLastUsageWebapiCommand} updateWebapi + * @returns {Promise} + */ + static async updateUsage(updateWebapi: UpdateLastUsageWebapiCommand): Promise { + return await dataSource + .createQueryBuilder() + .update(webapi) + .set({ + lastUsage: new Date(), + }) + .where("id = :id", { id: updateWebapi.id }) + .execute() + .then(() => {}) + .catch((err) => { + throw new InternalException(`Failed updating api last usage`, err); + }); + } + + /** + * @description delete api + * @param {DeleteWebapiCommand} deleteWebapi + * @returns {Promise} + */ + static async delete(deleteWebapi: DeleteWebapiCommand): Promise { + return await dataSource + .createQueryBuilder() + .delete() + .from(webapi) + .where("id = :id", { id: deleteWebapi.id }) + .execute() + .then(() => {}) + .catch((err) => { + throw new InternalException("Failed deleting api", err); + }); + } +} diff --git a/src/command/user/webapi/webapiPermissionCommand.ts b/src/command/user/webapi/webapiPermissionCommand.ts new file mode 100644 index 0000000..3942115 --- /dev/null +++ b/src/command/user/webapi/webapiPermissionCommand.ts @@ -0,0 +1,16 @@ +import { PermissionString } from "../../../type/permissionTypes"; + +export interface CreateWebapiPermissionCommand { + permission: PermissionString; + webapiId: number; +} + +export interface DeleteWebapiPermissionCommand { + permission: PermissionString; + webapiId: number; +} + +export interface UpdateWebapiPermissionsCommand { + webapiId: number; + permissions: Array; +} diff --git a/src/command/user/webapi/webapiPermissionCommandHandler.ts b/src/command/user/webapi/webapiPermissionCommandHandler.ts new file mode 100644 index 0000000..74ea514 --- /dev/null +++ b/src/command/user/webapi/webapiPermissionCommandHandler.ts @@ -0,0 +1,119 @@ +import { DeleteResult, EntityManager, InsertResult } from "typeorm"; +import { dataSource } from "../../../data-source"; +import { webapiPermission } from "../../../entity/user/webapi_permission"; +import InternalException from "../../../exceptions/internalException"; +import WebapiService from "../../../service/user/webapiService"; +import { + CreateWebapiPermissionCommand, + DeleteWebapiPermissionCommand, + UpdateWebapiPermissionsCommand, +} from "./webapiPermissionCommand"; +import PermissionHelper from "../../../helpers/permissionHelper"; +import WebapiPermissionService from "../../../service/user/webapiPermissionService"; +import { PermissionString } from "../../../type/permissionTypes"; + +export default abstract class WebapiPermissionCommandHandler { + /** + * @description update api permissions + * @param {UpdateWebapiPermissionsCommand} updateWebapiPermissions + * @returns {Promise} + */ + static async updatePermissions(updateWebapiPermissions: UpdateWebapiPermissionsCommand): Promise { + let currentPermissions = (await WebapiPermissionService.getByApi(updateWebapiPermissions.webapiId)).map( + (r) => r.permission + ); + return await dataSource.manager + .transaction(async (manager) => { + let newPermissions = PermissionHelper.getWhatToAdd(currentPermissions, updateWebapiPermissions.permissions); + let removePermissions = PermissionHelper.getWhatToRemove( + currentPermissions, + updateWebapiPermissions.permissions + ); + if (newPermissions.length != 0) { + await this.updatePermissionsAdd(manager, updateWebapiPermissions.webapiId, newPermissions); + } + if (removePermissions.length != 0) { + await this.updatePermissionsRemove(manager, updateWebapiPermissions.webapiId, removePermissions); + } + }) + .then(() => {}) + .catch((err) => { + throw new InternalException("Failed saving api permissions", err); + }); + } + + private static async updatePermissionsAdd( + manager: EntityManager, + webapiId: number, + permissions: Array + ): Promise { + return await manager + .createQueryBuilder() + .insert() + .into(webapiPermission) + .values( + permissions.map((p) => ({ + permission: p, + webapiId: webapiId, + })) + ) + .orIgnore() + .execute(); + } + + private static async updatePermissionsRemove( + manager: EntityManager, + webapiId: number, + permissions: Array + ): Promise { + return await manager + .createQueryBuilder() + .delete() + .from(webapiPermission) + .where("webapiId = :id", { id: webapiId }) + .andWhere("permission IN (:...permission)", { permission: permissions }) + .execute(); + } + + /** + * @description grant permission to user + * @param {CreateWebapiPermissionCommand} createPermission + * @returns {Promise} + */ + static async create(createPermission: CreateWebapiPermissionCommand): Promise { + return await dataSource + .createQueryBuilder() + .insert() + .into(webapiPermission) + .values({ + permission: createPermission.permission, + webapiId: createPermission.webapiId, + }) + .execute() + .then((result) => { + return result.identifiers[0].id; + }) + .catch((err) => { + throw new InternalException("Failed saving api permission", err); + }); + } + + /** + * @description remove permission from api + * @param {DeleteWebapiPermissionCommand} deletePermission + * @returns {Promise} + */ + static async delete(deletePermission: DeleteWebapiPermissionCommand): Promise { + return await dataSource + .createQueryBuilder() + .delete() + .from(webapiPermission) + .where("webapiId = :id", { id: deletePermission.webapiId }) + .andWhere("permission = :permission", { permission: deletePermission.permission }) + .execute() + .then(() => {}) + .catch((err) => { + throw new InternalException("failed api permission removal", err); + }); + } +} diff --git a/src/controller/admin/user/webapiController.ts b/src/controller/admin/user/webapiController.ts new file mode 100644 index 0000000..d5390ff --- /dev/null +++ b/src/controller/admin/user/webapiController.ts @@ -0,0 +1,155 @@ +import { Request, Response } from "express"; +import WebapiService from "../../../service/user/webapiService"; +import ApiFactory from "../../../factory/admin/user/webapi"; +import WebapiPermissionService from "../../../service/user/webapiPermissionService"; +import PermissionHelper from "../../../helpers/permissionHelper"; +import { + CreateWebapiCommand, + DeleteWebapiCommand, + UpdateWebapiCommand, +} from "../../../command/user/webapi/webapiCommand"; +import WebapiCommandHandler from "../../../command/user/webapi/webapiCommandHandler"; +import { UpdateWebapiPermissionsCommand } from "../../../command/user/webapi/webapiPermissionCommand"; +import WebapiPermissionCommandHandler from "../../../command/user/webapi/webapiPermissionCommandHandler"; +import { JWTHelper } from "../../../helpers/jwtHelper"; +import { CLUB_NAME } from "../../../env.defaults"; +import { StringHelper } from "../../../helpers/stringHelper"; + +/** + * @description get All apis + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getAllWebapis(req: Request, res: Response): Promise { + let apis = await WebapiService.getAll(); + + res.json(ApiFactory.mapToBase(apis)); +} + +/** + * @description get api by id + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getWebapiById(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + let api = await WebapiService.getById(id); + + res.json(ApiFactory.mapToSingle(api)); +} + +/** + * @description get api token by id + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getWebapiTokenById(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + let { token } = await WebapiService.getTokenById(id); + + res.send(token); +} + +/** + * @description get permissions by api + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getWebapiPermissions(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + let permissions = await WebapiPermissionService.getByApi(id); + + res.json(PermissionHelper.convertToObject(permissions.map((p) => p.permission))); +} + +/** + * @description create new api + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function createWebapi(req: Request, res: Response): Promise { + let title = req.body.title; + let expiry = req.body.expiry || null; + + let token = await JWTHelper.create( + { + iss: CLUB_NAME, + sub: "api_token_retrieve", + aud: StringHelper.random(32), + }, + { useExpiration: false } + ); + + let createApi: CreateWebapiCommand = { + token: token, + title: title, + expiry: expiry, + }; + await WebapiCommandHandler.create(createApi); + + res.sendStatus(204); +} + +/** + * @description update api data + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function updateWebapi(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + let title = req.body.title; + let expiry = req.body.expiry || null; + + let updateApi: UpdateWebapiCommand = { + id: id, + title: title, + expiry: expiry, + }; + await WebapiCommandHandler.update(updateApi); + + res.sendStatus(204); +} + +/** + * @description update api assigned permission strings + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function updateWebapiPermissions(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + let permissions = req.body.permissions; + + let permissionStrings = PermissionHelper.convertToStringArray(permissions); + + let updateApiPermissions: UpdateWebapiPermissionsCommand = { + webapiId: id, + permissions: permissionStrings, + }; + await WebapiPermissionCommandHandler.updatePermissions(updateApiPermissions); + + res.sendStatus(204); +} + +/** + * @description delete api by id + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function deleteWebapi(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + + let deleteApi: DeleteWebapiCommand = { + id: id, + }; + await WebapiCommandHandler.delete(deleteApi); + + res.sendStatus(204); +} diff --git a/src/controller/webapiController.ts b/src/controller/webapiController.ts new file mode 100644 index 0000000..7af86dd --- /dev/null +++ b/src/controller/webapiController.ts @@ -0,0 +1,37 @@ +import { Request, Response } from "express"; +import { JWTHelper } from "../helpers/jwtHelper"; +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/user/userService"; +import speakeasy from "speakeasy"; +import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; +import RefreshService from "../service/refreshService"; +import WebapiService from "../service/user/webapiService"; +import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; +import WebapiCommandHandler from "../command/user/webapi/webapiCommandHandler"; + +/** + * @description Check authentication status by token + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getWebApiAccess(req: Request, res: Response): Promise { + const bearer = req.headers.authorization?.split(" ")?.[1] ?? undefined; + + let { id, expiry } = await WebapiService.getByToken(bearer); + + if (expiry != null && new Date() > new Date(expiry)) { + throw new ForbiddenRequestException("api token expired"); + } + + await WebapiCommandHandler.updateUsage({ id }); + + let accessToken = await JWTHelper.buildWebapiToken(bearer, expiry); + + res.json({ + accessToken, + }); +} diff --git a/src/data-source.ts b/src/data-source.ts index c7ba457..8ba9056 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -68,6 +68,9 @@ import { Memberlist1736079005086 } from "./migrations/1736079005086-memberlist"; import { ExtendViewValues1736084198860 } from "./migrations/1736084198860-extendViewValues"; import { FinishInternalIdTransfer1736505324488 } from "./migrations/1736505324488-finishInternalIdTransfer"; import { ProtocolPresenceExcuse1737287798828 } from "./migrations/1737287798828-protocolPresenceExcuse"; +import { webapi } from "./entity/user/webapi"; +import { webapiPermission } from "./entity/user/webapi_permission"; +import { AddWebapiTokens1737453096674 } from "./migrations/1737453096674-addwebapiTokens"; const dataSource = new DataSource({ type: DB_TYPE as any, @@ -117,6 +120,8 @@ const dataSource = new DataSource({ memberExecutivePositionsView, memberQualificationsView, membershipView, + webapi, + webapiPermission, ], migrations: [ Initial1724317398939, @@ -146,6 +151,7 @@ const dataSource = new DataSource({ ExtendViewValues1736084198860, FinishInternalIdTransfer1736505324488, ProtocolPresenceExcuse1737287798828, + AddWebapiTokens1737453096674, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/user/webapi.ts b/src/entity/user/webapi.ts new file mode 100644 index 0000000..220db8c --- /dev/null +++ b/src/entity/user/webapi.ts @@ -0,0 +1,26 @@ +import { Column, CreateDateColumn, Entity, OneToMany, PrimaryColumn } from "typeorm"; +import { webapiPermission } from "./webapi_permission"; + +@Entity() +export class webapi { + @PrimaryColumn({ generated: "increment", type: "int" }) + id: number; + + @Column({ type: "text", unique: true, select: false }) + token: string; + + @Column({ type: "varchar", length: 255, unique: true }) + title: string; + + @CreateDateColumn() + createdAt: Date; + + @Column({ type: "datetime", nullable: true }) + lastUsage?: Date; + + @Column({ type: "date", nullable: true }) + expiry?: Date; + + @OneToMany(() => webapiPermission, (apiPermission) => apiPermission.webapi) + permissions: webapiPermission[]; +} diff --git a/src/entity/user/webapi_permission.ts b/src/entity/user/webapi_permission.ts new file mode 100644 index 0000000..988c7b9 --- /dev/null +++ b/src/entity/user/webapi_permission.ts @@ -0,0 +1,19 @@ +import { Column, Entity, ManyToOne, OneToMany, PrimaryColumn } from "typeorm"; +import { PermissionObject, PermissionString } from "../../type/permissionTypes"; +import { webapi } from "./webapi"; + +@Entity() +export class webapiPermission { + @PrimaryColumn({ type: "int" }) + webapiId: number; + + @PrimaryColumn({ type: "varchar", length: 255 }) + permission: PermissionString; + + @ManyToOne(() => webapi, { + nullable: false, + onDelete: "CASCADE", + onUpdate: "RESTRICT", + }) + webapi: webapi; +} diff --git a/src/env.defaults.ts b/src/env.defaults.ts index 33fe53a..d4e3c3b 100644 --- a/src/env.defaults.ts +++ b/src/env.defaults.ts @@ -21,7 +21,7 @@ export const MAIL_HOST = process.env.MAIL_HOST ?? ""; export const MAIL_PORT = Number(process.env.MAIL_PORT ?? "587"); export const MAIL_SECURE = process.env.MAIL_SECURE ?? "false"; -export const CLUB_NAME = process.env.CLUB_NAME ?? ""; +export const CLUB_NAME = process.env.CLUB_NAME ?? "FF Admin"; export const CLUB_WEBSITE = process.env.CLUB_WEBSITE ?? ""; export function configCheck() { diff --git a/src/factory/admin/user/webapi.ts b/src/factory/admin/user/webapi.ts new file mode 100644 index 0000000..3c0ede4 --- /dev/null +++ b/src/factory/admin/user/webapi.ts @@ -0,0 +1,30 @@ +import { webapi } from "../../../entity/user/webapi"; +import PermissionHelper from "../../../helpers/permissionHelper"; +import { ApiViewModel } from "../../../viewmodel/admin/user/webapi.models"; + +export default abstract class ApiFactory { + /** + * @description map record to api + * @param {webapi} record + * @returns {apiViewModel} + */ + public static mapToSingle(record: webapi): ApiViewModel { + return { + id: record.id, + permissions: PermissionHelper.convertToObject(record.permissions.map((e) => e.permission)), + title: record.title, + expiry: record.expiry, + lastUsage: record.lastUsage, + createdAt: record.createdAt, + }; + } + + /** + * @description map records to api + * @param {Array} records + * @returns {Array} + */ + public static mapToBase(records: Array): Array { + return records.map((r) => this.mapToSingle(r)); + } +} diff --git a/src/helpers/jwtHelper.ts b/src/helpers/jwtHelper.ts index b8a62a5..791af2a 100644 --- a/src/helpers/jwtHelper.ts +++ b/src/helpers/jwtHelper.ts @@ -6,6 +6,9 @@ import RolePermissionService from "../service/user/rolePermissionService"; import UserPermissionService from "../service/user/userPermissionService"; import UserService from "../service/user/userService"; import PermissionHelper from "./permissionHelper"; +import WebapiService from "../service/user/webapiService"; +import WebapiPermissionService from "../service/user/webapiPermissionService"; +import ms from "ms"; export abstract class JWTHelper { static validate(token: string): Promise { @@ -17,13 +20,16 @@ export abstract class JWTHelper { }); } - static create(data: JWTData): Promise { + static create( + data: JWTData, + { expOverwrite, useExpiration }: { expOverwrite?: number; useExpiration?: boolean } = { useExpiration: true } + ): Promise { return new Promise((resolve, reject) => { jwt.sign( data, JWT_SECRET, { - expiresIn: JWT_EXPIRATION, + ...(useExpiration ?? true ? { expiresIn: expOverwrite ?? JWT_EXPIRATION } : {}), }, (err, token) => { if (err) reject(err.message); @@ -72,4 +78,35 @@ export abstract class JWTHelper { throw new InternalException("Failed accessToken creation", err); }); } + + static async buildWebapiToken(token: string, expiration?: Date): Promise { + let { id, title } = await WebapiService.getByToken(token); + let webapiPermissions = await WebapiPermissionService.getByApi(id); + let webapiPermissionStrings = webapiPermissions.map((e) => e.permission); + let permissionObject = PermissionHelper.convertToObject(webapiPermissionStrings); + + let jwtData: JWTToken = { + userId: id, + mail: "", + username: title, + firstname: "", + lastname: "", + isOwner: false, + permissions: permissionObject, + sub: "webapi_access_token", + }; + + let overwriteExpiration = + ms(JWT_EXPIRATION) < new Date().getTime() - new Date(expiration).getTime() + ? null + : Date.now() - new Date(expiration).getTime(); + + return await JWTHelper.create(jwtData, { expOverwrite: overwriteExpiration, useExpiration: true }) + .then((result) => { + return result; + }) + .catch((err) => { + throw new InternalException("Failed webapi accessToken creation", err); + }); + } } diff --git a/src/index.ts b/src/index.ts index f5a73e6..68eef4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ declare global { isOwner: boolean; permissions: PermissionObject; isPWA: boolean; + isWebApiRequest: boolean; } } } diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts index cfa1f56..9e62bb4 100644 --- a/src/middleware/authenticate.ts +++ b/src/middleware/authenticate.ts @@ -29,10 +29,15 @@ export default async function authenticate(req: Request, res: Response, next: Fu throw new InternalException("process failed"); } + if (decoded?.sub == "api_token_retrieve") { + throw new BadRequestException("This token is only authorized to get temporary access tokens via GET /api/webapi"); + } + req.userId = decoded.userId; req.username = decoded.username; req.isOwner = decoded.isOwner; req.permissions = decoded.permissions; + req.isWebApiRequest = decoded?.sub == "webapi_access_token"; next(); } diff --git a/src/middleware/authenticateAPI.ts b/src/middleware/authenticateAPI.ts new file mode 100644 index 0000000..b05060e --- /dev/null +++ b/src/middleware/authenticateAPI.ts @@ -0,0 +1,37 @@ +import { Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import BadRequestException from "../exceptions/badRequestException"; +import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; +import InternalException from "../exceptions/internalException"; +import { JWTHelper } from "../helpers/jwtHelper"; + +export default async function authenticateAPI(req: Request, res: Response, next: Function) { + const bearer = req.headers.authorization?.split(" ")?.[1] ?? undefined; + + if (!bearer) { + throw new BadRequestException("Provide valid Authorization Header"); + } + + let decoded: string | jwt.JwtPayload; + await JWTHelper.validate(bearer) + .then((result) => { + decoded = result; + }) + .catch((err) => { + if (err == "jwt expired") { + throw new UnauthorizedRequestException("Token expired", err); + } else { + throw new BadRequestException("Failed Authorization Header decoding", err); + } + }); + + if (typeof decoded == "string" || !decoded) { + throw new InternalException("process failed"); + } + + if (decoded?.sub != "api_token_retrieve") { + throw new BadRequestException("This route can only be accessed via a api token"); + } + + next(); +} diff --git a/src/middleware/preventWebApiAccess.ts b/src/middleware/preventWebApiAccess.ts new file mode 100644 index 0000000..3c3b7c6 --- /dev/null +++ b/src/middleware/preventWebApiAccess.ts @@ -0,0 +1,10 @@ +import { Request, Response } from "express"; +import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; + +export default async function preventWebapiAccess(req: Request, res: Response, next: Function) { + if (req.isWebApiRequest) { + throw new ForbiddenRequestException("This route cannot be accessed via webapi"); + } else { + next(); + } +} diff --git a/src/migrations/1737453096674-addwebapiTokens.ts b/src/migrations/1737453096674-addwebapiTokens.ts new file mode 100644 index 0000000..55f37fb --- /dev/null +++ b/src/migrations/1737453096674-addwebapiTokens.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from "typeorm"; +import { DB_TYPE } from "../env.defaults"; + +export class AddWebapiTokens1737453096674 implements MigrationInterface { + name = "AddWebApiTokens1737453096674"; + + public async up(queryRunner: QueryRunner): Promise { + const variableType_int = DB_TYPE == "mysql" ? "int" : "integer"; + + await queryRunner.createTable( + new Table({ + name: "webapi", + columns: [ + { name: "id", type: variableType_int, isPrimary: true, isGenerated: true, generationStrategy: "increment" }, + { name: "token", type: "text", isUnique: true, isNullable: false }, + { name: "title", type: "varchar", isUnique: true, length: "255", isNullable: false }, + { name: "createdAt", type: "datetime", default: "CURRENT_TIMESTAMP(6)", isNullable: false }, + { name: "lastUsage", type: "datetime", isNullable: true, default: null }, + { name: "expiry", type: "date", isNullable: true, default: null }, + ], + }), + true + ); + + await queryRunner.createTable( + new Table({ + name: "webapi_permission", + columns: [ + { name: "webapiId", type: variableType_int, isPrimary: true, isNullable: false }, + { name: "permission", type: "varchar", length: "255", isPrimary: true, isNullable: false }, + ], + }), + true + ); + + await queryRunner.createForeignKey( + "webapi_permission", + new TableForeignKey({ + columnNames: ["webapiId"], + referencedColumnNames: ["id"], + referencedTableName: "webapi", + onDelete: "CASCADE", + onUpdate: "RESTRICT", + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable("webapi_permission"); + const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.indexOf("webapiId") !== -1); + await queryRunner.dropForeignKey("webapi_permission", foreignKey); + await queryRunner.dropTable("webapi_permission"); + await queryRunner.dropTable("webapi"); + } +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index cd7c935..9aa24ce 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -21,6 +21,8 @@ import newsletter from "./club/newsletter"; import role from "./user/role"; import user from "./user/user"; import invite from "./user/invite"; +import api from "./user/webapi"; +import preventWebapiAccess from "../../middleware/preventWebApiAccess"; var router = express.Router({ mergeParams: true }); @@ -60,5 +62,6 @@ router.use("/newsletter", PermissionHelper.passCheckMiddleware("read", "club", " router.use("/role", PermissionHelper.passCheckMiddleware("read", "user", "role"), role); router.use("/user", PermissionHelper.passCheckMiddleware("read", "user", "user"), user); router.use("/invite", PermissionHelper.passCheckMiddleware("read", "user", "user"), invite); +router.use("/webapi", preventWebapiAccess, PermissionHelper.passCheckMiddleware("read", "user", "webapi"), api); export default router; diff --git a/src/routes/admin/user/webapi.ts b/src/routes/admin/user/webapi.ts new file mode 100644 index 0000000..0d0e7ab --- /dev/null +++ b/src/routes/admin/user/webapi.ts @@ -0,0 +1,64 @@ +import express, { Request, Response } from "express"; +import PermissionHelper from "../../../helpers/permissionHelper"; +import { + createWebapi, + deleteWebapi, + getAllWebapis, + getWebapiById, + getWebapiPermissions, + getWebapiTokenById, + updateWebapi, + updateWebapiPermissions, +} from "../../../controller/admin/user/webapiController"; + +var router = express.Router({ mergeParams: true }); + +router.get("/", async (req: Request, res: Response) => { + await getAllWebapis(req, res); +}); + +router.get("/:id", async (req: Request, res: Response) => { + await getWebapiById(req, res); +}); + +router.get("/:id/token", async (req: Request, res: Response) => { + await getWebapiTokenById(req, res); +}); + +router.get("/:id/permissions", async (req: Request, res: Response) => { + await getWebapiPermissions(req, res); +}); + +router.post( + "/", + PermissionHelper.passCheckMiddleware("create", "user", "webapi"), + async (req: Request, res: Response) => { + await createWebapi(req, res); + } +); + +router.patch( + "/:id", + PermissionHelper.passCheckMiddleware("update", "user", "webapi"), + async (req: Request, res: Response) => { + await updateWebapi(req, res); + } +); + +router.patch( + "/:id/permissions", + PermissionHelper.passCheckMiddleware("admin", "user", "webapi"), + async (req: Request, res: Response) => { + await updateWebapiPermissions(req, res); + } +); + +router.delete( + "/:id", + PermissionHelper.passCheckMiddleware("delete", "user", "webapi"), + async (req: Request, res: Response) => { + await deleteWebapi(req, res); + } +); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index b939d52..3509940 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -14,6 +14,8 @@ import auth from "./auth"; import admin from "./admin/index"; import user from "./user"; import detectPWA from "../middleware/detectPWA"; +import api from "./webapi"; +import authenticateAPI from "../middleware/authenticateAPI"; export default (app: Express) => { app.set("query parser", "extended"); @@ -32,6 +34,7 @@ export default (app: Express) => { app.use("/api/reset", reset); app.use("/api/invite", invite); app.use("/api/auth", auth); + app.use("/api/webapi", authenticateAPI, api); app.use(authenticate); app.use("/api/admin", admin); app.use("/api/user", user); diff --git a/src/routes/webapi.ts b/src/routes/webapi.ts new file mode 100644 index 0000000..164802c --- /dev/null +++ b/src/routes/webapi.ts @@ -0,0 +1,10 @@ +import express, { Request, Response } from "express"; +import { getWebApiAccess } from "../controller/webapiController"; + +var router = express.Router({ mergeParams: true }); + +router.get("/retrieve", async (req: Request, res: Response) => { + await getWebApiAccess(req, res); +}); + +export default router; diff --git a/src/service/user/webapiPermissionService.ts b/src/service/user/webapiPermissionService.ts new file mode 100644 index 0000000..a210353 --- /dev/null +++ b/src/service/user/webapiPermissionService.ts @@ -0,0 +1,24 @@ +import { dataSource } from "../../data-source"; +import { webapiPermission } from "../../entity/user/webapi_permission"; +import InternalException from "../../exceptions/internalException"; + +export default abstract class WebapiPermissionService { + /** + * @description get permission by api + * @param webapiId number + * @returns {Promise>} + */ + static async getByApi(webapiId: number): Promise> { + return await dataSource + .getRepository(webapiPermission) + .createQueryBuilder("webapi_permission") + .where("webapi_permission.webapiId = :webapiId", { webapiId: webapiId }) + .getMany() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("webapi permissions not found by api", err); + }); + } +} diff --git a/src/service/user/webapiService.ts b/src/service/user/webapiService.ts new file mode 100644 index 0000000..0faa51a --- /dev/null +++ b/src/service/user/webapiService.ts @@ -0,0 +1,83 @@ +import { dataSource } from "../../data-source"; +import { webapi } from "../../entity/user/webapi"; +import InternalException from "../../exceptions/internalException"; + +export default abstract class WebapiService { + /** + * @description get apis + * @returns {Promise>} + */ + static async getAll(): Promise> { + return await dataSource + .getRepository(webapi) + .createQueryBuilder("webapi") + .leftJoinAndSelect("webapi.permissions", "permissions") + .getMany() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("webapis not found", err); + }); + } + + /** + * @description get api by id + * @param id number + * @returns {Promise} + */ + static async getById(id: number): Promise { + return await dataSource + .getRepository(webapi) + .createQueryBuilder("webapi") + .leftJoinAndSelect("webapi.permissions", "permissions") + .where("webapi.id = :id", { id: id }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("webapi not found by id", err); + }); + } + + /** + * @description get api by token + * @param token string + * @returns {Promise} + */ + static async getByToken(token: string): Promise { + return await dataSource + .getRepository(webapi) + .createQueryBuilder("webapi") + .leftJoinAndSelect("webapi.permissions", "permissions") + .where("webapi.token = :token", { token: token }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("webapi not found by token", err); + }); + } + + /** + * @description get api by id + * @param id number + * @returns {Promise} + */ + static async getTokenById(id: number): Promise { + return await dataSource + .getRepository(webapi) + .createQueryBuilder("webapi") + .select("webapi.token") + .where("webapi.id = :id", { id: id }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("webapi token not found by id", err); + }); + } +} diff --git a/src/type/permissionTypes.ts b/src/type/permissionTypes.ts index 95ea543..5ba5cb4 100644 --- a/src/type/permissionTypes.ts +++ b/src/type/permissionTypes.ts @@ -14,6 +14,7 @@ export type PermissionModule = | "calendar_type" | "user" | "role" + | "webapi" | "query" | "query_store" | "template" @@ -55,6 +56,7 @@ export const permissionModules: Array = [ "calendar_type", "user", "role", + "webapi", "query", "query_store", "template", @@ -75,5 +77,5 @@ export const sectionsAndModules: SectionsAndModulesObject = { "template_usage", "newsletter_config", ], - user: ["user", "role"], + user: ["user", "role", "webapi"], }; diff --git a/src/viewmodel/admin/user/webapi.models.ts b/src/viewmodel/admin/user/webapi.models.ts new file mode 100644 index 0000000..cf3a861 --- /dev/null +++ b/src/viewmodel/admin/user/webapi.models.ts @@ -0,0 +1,10 @@ +import { PermissionObject } from "../../../type/permissionTypes"; + +export interface ApiViewModel { + id: number; + permissions: PermissionObject; + title: string; + createdAt: Date; + lastUsage?: Date; + expiry?: Date; +}