From e8e657e8d1e2ed9e85e798472bc1f1335a5e8d41 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 23 Sep 2024 23:01:23 +0100 Subject: [PATCH] Validation: Strip unkown + Express/Auth added security, updated resource actions --- src/app/models/auth/User.ts | 19 +++ .../auth/commands/GenerateJWTSecret.ts | 3 +- .../auth/exceptions/ForbiddenResourceError.ts | 8 ++ .../domains/auth/interfaces/IAuthService.ts | 16 +++ .../auth/interfaces/ISecurityRequest.ts | 7 ++ .../domains/auth/interfaces/IUserModel.ts | 1 + .../auth/middleware/securityMiddleware.ts | 46 +++++++ .../domains/auth/providers/AuthProvider.ts | 3 +- src/core/domains/auth/services/AuthService.ts | 21 ++++ src/core/domains/auth/services/Security.ts | 119 ++++++++++++++++++ .../domains/auth/services/SecurityReader.ts | 52 ++++++++ .../domains/express/actions/resourceCreate.ts | 2 + .../domains/express/actions/resourceDelete.ts | 13 ++ .../domains/express/actions/resourceIndex.ts | 62 ++++++++- .../domains/express/actions/resourceShow.ts | 45 ++++++- .../domains/express/actions/resourceUpdate.ts | 13 ++ src/core/domains/express/interfaces/IRoute.ts | 2 + .../interfaces/IRouteResourceOptions.ts | 4 +- .../domains/express/middleware/authorize.ts | 2 + .../domains/express/routing/RouteResource.ts | 31 ++++- .../express/services/ExpressService.ts | 8 ++ .../domains/express/types/BaseRequest.t.ts | 3 +- .../domains/validator/base/BaseValidator.ts | 8 ++ .../validator/interfaces/IValidator.ts | 6 + .../middleware/validateMiddleware.ts | 8 +- src/core/services/App.ts | 22 ++++ 26 files changed, 506 insertions(+), 18 deletions(-) create mode 100644 src/core/domains/auth/exceptions/ForbiddenResourceError.ts create mode 100644 src/core/domains/auth/interfaces/ISecurityRequest.ts create mode 100644 src/core/domains/auth/middleware/securityMiddleware.ts create mode 100644 src/core/domains/auth/services/Security.ts create mode 100644 src/core/domains/auth/services/SecurityReader.ts diff --git a/src/app/models/auth/User.ts b/src/app/models/auth/User.ts index f9a308de9..629edc47f 100644 --- a/src/app/models/auth/User.ts +++ b/src/app/models/auth/User.ts @@ -59,6 +59,25 @@ export default class User extends Model implements IUserModel { 'roles' ] + /** + * Checks if the user has the given role + * + * @param role The role to check + * @returns True if the user has the role, false otherwise + */ + hasRole(roles: string | string[]): boolean { + roles = typeof roles === 'string' ? [roles] : roles; + const userRoles = this.getAttribute('roles') ?? []; + + for(const role of roles) { + if(userRoles.includes(role)) { + return true; + } + } + + return false + } + /** * @returns The tokens associated with this user * diff --git a/src/core/domains/auth/commands/GenerateJWTSecret.ts b/src/core/domains/auth/commands/GenerateJWTSecret.ts index 6e1cdc6e9..477fddec8 100644 --- a/src/core/domains/auth/commands/GenerateJWTSecret.ts +++ b/src/core/domains/auth/commands/GenerateJWTSecret.ts @@ -1,8 +1,7 @@ +import BaseCommand from "@src/core/domains/console/base/BaseCommand"; import { IEnvService } from "@src/core/interfaces/IEnvService"; import EnvService from "@src/core/services/EnvService"; -import BaseCommand from "../../console/base/BaseCommand"; - class GenerateJwtSecret extends BaseCommand { signature = 'auth:generate-jwt-secret'; diff --git a/src/core/domains/auth/exceptions/ForbiddenResourceError.ts b/src/core/domains/auth/exceptions/ForbiddenResourceError.ts new file mode 100644 index 000000000..749d52745 --- /dev/null +++ b/src/core/domains/auth/exceptions/ForbiddenResourceError.ts @@ -0,0 +1,8 @@ +export default class ForbiddenResourceError extends Error { + + constructor(message: string = 'You do not have permission to access this resource') { + super(message); + this.name = 'ForbiddenResourceError'; + } + +} \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index 694d4392f..fc50a13c6 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -1,8 +1,10 @@ /* eslint-disable no-unused-vars */ +import User from "@src/app/models/auth/User"; import IApiTokenModel from "@src/core/domains/auth/interfaces/IApitokenModel"; import IApiTokenRepository from "@src/core/domains/auth/interfaces/IApiTokenRepository"; import IUserModel from "@src/core/domains/auth/interfaces/IUserModel"; import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository"; +import { ISecurityMiddleware } from "@src/core/domains/auth/middleware/securityMiddleware"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import IService from "@src/core/interfaces/IService"; @@ -102,4 +104,18 @@ export interface IAuthService extends IService { * @memberof IAuthService */ getAuthRoutes(): IRoute[] | null; + + /** + * Returns the authenticated user. + * + * @returns {User | null} + */ + user(): User | null; + + /** + * Returns the security middleware + * + * @returns {ISecurityMiddleware} + */ + securityMiddleware(): ISecurityMiddleware; } diff --git a/src/core/domains/auth/interfaces/ISecurityRequest.ts b/src/core/domains/auth/interfaces/ISecurityRequest.ts new file mode 100644 index 000000000..0cad0c95a --- /dev/null +++ b/src/core/domains/auth/interfaces/ISecurityRequest.ts @@ -0,0 +1,7 @@ +import { Request } from 'express'; + +import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security'; + +export default interface ISecurityRequest extends Request { + security?: IdentifiableSecurityCallback[] +} \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IUserModel.ts b/src/core/domains/auth/interfaces/IUserModel.ts index a2dfa055a..90c12efd3 100644 --- a/src/core/domains/auth/interfaces/IUserModel.ts +++ b/src/core/domains/auth/interfaces/IUserModel.ts @@ -15,4 +15,5 @@ export interface IUserData extends IModelData { export default interface IUserModel extends IModel { tokens(...args: any[]): Promise; + hasRole(...args: any[]): any; } \ No newline at end of file diff --git a/src/core/domains/auth/middleware/securityMiddleware.ts b/src/core/domains/auth/middleware/securityMiddleware.ts new file mode 100644 index 000000000..6c37c5c84 --- /dev/null +++ b/src/core/domains/auth/middleware/securityMiddleware.ts @@ -0,0 +1,46 @@ +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import responseError from '@src/core/domains/express/requests/responseError'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + +import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; + +// eslint-disable-next-line no-unused-vars +export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise; + +/** + * This middleware will check the security definition of the route and validate it. + * If the security definition is not valid, it will throw an UnauthorizedError. + * + * @param {{ route: IRoute }} - The route object + * @returns {(req: BaseRequest, res: Response, next: NextFunction) => Promise} + */ +export const securityMiddleware = ({ route }) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + try { + // Attach security to the request object + req.security = route?.security ?? []; + + // Check if the hasRole security has been defined and validate + const hasRoleSecurity = req.security?.find((security) => security.id === SecurityIdentifiers.HAS_ROLE); + + if(hasRoleSecurity && !hasRoleSecurity.callback()) { + responseError(req, res, new ForbiddenResourceError(), 403) + return; + } + + // Security passed + next(); + } + catch (error) { + if(error instanceof UnauthorizedError) { + responseError(req, res, error, 401) + return; + } + + if(error instanceof Error) { + responseError(req, res, error) + } + } +}; \ No newline at end of file diff --git a/src/core/domains/auth/providers/AuthProvider.ts b/src/core/domains/auth/providers/AuthProvider.ts index d4390b142..a355ce295 100644 --- a/src/core/domains/auth/providers/AuthProvider.ts +++ b/src/core/domains/auth/providers/AuthProvider.ts @@ -1,10 +1,9 @@ import authConfig from "@src/config/auth"; import BaseProvider from "@src/core/base/Provider"; +import GenerateJwtSecret from "@src/core/domains/auth/commands/GenerateJWTSecret"; import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import { App } from "@src/core/services/App"; -import GenerateJwtSecret from "../commands/GenerateJWTSecret"; - export default class AuthProvider extends BaseProvider { /** diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index 821c26195..b4da482cf 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -1,3 +1,4 @@ +import User from '@src/app/models/auth/User'; import Service from '@src/core/base/Service'; import InvalidJWTSecret from '@src/core/domains/auth/exceptions/InvalidJWTSecret'; import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; @@ -10,11 +11,13 @@ import { IAuthService } from '@src/core/domains/auth/interfaces/IAuthService'; import { IJSonWebToken } from '@src/core/domains/auth/interfaces/IJSonWebToken'; import IUserModel from '@src/core/domains/auth/interfaces/IUserModel'; import IUserRepository from '@src/core/domains/auth/interfaces/IUserRepository'; +import { securityMiddleware } from '@src/core/domains/auth/middleware/securityMiddleware'; import authRoutes from '@src/core/domains/auth/routes/auth'; import comparePassword from '@src/core/domains/auth/utils/comparePassword'; import createJwt from '@src/core/domains/auth/utils/createJwt'; import decodeJwt from '@src/core/domains/auth/utils/decodeJwt'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { App } from '@src/core/services/App'; export default class AuthService extends Service implements IAuthService { @@ -163,4 +166,22 @@ export default class AuthService extends Service implements IAuthSe return routes; } + /** + * Returns the currently authenticated user from the request context. + * @returns The user model if the user is authenticated, or null if not. + */ + user(): User | null { + return App.getValue('user') ?? null; + } + + /** + * Returns the security middleware for the AuthService. + * + * @returns The middleware that will run security checks defined in the route. + * @memberof AuthService + */ + securityMiddleware() { + return securityMiddleware; + } + } \ No newline at end of file diff --git a/src/core/domains/auth/services/Security.ts b/src/core/domains/auth/services/Security.ts new file mode 100644 index 000000000..40ab63a93 --- /dev/null +++ b/src/core/domains/auth/services/Security.ts @@ -0,0 +1,119 @@ +import Singleton from "@src/core/base/Singleton"; +import { IModel } from "@src/core/interfaces/IModel"; +import { App } from "@src/core/services/App"; + +// eslint-disable-next-line no-unused-vars +export type SecurityCallback = (...args: any[]) => boolean; + +/** + * An interface for defining security callbacks with an identifier. + */ +export type IdentifiableSecurityCallback = { + // The identifier for the security callback. + id: string; + // The condition for when the security check should be executed. Defaults to 'always'. + when: string | null; + // The arguments for the security callback. + arguements?: Record; + // The security callback function. + callback: SecurityCallback; +} + +/** + * A list of security identifiers. + */ +export const SecurityIdentifiers = { + RESOURCE_OWNER: 'resourceOwner', + HAS_ROLE: 'hasRole', + CUSTOM: 'custom' +} as const; + +/** + * Security class with static methods for basic defining security callbacks. + */ +class Security extends Singleton { + + /** + * The condition for when the security check should be executed. + */ + public when: string = 'always'; + + /** + * Sets the condition for when the security check should be executed. + * + * @param condition - The condition value. If the value is 'always', the security check is always executed. + * @returns The Security class instance for chaining. + */ + public static when(condition: string): typeof Security { + this.getInstance().when = condition; + return this; + } + + /** + * Gets and then resets the condition for when the security check should be executed to always. + * @returns The when condition + */ + public static getWhenAndReset(): string { + const when = this.getInstance().when; + this.getInstance().when = 'always'; + return when; + } + + /** + * Checks if the currently logged in user is the owner of the given resource. + * + * @param attribute - The key of the resource attribute that should contain the user id. + * @returns A security callback that can be used in the security definition. + */ + public static resourceOwner(attribute: string = 'userId'): IdentifiableSecurityCallback { + return { + id: SecurityIdentifiers.RESOURCE_OWNER, + when: Security.getWhenAndReset(), + arguements: { key: attribute }, + callback: (resource: IModel) => { + if(typeof resource.getAttribute !== 'function') { + throw new Error('Resource is not an instance of IModel'); + } + + return resource.getAttribute(attribute) === App.container('auth').user()?.getId() + } + } + } + + /** + * Checks if the currently logged in user has the given role. + * @param role The role to check. + * @returns A callback function to be used in the security definition. + */ + public static hasRole(roles: string | string[]): IdentifiableSecurityCallback { + return { + id: SecurityIdentifiers.HAS_ROLE, + when: Security.getWhenAndReset(), + callback: () => { + const user = App.container('auth').user(); + return user?.hasRole(roles) ?? false + } + } + } + + /** + * Creates a custom security callback. + * + * @param identifier - The identifier for the security callback. + * @param callback - The callback to be executed to check the security. + * @param rest - The arguments for the security callback. + * @returns A callback function to be used in the security definition. + */ + public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IdentifiableSecurityCallback { + return { + id: identifier, + when: Security.getWhenAndReset(), + callback: () => { + return callback(...rest) + } + } + } + +} + +export default Security \ No newline at end of file diff --git a/src/core/domains/auth/services/SecurityReader.ts b/src/core/domains/auth/services/SecurityReader.ts new file mode 100644 index 000000000..f3b5e01cb --- /dev/null +++ b/src/core/domains/auth/services/SecurityReader.ts @@ -0,0 +1,52 @@ + +import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; +import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +class SecurityReader { + + /** + * Finds a security callback in the security callbacks of the given route resource options. + * + * @param options - The route resource options containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. + * @returns The found security callback, or undefined if not found. + */ + public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string): IdentifiableSecurityCallback | undefined { + return this.find(options.security ?? [], id, when); + } + + /** + * Finds a security callback from the security callbacks associated with the given request. + * + * @param req - The request object containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition. + * @returns The found security callback, or undefined if not found. + */ + public static findFromRequest(req: BaseRequest, id: string, when?: string): IdentifiableSecurityCallback | undefined { + return this.find(req.security ?? [], id, when); + } + + /** + * Finds a security callback in the given array of security callbacks. + * + * @param security - The array of security callbacks to search. + * @param options - The route resource options containing the security callbacks. + * @param id - The id of the security callback to find. + * @param when - The when condition to match. If not provided, the method will return the first match. + * @returns The security callback if found, or undefined if not found. + */ + public static find(security: IdentifiableSecurityCallback[], id: string, when?: string): IdentifiableSecurityCallback | undefined { + return security?.find(security => { + + const matchesWhenCondition = when !== 'always' && security.when === when; + + return security.id === id && matchesWhenCondition; + }); + } + +} + +export default SecurityReader \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index 680ecde31..cbc637943 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -3,6 +3,7 @@ import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; + /** * Creates a new instance of the model * @@ -21,6 +22,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp catch (err) { if (err instanceof Error) { responseError(req, res, err) + return; } res.status(500).send({ error: 'Something went wrong' }) diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 1d88165da..21a94db7e 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,6 +1,10 @@ import Repository from '@src/core/base/Repository'; +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; @@ -15,6 +19,8 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.DESTROY); + const repository = new Repository(options.resource); const result = await repository.findById(req.params?.id); @@ -23,6 +29,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new ModelNotFound('Resource not found'); } + if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(result)) { + responseError(req, res, new ForbiddenResourceError(), 401) + return; + } + await result.delete(); res.send({ success: true }) @@ -30,9 +41,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp catch (err) { if(err instanceof ModelNotFound) { responseError(req, res, err, 404) + return; } if (err instanceof Error) { responseError(req, res, err) + return; } res.status(500).send({ error: 'Something went wrong' }) diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index 7e5e64085..a465d5930 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -1,9 +1,24 @@ import Repository from '@src/core/base/Repository'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; +import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from '@src/core/interfaces/IModel'; +import IModelData from '@src/core/interfaces/IModelData'; +import { App } from '@src/core/services/App'; import { Response } from 'express'; +/** + * Formats the results by excluding guarded properties + * + * @param results + * @returns + */ +const formatResults = (results: IModel[]) => results.map(result => result.getData({ excludeGuarded: true }) as IModel); + /** * Finds all records in the resource's repository * @@ -13,11 +28,48 @@ import { Response } from 'express'; * @returns {Promise} */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { - - const repository = new Repository(options.resource); + try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.ALL) + + const repository = new Repository(options.resource); + + let results: IModel[] = []; + + /** + * When a resourceOwnerSecurity is defined, we need to find all records that are owned by the user + */ + if (resourceOwnerSecurity) { + const propertyKey = resourceOwnerSecurity.arguements?.key; + const userId = App.container('auth').user()?.getId(); + + if (!userId) { + responseError(req, res, new UnauthorizedError(), 401); + return; + } + + if (typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + results = await repository.findMany({ [propertyKey]: userId }) + + res.send(formatResults(results)) + return; + } + + /** + * Finds all results without any restrictions + */ + results = await repository.findMany(); - let results = await repository.findMany(); - results = results.map(result => result.getData({ excludeGuarded : true }) as IModel); + res.send(formatResults(results)) + } + catch (err) { + if (err instanceof Error) { + responseError(req, res, err) + return; + } - res.send(results) + res.status(500).send({ error: 'Something went wrong' }) + } } \ No newline at end of file diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 5a0b4f2c7..8c407bfb7 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,9 +1,14 @@ import Repository from '@src/core/base/Repository'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; +import { App } from '@src/core/services/App'; import { Response } from 'express'; /** @@ -16,9 +21,45 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.SHOW); + const repository = new Repository(options.resource); - const result = await repository.findById(req.params?.id); + let result: IModel | null = null; + + /** + * When a resourceOwnerSecurity is defined, we need to find the record that is owned by the user + */ + if(resourceOwnerSecurity) { + const propertyKey = resourceOwnerSecurity.arguements?.key; + const userId = App.container('auth').user()?.getId(); + + if(!userId) { + responseError(req, res, new UnauthorizedError()); + } + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + result = await repository.findOne({ + id: req.params?.id, + [propertyKey]: userId + }) + + if (!result) { + throw new ModelNotFound('Resource not found'); + } + + res.send(result?.getData({ excludeGuarded: true }) as IModel); + + return; + } + + /** + * Find resource without restrictions + */ + result = await repository.findById(req.params?.id); if (!result) { throw new ModelNotFound('Resource not found'); @@ -29,9 +70,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp catch (err) { if(err instanceof ModelNotFound) { responseError(req, res, err, 404) + return; } if (err instanceof Error) { responseError(req, res, err) + return; } res.status(500).send({ error: 'Something went wrong' }) diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index db0578c37..2f316a8ca 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -1,6 +1,10 @@ import Repository from '@src/core/base/Repository'; +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security'; +import SecurityReader from '@src/core/domains/auth/services/SecurityReader'; import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRouteResourceOptions'; import responseError from '@src/core/domains/express/requests/responseError'; +import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; @@ -16,6 +20,8 @@ import { Response } from 'express'; */ export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { + const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, RouteResourceTypes.UPDATE); + const repository = new Repository(options.resource); const result = await repository.findById(req.params?.id); @@ -24,6 +30,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new ModelNotFound('Resource not found'); } + if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(result)) { + responseError(req, res, new ForbiddenResourceError(), 403) + return; + } + result.fill(req.body); await result.save(); @@ -32,9 +43,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp catch (err) { if(err instanceof ModelNotFound) { responseError(req, res, err, 404) + return; } if (err instanceof Error) { responseError(req, res, err) + return; } res.status(500).send({ error: 'Something went wrong' }) diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index 9391eb82d..360880088 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -1,3 +1,4 @@ +import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security'; import { IRouteAction } from '@src/core/domains/express/interfaces/IRouteAction'; import { ValidatorCtor } from '@src/core/domains/validator/types/ValidatorCtor'; import { Middleware } from '@src/core/interfaces/Middleware.t'; @@ -10,4 +11,5 @@ export interface IRoute { middlewares?: Middleware[]; validator?: ValidatorCtor; validateBeforeAction?: boolean; + security?: IdentifiableSecurityCallback[]; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 3d450a82a..742442534 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -1,6 +1,7 @@ +import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; -import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; import { ValidatorCtor } from "@src/core/domains/validator/types/ValidatorCtor"; +import { IModel, ModelConstructor } from "@src/core/interfaces/IModel"; export type ResourceType = 'index' | 'create' | 'update' | 'show' | 'delete'; @@ -11,4 +12,5 @@ export interface IRouteResourceOptions extends Pick { name: string; createValidator?: ValidatorCtor; updateValidator?: ValidatorCtor; + security?: IdentifiableSecurityCallback[]; } \ No newline at end of file diff --git a/src/core/domains/express/middleware/authorize.ts b/src/core/domains/express/middleware/authorize.ts index b7b396624..763739aae 100644 --- a/src/core/domains/express/middleware/authorize.ts +++ b/src/core/domains/express/middleware/authorize.ts @@ -31,6 +31,8 @@ export const authorize = () => async (req: BaseRequest, res: Response, next: Nex req.user = user; req.apiToken = apiToken + App.setValue('user', user) + next(); } catch (error) { diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index 0ca860571..46c7423aa 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -10,6 +10,17 @@ import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; import routeGroupUtil from "@src/core/domains/express/utils/routeGroupUtil"; +/** + * Resource types that can be utilized when adding Security to a route + */ +export const RouteResourceTypes = { + ALL: 'all', + SHOW: 'show', + CREATE: 'create', + UPDATE: 'update', + DESTROY: 'destroy' +} as const + /** * Returns a group of routes for a given resource * - name.index - GET - /name @@ -35,14 +46,18 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { name: `${name}.index`, method: 'get', path: `/${name}`, - action: resourceAction(options, resourceIndex) + action: resourceAction(options, resourceIndex), + middlewares: options.middlewares, + security: options.security }), // Get resource by id Route({ name: `${name}.show`, method: 'get', path: `/${name}/:id`, - action: resourceAction(options, resourceShow) + action: resourceAction(options, resourceShow), + middlewares: options.middlewares, + security: options.security }), // Update resource by id Route({ @@ -50,14 +65,18 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { method: 'put', path: `/${name}/:id`, action: resourceAction(options, resourceUpdate), - validator: options.updateValidator + validator: options.updateValidator, + middlewares: options.middlewares, + security: options.security }), // Delete resource by id Route({ name: `${name}.destroy`, method: 'delete', path: `/${name}/:id`, - action: resourceAction(options, resourceDelete) + action: resourceAction(options, resourceDelete), + middlewares: options.middlewares, + security: options.security }), // Create resource Route({ @@ -65,7 +84,9 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { method: 'post', path: `/${name}`, action: resourceAction(options, resourceCreate), - validator: options.createValidator + validator: options.createValidator, + middlewares: options.middlewares, + security: options.security }) ]) diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 8975966d4..42a4c0772 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -114,6 +114,14 @@ export default class ExpressService extends Service implements I ); } + if(route?.security) { + const securityMiddleware = App.container('auth').securityMiddleware() + + middlewares.push( + securityMiddleware({ route }) + ) + } + return middlewares; } diff --git a/src/core/domains/express/types/BaseRequest.t.ts b/src/core/domains/express/types/BaseRequest.t.ts index f353c28b4..899c21f5e 100644 --- a/src/core/domains/express/types/BaseRequest.t.ts +++ b/src/core/domains/express/types/BaseRequest.t.ts @@ -1,8 +1,9 @@ import IAuthorizedRequest from "@src/core/domains/auth/interfaces/IAuthorizedRequest"; +import ISecurityRequest from "@src/core/domains/auth/interfaces/ISecurityRequest"; import IValidatorRequest from "@src/core/domains/express/interfaces/IValidatorRequest"; import { Request } from "express"; /** * Extends the express Request object with auth and validator properties. */ -export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest; \ No newline at end of file +export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest & ISecurityRequest; \ No newline at end of file diff --git a/src/core/domains/validator/base/BaseValidator.ts b/src/core/domains/validator/base/BaseValidator.ts index 550c964a0..ed662aeb3 100644 --- a/src/core/domains/validator/base/BaseValidator.ts +++ b/src/core/domains/validator/base/BaseValidator.ts @@ -107,6 +107,14 @@ abstract class BaseValidator

