diff --git a/src/config/http.ts b/src/config/http.ts index d1695677b..08276574d 100644 --- a/src/config/http.ts +++ b/src/config/http.ts @@ -1,7 +1,7 @@ -import express from 'express'; import IExpressConfig from '@src/core/domains/express/interfaces/IExpressConfig'; import parseBooleanFromString from '@src/core/util/parseBooleanFromString'; import bodyParser from 'body-parser'; +import express from 'express'; const config: IExpressConfig = { enabled: parseBooleanFromString(process.env.ENABLE_EXPRESS, 'true'), diff --git a/src/core/domains/auth/actions/create.ts b/src/core/domains/auth/actions/create.ts index beb8b3919..ee23b1e08 100644 --- a/src/core/domains/auth/actions/create.ts +++ b/src/core/domains/auth/actions/create.ts @@ -61,6 +61,7 @@ export default async (req: Request, res: Response): Promise => { // Handle other errors if (error instanceof Error) { responseError(req, res, error); + return; } } diff --git a/src/core/domains/auth/actions/getUser.ts b/src/core/domains/auth/actions/getUser.ts index 706221717..e645cf88b 100644 --- a/src/core/domains/auth/actions/getUser.ts +++ b/src/core/domains/auth/actions/getUser.ts @@ -18,6 +18,7 @@ export default async (req: IAuthorizedRequest, res: Response) => { // If there is an error, send the error response if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/actions/login.ts b/src/core/domains/auth/actions/login.ts index 99062bdd9..c7a2ca2d8 100644 --- a/src/core/domains/auth/actions/login.ts +++ b/src/core/domains/auth/actions/login.ts @@ -38,6 +38,7 @@ export default async (req: Request, res: Response): Promise => { // Handle other errors if (error instanceof Error) { responseError(req, res, error) + return; } } } diff --git a/src/core/domains/auth/actions/revoke.ts b/src/core/domains/auth/actions/revoke.ts index 01a641e71..b5cbf93df 100644 --- a/src/core/domains/auth/actions/revoke.ts +++ b/src/core/domains/auth/actions/revoke.ts @@ -26,6 +26,7 @@ export default async (req: IAuthorizedRequest, res: Response) => { // Handle any errors if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/actions/update.ts b/src/core/domains/auth/actions/update.ts index 317655b76..f77acc69f 100644 --- a/src/core/domains/auth/actions/update.ts +++ b/src/core/domains/auth/actions/update.ts @@ -38,6 +38,7 @@ export default async (req: BaseRequest, res: Response) => { // If there is an error, send the error response if(error instanceof Error) { responseError(req, res, error) + return; } } } diff --git a/src/core/domains/auth/actions/user.ts b/src/core/domains/auth/actions/user.ts index 7c014d094..c41b8d391 100644 --- a/src/core/domains/auth/actions/user.ts +++ b/src/core/domains/auth/actions/user.ts @@ -18,6 +18,7 @@ export default (req: IAuthorizedRequest, res: Response) => { // Handle any errors if (error instanceof Error) { responseError(req, res, error); + return; } } }; diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index fc50a13c6..694d4392f 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -1,10 +1,8 @@ /* 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"; @@ -104,18 +102,4 @@ 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/IRequestIdentifiable.ts b/src/core/domains/auth/interfaces/IRequestIdentifiable.ts new file mode 100644 index 000000000..b959f395b --- /dev/null +++ b/src/core/domains/auth/interfaces/IRequestIdentifiable.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; + +export default interface IRequestIdentifiable extends Request { + id?: string; +} \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/ISecurityRequest.ts b/src/core/domains/auth/interfaces/ISecurityRequest.ts deleted file mode 100644 index 0cad0c95a..000000000 --- a/src/core/domains/auth/interfaces/ISecurityRequest.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/middleware/securityMiddleware.ts b/src/core/domains/auth/middleware/securityMiddleware.ts deleted file mode 100644 index 6c37c5c84..000000000 --- a/src/core/domains/auth/middleware/securityMiddleware.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/routes/auth.ts b/src/core/domains/auth/routes/auth.ts index fbda9c686..4549c77e5 100644 --- a/src/core/domains/auth/routes/auth.ts +++ b/src/core/domains/auth/routes/auth.ts @@ -6,7 +6,7 @@ import user from "@src/core/domains/auth/actions/user"; import authConsts from "@src/core/domains/auth/consts/authConsts"; import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig"; import { IRoute } from "@src/core/domains/express/interfaces/IRoute"; -import { authorize } from "@src/core/domains/express/middleware/authorize"; +import { authorizeMiddleware } from "@src/core/domains/express/middleware/authorizeMiddleware"; import Route from "@src/core/domains/express/routing/Route"; import RouteGroup from "@src/core/domains/express/routing/RouteGroup"; @@ -31,7 +31,7 @@ export const routes = (config: IAuthConfig): IRoute[] => { method: 'patch', path: '/auth/user', action: update, - middlewares: [authorize()], + middlewares: [authorizeMiddleware()], validator: config.validators.updateUser, validateBeforeAction: true }), @@ -40,14 +40,14 @@ export const routes = (config: IAuthConfig): IRoute[] => { method: 'get', path: '/auth/user', action: user, - middlewares: [authorize()] + middlewares: [authorizeMiddleware()] }), Route({ name: authConsts.routes.authRevoke, method: 'post', path: '/auth/revoke', action: revoke, - middlewares: [authorize()] + middlewares: [authorizeMiddleware()] }) ]) } diff --git a/src/core/domains/auth/services/AuthRequest.ts b/src/core/domains/auth/services/AuthRequest.ts new file mode 100644 index 000000000..8d3976aae --- /dev/null +++ b/src/core/domains/auth/services/AuthRequest.ts @@ -0,0 +1,39 @@ +import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { App } from "@src/core/services/App"; + +class AuthRequest { + + /** + * Attempts to authorize a request with a Bearer token. + * + * If successful, attaches the user and apiToken to the request. Sets the user in the App. + * + * @param req The request to authorize + * @returns The authorized request + * @throws UnauthorizedError if the token is invalid + */ + public static async attemptAuthorizeRequest(req: BaseRequest): Promise { + const authorization = (req.headers.authorization ?? '').replace('Bearer ', ''); + + const apiToken = await App.container('auth').attemptAuthenticateToken(authorization) + + const user = await apiToken?.user() + + if(!user || !apiToken) { + throw new UnauthorizedError(); + } + + req.user = user; + req.apiToken = apiToken + + CurrentRequest.set(req, 'user', user); + CurrentRequest.set(req, 'userId', user?.getId()) + + return req; + } + +} + +export default AuthRequest \ No newline at end of file diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index b4da482cf..a77d9b814 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -1,4 +1,3 @@ -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'; @@ -8,16 +7,14 @@ import IApiTokenModel from '@src/core/domains/auth/interfaces/IApitokenModel'; import IApiTokenRepository from '@src/core/domains/auth/interfaces/IApiTokenRepository'; import { IAuthConfig } from '@src/core/domains/auth/interfaces/IAuthConfig'; 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'; +import { JsonWebTokenError } from 'jsonwebtoken'; export default class AuthService extends Service implements IAuthService { @@ -30,7 +27,7 @@ export default class AuthService extends Service implements IAuthSe * Repository for accessing user data */ public userRepository: IUserRepository; - + /** * Repository for accessing api tokens */ @@ -51,7 +48,7 @@ export default class AuthService extends Service implements IAuthSe * Validate jwt secret */ private validateJwtSecret() { - if(!this.config.jwtSecret || this.config.jwtSecret === '') { + if (!this.config.jwtSecret || this.config.jwtSecret === '') { throw new InvalidJWTSecret(); } } @@ -66,7 +63,7 @@ export default class AuthService extends Service implements IAuthSe await apiToken.save(); return apiToken } - + /** * Creates a JWT from a user model * @param user @@ -83,7 +80,7 @@ export default class AuthService extends Service implements IAuthSe * @returns */ jwt(apiToken: IApiTokenModel): string { - if(!apiToken?.data?.userId) { + if (!apiToken?.data?.userId) { throw new Error('Invalid token'); } const payload = JWTTokenFactory.create(apiToken.data?.userId?.toString(), apiToken.data?.token); @@ -96,7 +93,7 @@ export default class AuthService extends Service implements IAuthSe * @returns */ async revokeToken(apiToken: IApiTokenModel): Promise { - if(apiToken?.data?.revokedAt) { + if (apiToken?.data?.revokedAt) { return; } @@ -110,21 +107,30 @@ export default class AuthService extends Service implements IAuthSe * @returns */ async attemptAuthenticateToken(token: string): Promise { - const decoded = decodeJwt(this.config.jwtSecret, token) as IJSonWebToken; + try { + const decoded = decodeJwt(this.config.jwtSecret, token); - const apiToken = await this.apiTokenRepository.findOneActiveToken(decoded.token) + const apiToken = await this.apiTokenRepository.findOneActiveToken(decoded.token) - if(!apiToken) { - throw new UnauthorizedError() - } + if (!apiToken) { + throw new UnauthorizedError() + } - const user = await this.userRepository.findById(decoded.uid) + const user = await this.userRepository.findById(decoded.uid) - if(!user) { - throw new UnauthorizedError() + if (!user) { + throw new UnauthorizedError() + } + + return apiToken + } + catch (err) { + if(err instanceof JsonWebTokenError) { + throw new UnauthorizedError() + } } - return apiToken + return null } /** @@ -136,11 +142,11 @@ export default class AuthService extends Service implements IAuthSe async attemptCredentials(email: string, password: string): Promise { const user = await this.userRepository.findOneByEmail(email) as IUserModel; - if(!user?.data?.id) { + if (!user?.data?.id) { throw new UnauthorizedError() } - if(user?.data?.hashedPassword && !comparePassword(password, user.data?.hashedPassword)) { + if (user?.data?.hashedPassword && !comparePassword(password, user.data?.hashedPassword)) { throw new UnauthorizedError() } @@ -153,35 +159,17 @@ export default class AuthService extends Service implements IAuthSe * @returns an array of IRoute objects, or null if auth routes are disabled */ getAuthRoutes(): IRoute[] | null { - if(!this.config.enableAuthRoutes) { + if (!this.config.enableAuthRoutes) { return null } const routes = authRoutes(this.config); - if(!this.config.enableAuthRoutesAllowCreate) { + if (!this.config.enableAuthRoutesAllowCreate) { return routes.filter((route) => route.name !== 'authCreate'); } 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 deleted file mode 100644 index 40ab63a93..000000000 --- a/src/core/domains/auth/services/Security.ts +++ /dev/null @@ -1,119 +0,0 @@ -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/express/actions/resourceCreate.ts b/src/core/domains/express/actions/resourceCreate.ts index cbc637943..f51e2ff54 100644 --- a/src/core/domains/express/actions/resourceCreate.ts +++ b/src/core/domains/express/actions/resourceCreate.ts @@ -1,5 +1,12 @@ +import User from '@src/app/models/auth/User'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSecurityError'; 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 CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import { Response } from 'express'; @@ -14,7 +21,40 @@ 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]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } + const modelInstance = new options.resource(req.body); + + /** + * When a resourceOwnerSecurity is defined, we need to set the record that is owned by the user + */ + if(resourceOwnerSecurity) { + + if(!authorizationSecurity) { + responseError(req, res, new MissingSecurityError('Expected authorized security for this route, recieved: ' + typeof authorizationSecurity), 401); + } + + const propertyKey = resourceOwnerSecurity.arguements?.key; + const userId = CurrentRequest.get(req, 'user')?.getId() + + if(typeof propertyKey !== 'string') { + throw new Error('Malformed resourceOwner security. Expected parameter \'key\' to be a string but received ' + typeof propertyKey); + } + + if(!userId) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } + + modelInstance.setAttribute(propertyKey, userId) + } + await modelInstance.save(); res.status(201).send(modelInstance.getData({ excludeGuarded: true }) as IRouteResourceOptions['resource']) diff --git a/src/core/domains/express/actions/resourceDelete.ts b/src/core/domains/express/actions/resourceDelete.ts index 21a94db7e..881423840 100644 --- a/src/core/domains/express/actions/resourceDelete.ts +++ b/src/core/domains/express/actions/resourceDelete.ts @@ -1,10 +1,11 @@ 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 UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; 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 SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { Response } from 'express'; @@ -19,8 +20,13 @@ 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 resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.DESTROY]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.DESTROY, ALWAYS]); + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } const repository = new Repository(options.resource); const result = await repository.findById(req.params?.id); @@ -29,8 +35,8 @@ 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) + if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { + responseError(req, res, new ForbiddenResourceError(), 403) return; } diff --git a/src/core/domains/express/actions/resourceIndex.ts b/src/core/domains/express/actions/resourceIndex.ts index a465d5930..b669a113e 100644 --- a/src/core/domains/express/actions/resourceIndex.ts +++ b/src/core/domains/express/actions/resourceIndex.ts @@ -1,14 +1,15 @@ +import User from '@src/app/models/auth/User'; 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 CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; 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'; /** @@ -29,8 +30,14 @@ 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 resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.ALL]) + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.ALL, ALWAYS]); + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } + const repository = new Repository(options.resource); let results: IModel[] = []; @@ -38,9 +45,10 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp /** * When a resourceOwnerSecurity is defined, we need to find all records that are owned by the user */ - if (resourceOwnerSecurity) { + if (resourceOwnerSecurity && authorizationSecurity) { + const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('auth').user()?.getId(); + const userId = CurrentRequest.get(req, 'user')?.getId() if (!userId) { 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 8c407bfb7..66eed7e3f 100644 --- a/src/core/domains/express/actions/resourceShow.ts +++ b/src/core/domains/express/actions/resourceShow.ts @@ -1,14 +1,16 @@ +import User from '@src/app/models/auth/User'; import Repository from '@src/core/base/Repository'; +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; 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 CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; +import { ALWAYS, SecurityIdentifiers } from '@src/core/domains/express/services/Security'; +import SecurityReader from '@src/core/domains/express/services/SecurityReader'; 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'; /** @@ -21,8 +23,13 @@ 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 resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.SHOW]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.SHOW, ALWAYS]); + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } const repository = new Repository(options.resource); let result: IModel | null = null; @@ -30,12 +37,14 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp /** * When a resourceOwnerSecurity is defined, we need to find the record that is owned by the user */ - if(resourceOwnerSecurity) { + if(resourceOwnerSecurity && authorizationSecurity) { + const propertyKey = resourceOwnerSecurity.arguements?.key; - const userId = App.container('auth').user()?.getId(); + const userId = CurrentRequest.get(req, 'user')?.getId(); if(!userId) { - responseError(req, res, new UnauthorizedError()); + responseError(req, res, new ForbiddenResourceError(), 403); + return; } if(typeof propertyKey !== 'string') { diff --git a/src/core/domains/express/actions/resourceUpdate.ts b/src/core/domains/express/actions/resourceUpdate.ts index 2f316a8ca..6b1f9b976 100644 --- a/src/core/domains/express/actions/resourceUpdate.ts +++ b/src/core/domains/express/actions/resourceUpdate.ts @@ -1,10 +1,12 @@ 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 UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import MissingSecurityError from '@src/core/domains/express/exceptions/MissingSecurityError'; 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 SecurityReader from '@src/core/domains/express/services/SecurityReader'; import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; import ModelNotFound from '@src/core/exceptions/ModelNotFound'; import { IModel } from '@src/core/interfaces/IModel'; @@ -20,8 +22,13 @@ 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 resourceOwnerSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.RESOURCE_OWNER, [RouteResourceTypes.UPDATE]); + const authorizationSecurity = SecurityReader.findFromRouteResourceOptions(options, SecurityIdentifiers.AUTHORIZATION, [RouteResourceTypes.UPDATE, ALWAYS]); + + if(authorizationSecurity && !authorizationSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401) + return; + } const repository = new Repository(options.resource); const result = await repository.findById(req.params?.id); @@ -30,7 +37,11 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp throw new ModelNotFound('Resource not found'); } - if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(result)) { + if(resourceOwnerSecurity && !authorizationSecurity) { + responseError(req, res, new MissingSecurityError('Expected authorized security for this route, recieved: ' + typeof authorizationSecurity), 401); + } + + if(resourceOwnerSecurity && !resourceOwnerSecurity.callback(req, result)) { responseError(req, res, new ForbiddenResourceError(), 403) return; } diff --git a/src/core/domains/express/exceptions/MissingSecurityError.ts b/src/core/domains/express/exceptions/MissingSecurityError.ts new file mode 100644 index 000000000..8ae527ff4 --- /dev/null +++ b/src/core/domains/express/exceptions/MissingSecurityError.ts @@ -0,0 +1,8 @@ +export default class MissingSecurityError extends Error { + + constructor(message: string = 'Missing security for this route') { + super(message); + this.name = 'MissingSecurityError'; + } + +} \ No newline at end of file diff --git a/src/core/domains/express/interfaces/IRoute.ts b/src/core/domains/express/interfaces/IRoute.ts index 360880088..e3e41ed99 100644 --- a/src/core/domains/express/interfaces/IRoute.ts +++ b/src/core/domains/express/interfaces/IRoute.ts @@ -1,15 +1,16 @@ -import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security'; import { IRouteAction } from '@src/core/domains/express/interfaces/IRouteAction'; +import { IIdentifiableSecurityCallback } from '@src/core/domains/express/interfaces/ISecurity'; import { ValidatorCtor } from '@src/core/domains/validator/types/ValidatorCtor'; 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; middlewares?: Middleware[]; validator?: ValidatorCtor; validateBeforeAction?: boolean; - security?: IdentifiableSecurityCallback[]; + security?: IIdentifiableSecurityCallback[]; } \ No newline at end of file diff --git a/src/core/domains/express/interfaces/ISecurity.ts b/src/core/domains/express/interfaces/ISecurity.ts new file mode 100644 index 000000000..a9ba1bd6b --- /dev/null +++ b/src/core/domains/express/interfaces/ISecurity.ts @@ -0,0 +1,42 @@ +import { IRoute } from '@src/core/domains/express/interfaces/IRoute'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Request, Response } from 'express'; + +/** + * Authorize Security props + */ +export interface ISecurityAuthorizeProps { + throwExceptionOnUnauthorized?: boolean +} + +/** + * The callback function + */ +// eslint-disable-next-line no-unused-vars +export type SecurityCallback = (req: BaseRequest, ...args: any[]) => boolean; + +/** + * An interface for defining security callbacks with an identifier. + */ +export type IIdentifiableSecurityCallback = { + // 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 condition for when the security check should never be executed. + never: string[] | null; + // The arguments for the security callback. + arguements?: Record; + // The security callback function. + callback: SecurityCallback; +} + +// eslint-disable-next-line no-unused-vars +export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise; + +/** + * Security request to be included in BaseRequest + */ +export default interface ISecurityRequest extends Request { + security?: IIdentifiableSecurityCallback[] +} \ No newline at end of file diff --git a/src/core/domains/express/middleware/authorize.ts b/src/core/domains/express/middleware/authorizeMiddleware.ts similarity index 64% rename from src/core/domains/express/middleware/authorize.ts rename to src/core/domains/express/middleware/authorizeMiddleware.ts index 763739aae..1283abc77 100644 --- a/src/core/domains/express/middleware/authorize.ts +++ b/src/core/domains/express/middleware/authorizeMiddleware.ts @@ -1,7 +1,7 @@ import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +import AuthRequest from '@src/core/domains/auth/services/AuthRequest'; import responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; -import { App } from '@src/core/services/App'; import { NextFunction, Response } from 'express'; /** @@ -16,23 +16,15 @@ import { NextFunction, Response } from 'express'; * @param {NextFunction} next - The next function * @returns {Promise} */ -export const authorize = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { +export const authorizeMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { try { - const authorization = (req.headers.authorization ?? '').replace('Bearer ', ''); - - const apiToken = await App.container('auth').attemptAuthenticateToken(authorization) - - const user = await apiToken?.user() - - if(!user || !apiToken) { - throw new UnauthorizedError(); - } - - req.user = user; - req.apiToken = apiToken - - App.setValue('user', user) + // Authorize the request + // Parses the authorization header + // If successful, attaches the user and apiToken to the request + // and sets the user in the App + await AuthRequest.attemptAuthorizeRequest(req); + next(); } catch (error) { @@ -43,6 +35,7 @@ export const authorize = () => async (req: BaseRequest, res: Response, next: Nex if(error instanceof Error) { responseError(req, res, error) + return; } } }; \ No newline at end of file diff --git a/src/core/domains/express/middleware/basicLoggerMiddleware.ts b/src/core/domains/express/middleware/basicLoggerMiddleware.ts new file mode 100644 index 000000000..7b6372dbe --- /dev/null +++ b/src/core/domains/express/middleware/basicLoggerMiddleware.ts @@ -0,0 +1,9 @@ +import CurrentRequest from '@src/core/domains/express/services/CurrentRequest'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + + +export const basicLoggerMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + console.log('Request', `${req.method} ${req.url}`, 'Headers: ', req.headers, 'CurrentRequest: ', CurrentRequest.get(req)); + next(); +}; diff --git a/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts new file mode 100644 index 000000000..ed4377d98 --- /dev/null +++ b/src/core/domains/express/middleware/endCurrentRequestMiddleware.ts @@ -0,0 +1,17 @@ +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { NextFunction, Response } from "express"; + + +/** + * Middleware that ends the current request context and removes all associated values. + */ +const endCurrentRequestMiddleware = () => (req: BaseRequest, res: Response, next: NextFunction) => { + res.once('finish', () => { + CurrentRequest.end(req) + }) + + next() +} + +export default endCurrentRequestMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/logger.ts b/src/core/domains/express/middleware/logger.ts deleted file mode 100644 index 1620a55c7..000000000 --- a/src/core/domains/express/middleware/logger.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; -import { NextFunction, Response } from 'express'; - -export const logger = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { - console.log('Request', `${req.method} ${req.url}`); - next(); -}; diff --git a/src/core/domains/express/middleware/requestIdMiddleware.ts b/src/core/domains/express/middleware/requestIdMiddleware.ts new file mode 100644 index 000000000..df67c4fd2 --- /dev/null +++ b/src/core/domains/express/middleware/requestIdMiddleware.ts @@ -0,0 +1,40 @@ +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { generateUuidV4 } from "@src/core/util/uuid/generateUuidV4"; +import { NextFunction, Response } from "express"; + +type Props = { + // eslint-disable-next-line no-unused-vars + generator: (...args: any[]) => string; + setHeader: boolean; + headerName: string; +} + +const defaultProps: Props = { + generator: generateUuidV4, + setHeader: true, + headerName: 'X-Request-Id' +} + +/** + * Sets a request id on the request object and sets the response header if desired + * + * @param {Props} props - Options to configure the request id middleware + * @param {string} [props.generator=generateUuidV4] - Function to generate a request id + * @param {boolean} [props.setHeader=true] - If true, sets the response header with the request id + * @param {string} [props.headerName='X-Request-Id'] - Name of the response header to set + * @returns {import("express").RequestHandler} - The middleware function + */ +const requestIdMiddleware = ({ generator, setHeader, headerName }: Props = defaultProps) => (req: BaseRequest, res: Response, next: NextFunction) => { + const oldValue = req.get(headerName) + const id = oldValue ?? generator() + + if(setHeader) { + res.set(headerName, id) + } + + req.id = id + + next() +} + +export default requestIdMiddleware \ No newline at end of file diff --git a/src/core/domains/express/middleware/securityMiddleware.ts b/src/core/domains/express/middleware/securityMiddleware.ts new file mode 100644 index 000000000..61e3bcc7f --- /dev/null +++ b/src/core/domains/express/middleware/securityMiddleware.ts @@ -0,0 +1,108 @@ +import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError'; +import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError'; +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 SecurityReader from '@src/core/domains/express/services/SecurityReader'; +import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; +import { NextFunction, Response } from 'express'; + +const bindSecurityToRequest = (route: IRoute, req: BaseRequest) => { + req.security = route.security ?? []; +} + + +/** + * Applies the authorization security check on the request. + */ +const applyAuthorizeSecurity = async (route: IRoute, req: BaseRequest, res: Response): Promise => { + + const conditions = [ALWAYS] + + if(route.resourceType) { + conditions.push(route.resourceType) + } + + const authorizeSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, conditions); + + if (authorizeSecurity) { + try { + req = await AuthRequest.attemptAuthorizeRequest(req); + + if(!authorizeSecurity.callback(req)) { + responseError(req, res, new UnauthorizedError(), 401); + return; + } + } + catch (err) { + if (err instanceof UnauthorizedError && authorizeSecurity.arguements?.throwExceptionOnUnauthorized) { + throw err; + } + + // Continue processing + } + } +} + +/** + * 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 applyHasRoleSecurity = (req: BaseRequest, res: Response): void | null => { + // Check if the hasRole security has been defined and validate + const securityHasRole = SecurityReader.findFromRequest(req, SecurityIdentifiers.HAS_ROLE); + + if (securityHasRole && !securityHasRole.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. + * + * @param {{ route: IRoute }} - The route object + * @returns {(req: BaseRequest, res: Response, next: NextFunction) => Promise} + */ +export const securityMiddleware: ISecurityMiddleware = ({ route }) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { + try { + + /** + * Adds security rules to the Express Request + */ + bindSecurityToRequest(route, req); + + /** + * Authorizes the user + * Depending on option 'throwExceptionOnUnauthorized', can allow continue processing on failed auth + */ + await applyAuthorizeSecurity(route, req, res) + + /** + * Check if the authorized user passes the has role security + */ + if(applyHasRoleSecurity(req, res) === null) { + return; + } + + /** + * Security is OK, continue + */ + next(); + } + catch (error) { + if (error instanceof UnauthorizedError) { + responseError(req, res, error, 401) + return; + } + + if (error instanceof Error) { + responseError(req, res, error) + return; + } + } +}; \ No newline at end of file diff --git a/src/core/domains/express/routing/RouteResource.ts b/src/core/domains/express/routing/RouteResource.ts index 46c7423aa..d7cfeae45 100644 --- a/src/core/domains/express/routing/RouteResource.ts +++ b/src/core/domains/express/routing/RouteResource.ts @@ -44,6 +44,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Get all resources Route({ name: `${name}.index`, + resourceType: RouteResourceTypes.ALL, method: 'get', path: `/${name}`, action: resourceAction(options, resourceIndex), @@ -53,6 +54,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Get resource by id Route({ name: `${name}.show`, + resourceType: RouteResourceTypes.SHOW, method: 'get', path: `/${name}/:id`, action: resourceAction(options, resourceShow), @@ -62,6 +64,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Update resource by id Route({ name: `${name}.update`, + resourceType: RouteResourceTypes.UPDATE, method: 'put', path: `/${name}/:id`, action: resourceAction(options, resourceUpdate), @@ -72,6 +75,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Delete resource by id Route({ name: `${name}.destroy`, + resourceType: RouteResourceTypes.DESTROY, method: 'delete', path: `/${name}/:id`, action: resourceAction(options, resourceDelete), @@ -81,6 +85,7 @@ const RouteResource = (options: IRouteResourceOptions): IRoute[] => { // Create resource Route({ name: `${name}.create`, + resourceType: RouteResourceTypes.CREATE, method: 'post', path: `/${name}`, action: resourceAction(options, resourceCreate), diff --git a/src/core/domains/express/security/authorizedSecurity.ts b/src/core/domains/express/security/authorizedSecurity.ts new file mode 100644 index 000000000..bdbf0fcaa --- /dev/null +++ b/src/core/domains/express/security/authorizedSecurity.ts @@ -0,0 +1,20 @@ + +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + + +/** + * Checks if the request is authorized, i.e. if the user is logged in. + * + * @param req - The Express Request object + * @returns True if the user is logged in, false otherwise + */ +const authorizedSecurity = (req: BaseRequest): boolean => { + if(CurrentRequest.get(req, 'user')) { + return true; + } + + return false; +} + +export default authorizedSecurity \ No newline at end of file diff --git a/src/core/domains/express/security/hasRoleSecurity.ts b/src/core/domains/express/security/hasRoleSecurity.ts new file mode 100644 index 000000000..34f702327 --- /dev/null +++ b/src/core/domains/express/security/hasRoleSecurity.ts @@ -0,0 +1,22 @@ +import User from "@src/app/models/auth/User"; +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +/** + * Checks if the currently logged in user has the given role(s). + * + * @param {BaseRequest} req - The Express Request object + * @param {string | string[]} roles - The role(s) to check + * @returns {boolean} True if the user has the role, false otherwise + */ +const hasRoleSecurity = (req: BaseRequest, roles: string | string[]): boolean => { + const user = CurrentRequest.get(req, 'user'); + + if(!user) { + return false; + } + + return user?.hasRole(roles) ?? false +} + +export default hasRoleSecurity \ No newline at end of file diff --git a/src/core/domains/express/security/resourceOwnerSecurity.ts b/src/core/domains/express/security/resourceOwnerSecurity.ts new file mode 100644 index 000000000..5e606ca45 --- /dev/null +++ b/src/core/domains/express/security/resourceOwnerSecurity.ts @@ -0,0 +1,28 @@ +import User from "@src/app/models/auth/User"; +import CurrentRequest from "@src/core/domains/express/services/CurrentRequest"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; +import { IModel } from "@src/core/interfaces/IModel"; + +/** + * Checks if the currently logged in user is the owner of the given resource. + * + * @param req - The request object + * @param resource - The resource object + * @param attribute - The attribute name that should contain the user id + * @returns True if the user is the resource owner, false otherwise + */ +const resourceOwnerSecurity = (req: BaseRequest, resource: IModel, attribute: string): boolean => { + const user = CurrentRequest.get(req, 'user'); + + if(!user) { + return false; + } + + if(typeof resource.getAttribute !== 'function') { + throw new Error('Resource is not an instance of IModel'); + } + + return resource.getAttribute(attribute) === user?.getId() +} + +export default resourceOwnerSecurity \ No newline at end of file diff --git a/src/core/domains/express/services/CurrentRequest.ts b/src/core/domains/express/services/CurrentRequest.ts new file mode 100644 index 000000000..1ef0eed3d --- /dev/null +++ b/src/core/domains/express/services/CurrentRequest.ts @@ -0,0 +1,57 @@ +import Singleton from "@src/core/base/Singleton"; +import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; + +class CurrentRequest extends Singleton { + + protected values: Record> = {}; + + /** + * Sets a value in the current request context + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to set + * @param {unknown} value - The value associated with the key + * @returns {typeof CurrentRequest} - The CurrentRequest class itself to enable chaining + */ + public static set(req: BaseRequest, key: string, value: unknown): typeof CurrentRequest { + const requestId = req.id as string; + + if(!this.getInstance().values[requestId]) { + this.getInstance().values[requestId] = {} + } + + this.getInstance().values[requestId][key] = value; + return this; + } + + /** + * Gets a value from the current request context + * + * @param {BaseRequest} req - The Express Request object + * @param {string} key - The key of the value to retrieve + * @returns {T | undefined} - The value associated with the key, or undefined if not found + */ + public static get(req: BaseRequest, key?: string): T | undefined { + const requestId = req.id as string; + + if(!key) { + return this.getInstance().values[requestId] as T ?? undefined; + } + + return this.getInstance().values[requestId]?.[key] as T ?? undefined + } + + /** + * Ends the current request context and removes all associated values + * + * @param {BaseRequest} req - The Express Request object + * @returns {void} + */ + public static end(req: BaseRequest) { + const requestId = req.id as string; + delete this.getInstance().values[requestId]; + } + +} + +export default CurrentRequest \ No newline at end of file diff --git a/src/core/domains/express/services/ExpressService.ts b/src/core/domains/express/services/ExpressService.ts index 42a4c0772..ffe7a0e2a 100644 --- a/src/core/domains/express/services/ExpressService.ts +++ b/src/core/domains/express/services/ExpressService.ts @@ -2,6 +2,9 @@ import Service from '@src/core/base/Service'; import IExpressConfig from '@src/core/domains/express/interfaces/IExpressConfig'; import IExpressService from '@src/core/domains/express/interfaces/IExpressService'; 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 { Middleware } from '@src/core/interfaces/Middleware.t'; import { App } from '@src/core/services/App'; import express from 'express'; @@ -36,6 +39,10 @@ export default class ExpressService extends Service implements I if (!this.config) { throw new Error('Config not provided'); } + + this.app.use(requestIdMiddleware()) + this.app.use(endCurrentRequestMiddleware()) + for (const middleware of this.config?.globalMiddlewares ?? []) { this.app.use(middleware); } @@ -115,8 +122,6 @@ export default class ExpressService extends Service implements I } if(route?.security) { - const securityMiddleware = App.container('auth').securityMiddleware() - middlewares.push( securityMiddleware({ route }) ) diff --git a/src/core/domains/express/services/Security.ts b/src/core/domains/express/services/Security.ts new file mode 100644 index 000000000..f409522f8 --- /dev/null +++ b/src/core/domains/express/services/Security.ts @@ -0,0 +1,183 @@ +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; + +/** + * The default condition for when the security check should be executed. + */ +export const ALWAYS = 'always'; + +/** + * 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[] | null = null; + + /** + * The condition for when the security check should never be executed. + */ + public never: string[] | null = null; + + /** + * 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 | string[]): typeof Security { + condition = typeof condition === 'string' ? [condition] : condition; + this.getInstance().when = condition; + return this; + } + + /** + * Sets the condition for when the security check should never be executed. + * + * @param condition - The condition value(s) to set. If the value is 'always', the security check is never executed. + * @returns The Security class instance for chaining. + */ + public static never(condition: string | string[]): typeof Security { + condition = typeof condition === 'string' ? [condition] : condition; + this.getInstance().never = 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[] | null { + const when = this.getInstance().when; + this.getInstance().when = null; + return when; + } + + /** + * 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; + return never; + } + + /** + * 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'): IIdentifiableSecurityCallback { + return { + id: SecurityIdentifiers.RESOURCE_OWNER, + when: Security.getWhenAndReset(), + never: Security.getNeverAndReset(), + arguements: { key: attribute }, + callback: (req: BaseRequest, resource: IModel) => resourceOwnerSecurity(req, resource, attribute) + } + } + + /** + * Checks if the request is authorized, i.e. if the user is logged in. + * + * 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: + * const authorizationSecurity = SecurityReader.findFromRequest(req, SecurityIdentifiers.AUTHORIZATION, [ALWAYS]); + * + * if(authorizationSecurity && !authorizationSecurity.callback(req)) { + * responseError(req, res, new UnauthorizedError(), 401) + * return; + * } + * + * // Continue processing + * + * @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) + } + } + + /** + * Same as `authorization` but throws an exception if the user is not authenticated. + * This method is useful if you want to handle authentication failure in a centralized way. + * + * @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) + } + } + + /** + * 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[]): IIdentifiableSecurityCallback { + return { + id: SecurityIdentifiers.HAS_ROLE, + when: Security.getWhenAndReset(), + never: Security.getNeverAndReset(), + callback: (req: BaseRequest) => hasRoleSecurity(req, roles) + } + } + + /** + * 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. + */ + // 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) + } + } + } + +} + +export default Security \ No newline at end of file diff --git a/src/core/domains/auth/services/SecurityReader.ts b/src/core/domains/express/services/SecurityReader.ts similarity index 54% rename from src/core/domains/auth/services/SecurityReader.ts rename to src/core/domains/express/services/SecurityReader.ts index f3b5e01cb..6411740f8 100644 --- a/src/core/domains/auth/services/SecurityReader.ts +++ b/src/core/domains/express/services/SecurityReader.ts @@ -1,6 +1,6 @@ - -import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security"; 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 { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t"; class SecurityReader { @@ -13,7 +13,7 @@ class SecurityReader { * @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 { + public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { return this.find(options.security ?? [], id, when); } @@ -25,7 +25,7 @@ class SecurityReader { * @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 { + public static findFromRequest(req: BaseRequest, id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { return this.find(req.security ?? [], id, when); } @@ -38,12 +38,44 @@ class SecurityReader { * @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 => { + public static find(security: IIdentifiableSecurityCallback[], id: string, when?: string[] | null): IIdentifiableSecurityCallback | undefined { + when = when ?? null; + when = when && typeof when === 'string' ? [when] : when; + + // Checks if the condition should never be passable + const conditionNeverPassable = (conditions: string[] | null, never: string[] | null = null) => { + if(!never) return false; + + for(const neverCondition of never) { + if(conditions?.includes(neverCondition)) return true; + } + + return false; + } - const matchesWhenCondition = when !== 'always' && security.when === when; + // Checks if the condition should be passable + const conditionPassable = (condition: string[] | null) => { + if(!condition) { + return true; + } - return security.id === id && matchesWhenCondition; + condition = typeof condition === 'string' ? [condition] : condition; + + if(when?.includes(ALWAYS)) return true; + + for(const conditionString of condition) { + if(when?.includes(conditionString)) { + return true; + } + } + + return false; + } + + return security?.find(security => { + return security.id === id && + conditionNeverPassable(when, security.never) === false && + conditionPassable(security.when); }); } diff --git a/src/core/domains/express/types/BaseRequest.t.ts b/src/core/domains/express/types/BaseRequest.t.ts index 899c21f5e..92b3bf5e6 100644 --- a/src/core/domains/express/types/BaseRequest.t.ts +++ b/src/core/domains/express/types/BaseRequest.t.ts @@ -1,9 +1,10 @@ import IAuthorizedRequest from "@src/core/domains/auth/interfaces/IAuthorizedRequest"; -import ISecurityRequest from "@src/core/domains/auth/interfaces/ISecurityRequest"; +import IRequestIdentifiable from "@src/core/domains/auth/interfaces/IRequestIdentifiable"; +import ISecurityRequest from "@src/core/domains/express/interfaces/ISecurity"; 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 & ISecurityRequest; \ No newline at end of file +export type BaseRequest = Request & IAuthorizedRequest & IValidatorRequest & ISecurityRequest & IRequestIdentifiable; \ No newline at end of file diff --git a/src/core/domains/make/templates/Action.ts.template b/src/core/domains/make/templates/Action.ts.template index 39004e93d..aa39e09cb 100644 --- a/src/core/domains/make/templates/Action.ts.template +++ b/src/core/domains/make/templates/Action.ts.template @@ -16,6 +16,7 @@ export const #name# = (req: BaseRequest, res: Response) => { catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } } \ No newline at end of file diff --git a/src/core/domains/make/templates/Middleware.ts.template b/src/core/domains/make/templates/Middleware.ts.template index 6141a8bb5..d516affbc 100644 --- a/src/core/domains/make/templates/Middleware.ts.template +++ b/src/core/domains/make/templates/Middleware.ts.template @@ -23,6 +23,7 @@ export const #name# = () => async (req: BaseRequest, res: Response, next: NextFu catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } }; \ No newline at end of file diff --git a/src/core/domains/make/templates/Model.ts.template b/src/core/domains/make/templates/Model.ts.template index c80008b73..40c652a29 100644 --- a/src/core/domains/make/templates/Model.ts.template +++ b/src/core/domains/make/templates/Model.ts.template @@ -9,7 +9,7 @@ import IModelData from '@src/core/interfaces/IModelData'; * bar: number; * baz: boolean; */ -interface I#name#Data extends IModelData { +export interface I#name#Data extends IModelData { } diff --git a/src/core/domains/validator/middleware/validateMiddleware.ts b/src/core/domains/validator/middleware/validateMiddleware.ts index baed03299..659505c1a 100644 --- a/src/core/domains/validator/middleware/validateMiddleware.ts +++ b/src/core/domains/validator/middleware/validateMiddleware.ts @@ -40,6 +40,7 @@ export const validateMiddleware = ({validator, validateBeforeAction}: ValidatorM catch (error) { if(error instanceof Error) { responseError(req, res, error) + return; } } }; \ No newline at end of file