diff --git a/src/core/domains/console/commands/ListRoutesCommand.ts b/src/core/domains/console/commands/ListRoutesCommand.ts index 826c42c25..655362c0a 100644 --- a/src/core/domains/console/commands/ListRoutesCommand.ts +++ b/src/core/domains/console/commands/ListRoutesCommand.ts @@ -9,12 +9,11 @@ export default class ListRoutesCommand extends BaseCommand { public keepProcessAlive = false; - /** * Execute the command */ async execute() { - const showDetails = (this.getArguementByKey('details')?.value ?? false) !== false; + const showDetails = this.parsedArgumenets.find(arg => ['--details', '--d', '--detailed'].includes(arg.value)); const expressService = App.container('express') this.input.clearScreen(); @@ -27,8 +26,10 @@ export default class ListRoutesCommand extends BaseCommand { this.input.writeLine(` Name: ${route.name}`); this.input.writeLine(` Method: ${route.method}`); this.input.writeLine(` Action: ${route.action.name}`); - this.input.writeLine(` Middleware: ${route.middlewares?.map(m => m.name).join(', ')}`); - this.input.writeLine(` Validators: ${route.validator?.name ?? 'None'}`); + this.input.writeLine(` Middleware: [${route.middlewares?.map(m => m.name).join(', ') ?? ''}]`); + this.input.writeLine(` Validators: [${route.validator?.name ?? ''}]`); + this.input.writeLine(` Scopes: [${route.scopes?.join(', ') ?? ''}]`); + this.input.writeLine(` Security: [${route.security?.map(s => s.id).join(', ')}]`); this.input.writeLine(); return; } diff --git a/src/core/domains/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index bc0087b7f..b8143feee 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -4,8 +4,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRou import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; @@ -21,7 +22,7 @@ import { Response } from 'express'; export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.CREATE]); - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.CREATE, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.CREATE, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 881423840..a042ec504 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -4,8 +4,9 @@ import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedErr 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 { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; @@ -21,7 +22,7 @@ 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 authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.DESTROY, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.DESTROY, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index d51f41452..8fc3186a9 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -4,8 +4,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRou import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { IModel } from '@src/core/interfaces/IModel'; import IModelData from '@src/core/interfaces/IModelData'; @@ -30,7 +31,7 @@ const formatResults = (results: IModel[]) => results.map(result => r export default async (req: BaseRequest, res: Response, options: IRouteResourceOptions): Promise => { try { const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) - const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.ALL, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.ALL, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/actions/resourceShow.ts b/src/core/domains/express/actions/resourceShow.ts index 0b9bff15f..a2405e661 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -5,8 +5,9 @@ import { IRouteResourceOptions } from '@src/core/domains/express/interfaces/IRou import responseError from '@src/core/domains/express/requests/responseError'; import { RouteResourceTypes } from '@src/core/domains/express/routing/RouteResource'; import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; @@ -23,7 +24,7 @@ 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 authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.SHOW, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.SHOW, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index 6b1f9b976..ce9f9a0a0 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -5,8 +5,9 @@ import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSe 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 { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; @@ -23,7 +24,7 @@ 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 authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.UPDATE, ALWAYS]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZED, [RouteResourceTypes.UPDATE, ALWAYS]); if(authorizationSecurity && !authorizationSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401) diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index e3e41ed99..dc1f59cd9 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -5,10 +5,12 @@ import { Middleware } from '@src/core/interfaces/Middleware.t'; export interface IRoute { name: string; - resourceType?: string; path: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; action: IRouteAction; + resourceType?: string; + scopes?: string[]; + scopesSecurityEnabled?: boolean; middlewares?: Middleware[]; validator?: ValidatorCtor; validateBeforeAction?: boolean; diff --git a/src/core/domains/express/interfaces/IRouteResourceOptions.ts b/src/core/domains/express/interfaces/IRouteResourceOptions.ts index 742442534..604cdb45c 100644 --- a/src/core/domains/express/interfaces/IRouteResourceOptions.ts +++ b/src/core/domains/express/interfaces/IRouteResourceOptions.ts @@ -1,16 +1,18 @@ -import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; +import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; 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'; export interface IRouteResourceOptions extends Pick { + name: string; + resource: ModelConstructor; except?: ResourceType[]; only?: ResourceType[]; - resource: ModelConstructor; - name: string; createValidator?: ValidatorCtor; updateValidator?: ValidatorCtor; - security?: IdentifiableSecurityCallback[]; + security?: IIdentifiableSecurityCallback[]; + scopes?: string[]; + scopesSecurityEnabled?: boolean; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts index a9ba1bd6b..2ebf4a778 100644 --- a/src/core/domains/express/interfaces/ISecurity.ts +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -21,6 +21,9 @@ export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; export type IIdentifiableSecurityCallback = { // The identifier for the security callback. id: string; + // Include another security rule in the callback. + // TODO: We could add another type here 'alsoArguments' if extra parameters are required + also?: string; // The condition for when the security check should be executed. Defaults to 'always'. when: string[] | null; // The condition for when the security check should never be executed. diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts index 61e3bcc7f..fbcbe8258 100644 --- a/src/core/domains/express/middleware/securityMiddleware.ts +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -4,8 +4,9 @@ import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; import { ISecurityMiddleware } from '@src/core/domains/express/interfaces/ISecurity'; import responseError from '@src/core/domains/express/requests/responseError'; -import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import { ALWAYS } from '@src/core/domains/express/services/Security'; import SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; @@ -17,7 +18,7 @@ const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { /** * Applies the authorization security check on the request. */ -const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Response): Promise => { +const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Response): Promise => { const conditions = [ALWAYS] @@ -25,7 +26,7 @@ const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Resp conditions.push(route.resourceType) } - const authorizeSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, conditions); + const authorizeSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZED, conditions); if (authorizeSecurity) { try { @@ -33,7 +34,7 @@ const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Resp if(!authorizeSecurity.callback(req)) { responseError(req, res, new UnauthorizedError(), 401); - return; + return null; } } catch (err) { @@ -61,6 +62,23 @@ const applyHasRoleSecurity = (req: BaseRequest, res: Response): void | null => { } +/** + * Checks if the hasRole security has been defined and validates it. + * If the hasRole security is defined and the validation fails, it will send a 403 response with a ForbiddenResourceError. + */ +const applyHasScopeSecurity = (req: BaseRequest, res: Response): void | null => { + + // Check if the hasRole security has been defined and validate + const securityHasScope = SecurityReader.findFromRequest(req, SecurityIdentifiers.HAS_SCOPE); + + if (securityHasScope && !securityHasScope.callback(req)) { + responseError(req, res, new ForbiddenResourceError(), 403) + return null; + } + +} + + /** * 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. @@ -80,7 +98,9 @@ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req * Authorizes the user * Depending on option 'throwExceptionOnUnauthorized', can allow continue processing on failed auth */ - await applyAuthorizeSecurity(route, req, res) + if(await applyAuthorizeSecurity(route, req, res) === null) { + return; + } /** * Check if the authorized user passes the has role security @@ -89,6 +109,13 @@ export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req return; } + /** + * Check if the authorized user passes the has scope security + */ + if(applyHasScopeSecurity(req, res) === null) { + return; + } + /** * Security is OK, continue */ diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index d7cfeae45..a067abe82 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -8,6 +8,7 @@ import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; +import RouteResourceScope from "@src/core/domains/express/routing/RouteResourceScope"; import routeGroupUtil from "@src/core/domains/express/utils/routeGroupUtil"; /** @@ -40,11 +41,22 @@ export const RouteResourceTypes = { const RouteResource = (options: IRouteResourceOptions): IRoute[] => { const name = options.name.startsWith('/') ? options.name.slice(1) : options.name + const { + scopes = [], + scopesSecurityEnabled = false + } = options; + const routes = RouteGroup([ - // Get all resources + // Get all resources Route({ name: `${name}.index`, resourceType: RouteResourceTypes.ALL, + scopes: RouteResourceScope.getScopes({ + name, + types: 'read', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'get', path: `/${name}`, action: resourceAction(options, resourceIndex), @@ -55,6 +67,12 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.show`, resourceType: RouteResourceTypes.SHOW, + scopes: RouteResourceScope.getScopes({ + name, + types: 'read', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'get', path: `/${name}/:id`, action: resourceAction(options, resourceShow), @@ -65,6 +83,12 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.update`, resourceType: RouteResourceTypes.UPDATE, + scopes: RouteResourceScope.getScopes({ + name, + types: 'write', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'put', path: `/${name}/:id`, action: resourceAction(options, resourceUpdate), @@ -76,6 +100,12 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.destroy`, resourceType: RouteResourceTypes.DESTROY, + scopes: RouteResourceScope.getScopes({ + name, + types: 'delete', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'delete', path: `/${name}/:id`, action: resourceAction(options, resourceDelete), @@ -86,6 +116,12 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { Route({ name: `${name}.create`, resourceType: RouteResourceTypes.CREATE, + scopes: RouteResourceScope.getScopes({ + name, + types: 'read', + additionalScopes: scopes + }), + scopesSecurityEnabled, method: 'post', path: `/${name}`, action: resourceAction(options, resourceCreate), diff --git a/src/core/domains/express/routing/RouteResourceScope.ts b/src/core/domains/express/routing/RouteResourceScope.ts new file mode 100644 index 000000000..65d16d761 --- /dev/null +++ b/src/core/domains/express/routing/RouteResourceScope.ts @@ -0,0 +1,48 @@ +export type RouteResourceScopeType = 'read' | 'write' | 'delete' | 'all'; + +export const defaultRouteResourceScopes: RouteResourceScopeType[] = ['read', 'write', 'delete', 'all']; + +export type GetScopesOptions = { + name: string, + types?: RouteResourceScopeType[] | RouteResourceScopeType, + additionalScopes?: string[] +} + +class RouteResourceScope { + + /** + * Generates a list of scopes, given a resource name and some scope types. + * @param name The name of the resource + * @param types The scope type(s) to generate scopes for. If a string, it will be an array of only that type. + * @param additionalScopes Additional scopes to append to the output + * @returns A list of scopes in the format of 'resourceName:scopeType' + * + * Example: + * + * const scopes = RouteResourceScope.getScopes('blog', ['write', 'all'], ['otherScope']) + * + * // Output + * [ + * 'blog:write', + * 'blog:all', + * 'otherScope' + * ] + */ + public static getScopes(options: GetScopesOptions): string[] { + const { + name, + types = defaultRouteResourceScopes, + additionalScopes = [] + } = options + + const typesArray = typeof types === 'string' ? [types] : types; + + return [ + ...typesArray.map(type => `${name}:${type}`), + ...additionalScopes + ]; + } + +} + +export default RouteResourceScope \ No newline at end of file diff --git a/src/core/domains/express/security/authorizedSecurity.ts b/src/core/domains/express/rules/authorizedSecurity.ts similarity index 100% rename from src/core/domains/express/security/authorizedSecurity.ts rename to src/core/domains/express/rules/authorizedSecurity.ts diff --git a/src/core/domains/express/security/hasRoleSecurity.ts b/src/core/domains/express/rules/hasRoleSecurity.ts similarity index 100% rename from src/core/domains/express/security/hasRoleSecurity.ts rename to src/core/domains/express/rules/hasRoleSecurity.ts diff --git a/src/core/domains/express/rules/hasScopeSecurity.ts b/src/core/domains/express/rules/hasScopeSecurity.ts new file mode 100644 index 000000000..548ec9d8e --- /dev/null +++ b/src/core/domains/express/rules/hasScopeSecurity.ts @@ -0,0 +1,20 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +/** + * Checks if the given scope(s) are present in the scopes of the current request's API token. + * If no API token is found, it will return false. + * @param req The request object + * @param scope The scope(s) to check + * @returns True if all scopes are present, false otherwise + */ +const hasScopeSecurity = (req: BaseRequest, scope: string | string[]): boolean => { + const apiToken = req.apiToken; + + if(!apiToken) { + return false; + } + + return apiToken?.hasScope(scope) +} + +export default hasScopeSecurity \ No newline at end of file diff --git a/src/core/domains/express/security/resourceOwnerSecurity.ts b/src/core/domains/express/rules/resourceOwnerSecurity.ts similarity index 100% rename from src/core/domains/express/security/resourceOwnerSecurity.ts rename to src/core/domains/express/rules/resourceOwnerSecurity.ts diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index ffe7a0e2a..59a5f89b0 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -5,6 +5,7 @@ import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; import endCurrentRequestMiddleware from '@src/core/domains/express/middleware/endCurrentRequestMiddleware'; import requestIdMiddleware from '@src/core/domains/express/middleware/requestIdMiddleware'; import { securityMiddleware } from '@src/core/domains/express/middleware/securityMiddleware'; +import SecurityRules, { SecurityIdentifiers } from '@src/core/domains/express/services/SecurityRules'; import { Middleware } from '@src/core/interfaces/Middleware.t'; import { App } from '@src/core/services/App'; import express from 'express'; @@ -21,7 +22,7 @@ export default class ExpressService extends Service implements I private app: express.Express private registedRoutes: IRoute[] = []; - + /** * Config defined in @src/config/http/express.ts * @param config @@ -52,9 +53,9 @@ export default class ExpressService extends Service implements I * Starts listening for connections on the port specified in the config. * If no port is specified, the service will not start listening. */ - public async listen(): Promise { - const port = this.config?.port - + public async listen(): Promise { + const port = this.config?.port + return new Promise(resolve => { this.app.listen(port, () => resolve()) }) @@ -75,11 +76,22 @@ export default class ExpressService extends Service implements I * @param route */ public bindSingleRoute(route: IRoute): void { - const middlewares = this.addValidatorMiddleware(route); + const userDefinedMiddlewares = route.middlewares ?? []; + + // Add security and validator middlewares + const middlewares: Middleware[] = [ + ...userDefinedMiddlewares, + ...this.addValidatorMiddleware(route), + ...this.addSecurityMiddleware(route), + ]; + + // Add route handlers const handlers = [...middlewares, route?.action] - console.log(`[Express] binding route ${route.method.toUpperCase()}: '${route.path}' as '${route.name}'`) + // Log route + this.logRoute(route) + // Bind route switch (route.method) { case 'get': this.app.get(route.path, handlers); @@ -98,7 +110,7 @@ export default class ExpressService extends Service implements I break; default: throw new Error(`Unsupported method ${route.method} for path ${route.path}`); - } + } this.registedRoutes.push(route) } @@ -109,8 +121,11 @@ export default class ExpressService extends Service implements I * @returns middlewares with added validator middleware */ public addValidatorMiddleware(route: IRoute): Middleware[] { - const middlewares = [...route?.middlewares ?? []]; + const middlewares: Middleware[] = []; + /** + * Add validator middleware + */ if (route?.validator) { const validatorMiddleware = App.container('validate').middleware() const validator = new route.validator(); @@ -121,7 +136,33 @@ export default class ExpressService extends Service implements I ); } - if(route?.security) { + return middlewares; + } + + /** + * Adds security middleware to the route. If the route has scopesSecurityEnabled + * and scopes is present, it adds the HAS_SCOPE security rule to the route. + * Then it adds the security middleware to the route's middleware array. + * @param route The route to add the middleware to + * @returns The route's middleware array with the security middleware added + */ + public addSecurityMiddleware(route: IRoute): Middleware[] { + const middlewares: Middleware[] = []; + + /** + * Check if scopes is present, add related security rule + */ + if (route?.scopesSecurityEnabled && route?.scopes?.length) { + route.security = [ + ...(route.security ?? []), + SecurityRules[SecurityIdentifiers.HAS_SCOPE](route.scopes) + ] + } + + /** + * Add security middleware + */ + if (route?.security) { middlewares.push( securityMiddleware({ route }) ) @@ -153,4 +194,25 @@ export default class ExpressService extends Service implements I return this.registedRoutes } + /** + * Logs a route binding to the console. + * @param route - IRoute instance + */ + private logRoute(route: IRoute): void { + let str = `[Express] binding route ${route.method.toUpperCase()}: '${route.path}' as '${route.name}'`; + + if (route.scopes?.length) { + str += ` with scopes: [${route.scopes.join(', ')}]` + + if (route?.scopesSecurityEnabled) { + str += ' (scopes security ON)' + } + else { + str += ' (scopes security OFF)' + } + } + + console.log(str) + } + } diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts index f409522f8..0d755ddba 100644 --- a/src/core/domains/express/services/Security.ts +++ b/src/core/domains/express/services/Security.ts @@ -1,20 +1,6 @@ import Singleton from "@src/core/base/Singleton"; import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; -import authorizedSecurity from "@src/core/domains/express/security/authorizedSecurity"; -import hasRoleSecurity from "@src/core/domains/express/security/hasRoleSecurity"; -import resourceOwnerSecurity from "@src/core/domains/express/security/resourceOwnerSecurity"; -import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; -import { IModel } from "@src/core/interfaces/IModel"; - -/** - * A list of security identifiers. - */ -export const SecurityIdentifiers = { - AUTHORIZATION: 'authorization', - RESOURCE_OWNER: 'resourceOwner', - HAS_ROLE: 'hasRole', - CUSTOM: 'custom' -} as const; +import SecurityRules, { SecurityIdentifiers } from "@src/core/domains/express/services/SecurityRules"; /** * The default condition for when the security check should be executed. @@ -64,9 +50,9 @@ class Security extends Singleton { * Gets and then resets the condition for when the security check should be executed to always. * @returns The when condition */ - public static getWhenAndReset(): string[] | null { - const when = this.getInstance().when; - this.getInstance().when = null; + public getWhenAndReset(): string[] | null { + const when = this.when; + this.when = null; return when; } @@ -74,26 +60,41 @@ class Security extends Singleton { * Gets and then resets the condition for when the security check should never be executed. * @returns The when condition */ - public static getNeverAndReset(): string[] | null { - const never = this.getInstance().never; - this.getInstance().never = null; + public getNeverAndReset(): string[] | null { + const never = this.never; + this.never = null; return never; } /** * Checks if the currently logged in user is the owner of the given resource. + * + * Usage with RouteResource: + * - CREATE - Adds the attribute to the resource model + * - UPDATE - Checks the authroized user is the owner of the resource + * - DELETE - Checks the authroized user is the owner of the resource + * - SHOW - Only shows the resource if the authroized user is the owner of the resource + * - INDEX - Filters the resources by the authroized user + * + * Example usage within an Action/Controller: + * + * // Instance of a model (resource) + * const result = await repository.findById(id) + * + * // Check if resourceOwner is applicable + * const resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, ['SomeConditionValue']); + * + * // The callback checks the attribute on the resource model matches the authorized user + * if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { + * responseError(req, res, new ForbiddenResourceError(), 403) + * return; + * } * * @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'): IIdentifiableSecurityCallback { - return { - id: SecurityIdentifiers.RESOURCE_OWNER, - when: Security.getWhenAndReset(), - never: Security.getNeverAndReset(), - arguements: { key: attribute }, - callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) - } + return SecurityRules[SecurityIdentifiers.RESOURCE_OWNER](attribute); } /** @@ -102,7 +103,7 @@ class Security extends Singleton { * Authorization failure does not throw any exceptions, this method allows the middleware to pass regarldess of authentication failure. * This will allow the user to have full control over the unathenticated flow. * - * Example: + * Example usage within an Action/Controller: * const authorizationSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, [ALWAYS]); * * if(authorizationSecurity && !authorizationSecurity.callback(req)) { @@ -115,15 +116,7 @@ class Security extends Singleton { * @returns A security callback that can be used in the security definition. */ public static authorized(): IIdentifiableSecurityCallback { - return { - id: SecurityIdentifiers.AUTHORIZATION, - when: Security.getWhenAndReset(), - never: Security.getNeverAndReset(), - arguements: { - throwExceptionOnUnauthorized: false - }, - callback: (req: BaseRequest) => authorizedSecurity(req) - } + return SecurityRules[SecurityIdentifiers.AUTHORIZED](); } /** @@ -133,15 +126,7 @@ class Security extends Singleton { * @returns A security callback that can be used in the security definition. */ public static authorizationThrowsException(): IIdentifiableSecurityCallback { - return { - id: SecurityIdentifiers.AUTHORIZATION, - when: Security.getWhenAndReset(), - never: Security.getNeverAndReset(), - arguements: { - throwExceptionOnUnauthorized: true - }, - callback: (req: BaseRequest) => authorizedSecurity(req) - } + return SecurityRules[SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION](); } /** @@ -150,12 +135,16 @@ class Security extends Singleton { * @returns A callback function to be used in the security definition. */ public static hasRole(roles: string | string[]): IIdentifiableSecurityCallback { - return { - id: SecurityIdentifiers.HAS_ROLE, - when: Security.getWhenAndReset(), - never: Security.getNeverAndReset(), - callback: (req: BaseRequest) => hasRoleSecurity(req, roles) - } + return SecurityRules[SecurityIdentifiers.HAS_ROLE](roles); + } + + /** + * Checks if the currently logged in user has the given scope(s). + * @param scopes The scope(s) to check. + * @returns A callback function to be used in the security definition. + */ + public static hasScope(scopes: string | string[]): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.HAS_SCOPE](scopes); } /** @@ -166,16 +155,9 @@ class Security extends Singleton { * @param rest - The arguments for the security callback. * @returns A callback function to be used in the security definition. */ - // eslint-disable-next-line no-unused-vars - public static custom(identifier: string, callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { - return { - id: identifier, - never: Security.getNeverAndReset(), - when: Security.getWhenAndReset(), - callback: (req: BaseRequest, ...rest: any[]) => { - return callback(req, ...rest) - } - } + + public static custom(callback: SecurityCallback, ...rest: any[]): IIdentifiableSecurityCallback { + return SecurityRules[SecurityIdentifiers.CUSTOM](callback, ...rest); } } diff --git a/src/core/domains/express/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts index 6411740f8..47fb5627d 100644 --- a/src/core/domains/express/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -1,6 +1,7 @@ import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions"; import { IIdentifiableSecurityCallback } from "@src/core/domains/express/interfaces/ISecurity"; import { ALWAYS } from "@src/core/domains/express/services/Security"; +import SecurityRules from "@src/core/domains/express/services/SecurityRules"; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; class SecurityReader { @@ -39,6 +40,8 @@ class SecurityReader { * @returns The security callback if found, or undefined if not found. */ public static find(security: IIdentifiableSecurityCallback[], id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { + let result: IIdentifiableSecurityCallback | undefined = undefined; + when = when ?? null; when = when && typeof when === 'string' ? [when] : when; @@ -72,11 +75,40 @@ class SecurityReader { return false; } - return security?.find(security => { - return security.id === id && + + /** + * Find by 'id' + */ + result = security?.find(security => { + const matchesIdentifier = security.id === id + + return matchesIdentifier && conditionNeverPassable(when, security.never) === false && conditionPassable(security.when); }); + + /** + * Includes security rule defined in optional 'also' property + * + * Example: hasScope rule requires authorized rule + */ + if(!result) { + + // We need to find the unrelated security rule that has the ID in 'also' + const unrelatedSecurityRule = security?.find(security => { + return security.also === id && + conditionNeverPassable(when, security.never) === false && + conditionPassable(security.when); + }); + + // The 'unrelatedSecurityRule' contains the 'also' property. + // We can use it to fetch the desired security rule. + if(unrelatedSecurityRule) { + result = SecurityRules[unrelatedSecurityRule.also as string]() + } + } + + return result } } diff --git a/src/core/domains/express/services/SecurityRules.ts b/src/core/domains/express/services/SecurityRules.ts new file mode 100644 index 000000000..a9f3212a0 --- /dev/null +++ b/src/core/domains/express/services/SecurityRules.ts @@ -0,0 +1,117 @@ +import { IIdentifiableSecurityCallback, SecurityCallback } from "@src/core/domains/express/interfaces/ISecurity" +import authorizedSecurity from "@src/core/domains/express/rules/authorizedSecurity" +import hasRoleSecurity from "@src/core/domains/express/rules/hasRoleSecurity" +import hasScopeSecurity from "@src/core/domains/express/rules/hasScopeSecurity" +import resourceOwnerSecurity from "@src/core/domains/express/rules/resourceOwnerSecurity" +import Security from "@src/core/domains/express/services/Security" +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t" +import { IModel } from "@src/core/interfaces/IModel" + +/** + * Security rules + */ +export interface ISecurityRules { + // eslint-disable-next-line no-unused-vars + [key: string]: (...args: any[]) => IIdentifiableSecurityCallback +} + +/** + * The list of security identifiers. + */ +export const SecurityIdentifiers = { + AUTHORIZED: 'authorized', + AUTHORIZED_THROW_EXCEPTION: 'authorizedThrowException', + RESOURCE_OWNER: 'resourceOwner', + HAS_ROLE: 'hasRole', + HAS_SCOPE: 'hasScope', + CUSTOM: 'custom' +} as const; + +const SecurityRules: ISecurityRules = { + + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * Does not throw exceptions on unauthorized requests. + * @returns + */ + [SecurityIdentifiers.AUTHORIZED]: () => ({ + id: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { + throwExceptionOnUnauthorized: true + }, + callback: (req: BaseRequest) => authorizedSecurity(req) + }), + + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * Throws an exception on unauthorized requests. + * @returns + */ + [SecurityIdentifiers.AUTHORIZED_THROW_EXCEPTION]: () => ({ + id: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { + throwExceptionOnUnauthorized: true + }, + callback: (req: BaseRequest) => authorizedSecurity(req) + }), + + /** + * Checks if the currently logged in user is the owner of the given resource. + * @param attribute + * @returns + */ + [SecurityIdentifiers.RESOURCE_OWNER]: (attribute: string = 'userId') => ({ + id: SecurityIdentifiers.RESOURCE_OWNER, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + arguements: { key: attribute }, + callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) + }), + + /** + * Checks if the currently logged in user has the given role. + * @param roles + * @returns + */ + [SecurityIdentifiers.HAS_ROLE]: (roles: string | string[]) => ({ + id: SecurityIdentifiers.HAS_ROLE, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + callback: (req: BaseRequest) => hasRoleSecurity(req, roles) + }), + + /** + * Checks if the currently logged in user has the given scope(s). + * @param scopes + * @returns + */ + [SecurityIdentifiers.HAS_SCOPE]: (scopes: string | string[]) => ({ + id: SecurityIdentifiers.HAS_SCOPE, + also: SecurityIdentifiers.AUTHORIZED, + when: Security.getInstance().getWhenAndReset(), + never: Security.getInstance().getNeverAndReset(), + callback: (req: BaseRequest) => hasScopeSecurity(req, scopes) + }), + + /** + * Custom security rule + * @param callback + * @param rest + * @returns + */ + // eslint-disable-next-line no-unused-vars + [SecurityIdentifiers.CUSTOM]: (callback: SecurityCallback, ...rest: any[]) => ({ + id: SecurityIdentifiers.CUSTOM, + never: Security.getInstance().getNeverAndReset(), + when: Security.getInstance().getWhenAndReset(), + callback: (req: BaseRequest, ...rest: any[]) => { + return callback(req, ...rest) + } + }) +} as const + +export default SecurityRules \ No newline at end of file