im } } + /** + * Gets the Joi validation options + * @returns The Joi validation options + */ + getJoiOptions(): Joi.ValidationOptions { + return {} + } + } export default BaseValidator \ No newline at end of file diff --git a/src/core/domains/validator/interfaces/IValidator.ts b/src/core/domains/validator/interfaces/IValidator.ts index a1b649096..7a68c2b91 100644 --- a/src/core/domains/validator/interfaces/IValidator.ts +++ b/src/core/domains/validator/interfaces/IValidator.ts @@ -40,6 +40,12 @@ interface IValidator * @returns The validator instance. */ setErrorMessage(customMessages: Record): IValidator; + + /** + * Gets the Joi validation options + * @returns The Joi validation options + */ + getJoiOptions(): ValidationOptions; } export default IValidator diff --git a/src/core/domains/validator/middleware/validateMiddleware.ts b/src/core/domains/validator/middleware/validateMiddleware.ts index 69997722c..baed03299 100644 --- a/src/core/domains/validator/middleware/validateMiddleware.ts +++ b/src/core/domains/validator/middleware/validateMiddleware.ts @@ -18,7 +18,13 @@ export const validateMiddleware = ({validator, validateBeforeAction}: ValidatorM req.validator = validator; if(validateBeforeAction) { - const result = await validator.validate(req.body); + const result = await validator.validate( + req.body, + { + stripUnknown: true, + ...validator.getJoiOptions() + } + ); if(!result.success) { res.send({ diff --git a/src/core/services/App.ts b/src/core/services/App.ts index 8b771f9d2..65ddd755c 100644 --- a/src/core/services/App.ts +++ b/src/core/services/App.ts @@ -18,6 +18,28 @@ export class App extends Singleton { */ public env!: string; + /** + * Global values + */ + protected values: Record = {}; + + /** + * Sets a value + * @param key The key of the value + * @param value The value to set + */ + public static setValue(key: string, value: unknown): void { + this.getInstance().values[key] = value; + } + + /** + * Gets a value + * @param key The key of the value to get + */ + public static getValue(key: string): T | undefined { + return this.getInstance().values[key] as T; + } + /** * Sets a container * @param name The name of the container