Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/app/models/auth/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ export default class User extends Model<IUserData> implements IUserModel {
'roles'
]

/**
* Checks if the user has the given role
*
* @param role The role to check
* @returns True if the user has the role, false otherwise
*/
hasRole(roles: string | string[]): boolean {
roles = typeof roles === 'string' ? [roles] : roles;
const userRoles = this.getAttribute('roles') ?? [];

for(const role of roles) {
if(userRoles.includes(role)) {
return true;
}
}

return false
}

/**
* @returns The tokens associated with this user
*
Expand Down
3 changes: 1 addition & 2 deletions src/core/domains/auth/commands/GenerateJWTSecret.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import BaseCommand from "@src/core/domains/console/base/BaseCommand";
import { IEnvService } from "@src/core/interfaces/IEnvService";
import EnvService from "@src/core/services/EnvService";

import BaseCommand from "../../console/base/BaseCommand";

class GenerateJwtSecret extends BaseCommand {

signature = 'auth:generate-jwt-secret';
Expand Down
8 changes: 8 additions & 0 deletions src/core/domains/auth/exceptions/ForbiddenResourceError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default class ForbiddenResourceError extends Error {

constructor(message: string = 'You do not have permission to access this resource') {
super(message);
this.name = 'ForbiddenResourceError';
}

}
16 changes: 16 additions & 0 deletions src/core/domains/auth/interfaces/IAuthService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable no-unused-vars */
import User from "@src/app/models/auth/User";
import IApiTokenModel from "@src/core/domains/auth/interfaces/IApitokenModel";
import IApiTokenRepository from "@src/core/domains/auth/interfaces/IApiTokenRepository";
import IUserModel from "@src/core/domains/auth/interfaces/IUserModel";
import IUserRepository from "@src/core/domains/auth/interfaces/IUserRepository";
import { ISecurityMiddleware } from "@src/core/domains/auth/middleware/securityMiddleware";
import { IRoute } from "@src/core/domains/express/interfaces/IRoute";
import IService from "@src/core/interfaces/IService";

Expand Down Expand Up @@ -102,4 +104,18 @@ export interface IAuthService extends IService {
* @memberof IAuthService
*/
getAuthRoutes(): IRoute[] | null;

/**
* Returns the authenticated user.
*
* @returns {User | null}
*/
user(): User | null;

/**
* Returns the security middleware
*
* @returns {ISecurityMiddleware}
*/
securityMiddleware(): ISecurityMiddleware;
}
7 changes: 7 additions & 0 deletions src/core/domains/auth/interfaces/ISecurityRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Request } from 'express';

import { IdentifiableSecurityCallback } from '@src/core/domains/auth/services/Security';

export default interface ISecurityRequest extends Request {
security?: IdentifiableSecurityCallback[]
}
1 change: 1 addition & 0 deletions src/core/domains/auth/interfaces/IUserModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export interface IUserData extends IModelData {

export default interface IUserModel extends IModel<IUserData> {
tokens(...args: any[]): Promise<any>;
hasRole(...args: any[]): any;
}
46 changes: 46 additions & 0 deletions src/core/domains/auth/middleware/securityMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError';
import responseError from '@src/core/domains/express/requests/responseError';
import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t';
import { NextFunction, Response } from 'express';

import { IRoute } from '@src/core/domains/express/interfaces/IRoute';
import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError';
import { SecurityIdentifiers } from '@src/core/domains/auth/services/Security';

// eslint-disable-next-line no-unused-vars
export type ISecurityMiddleware = ({ route }: { route: IRoute }) => (req: BaseRequest, res: Response, next: NextFunction) => Promise<void>;

/**
* 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<void>}
*/
export const securityMiddleware = ({ route }) => async (req: BaseRequest, res: Response, next: NextFunction): Promise<void> => {
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)
}
}
};
3 changes: 1 addition & 2 deletions src/core/domains/auth/providers/AuthProvider.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import authConfig from "@src/config/auth";
import BaseProvider from "@src/core/base/Provider";
import GenerateJwtSecret from "@src/core/domains/auth/commands/GenerateJWTSecret";
import { IAuthConfig } from "@src/core/domains/auth/interfaces/IAuthConfig";
import { App } from "@src/core/services/App";

import GenerateJwtSecret from "../commands/GenerateJWTSecret";

export default class AuthProvider extends BaseProvider {

/**
Expand Down
21 changes: 21 additions & 0 deletions src/core/domains/auth/services/AuthService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import User from '@src/app/models/auth/User';
import Service from '@src/core/base/Service';
import InvalidJWTSecret from '@src/core/domains/auth/exceptions/InvalidJWTSecret';
import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError';
Expand All @@ -10,11 +11,13 @@ import { IAuthService } from '@src/core/domains/auth/interfaces/IAuthService';
import { IJSonWebToken } from '@src/core/domains/auth/interfaces/IJSonWebToken';
import IUserModel from '@src/core/domains/auth/interfaces/IUserModel';
import IUserRepository from '@src/core/domains/auth/interfaces/IUserRepository';
import { securityMiddleware } from '@src/core/domains/auth/middleware/securityMiddleware';
import authRoutes from '@src/core/domains/auth/routes/auth';
import comparePassword from '@src/core/domains/auth/utils/comparePassword';
import createJwt from '@src/core/domains/auth/utils/createJwt';
import decodeJwt from '@src/core/domains/auth/utils/decodeJwt';
import { IRoute } from '@src/core/domains/express/interfaces/IRoute';
import { App } from '@src/core/services/App';

export default class AuthService extends Service<IAuthConfig> implements IAuthService {

Expand Down Expand Up @@ -163,4 +166,22 @@ export default class AuthService extends Service<IAuthConfig> implements IAuthSe
return routes;
}

/**
* Returns the currently authenticated user from the request context.
* @returns The user model if the user is authenticated, or null if not.
*/
user(): User | null {
return App.getValue<User>('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;
}

}
119 changes: 119 additions & 0 deletions src/core/domains/auth/services/Security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import Singleton from "@src/core/base/Singleton";
import { IModel } from "@src/core/interfaces/IModel";
import { App } from "@src/core/services/App";

// eslint-disable-next-line no-unused-vars
export type SecurityCallback = (...args: any[]) => boolean;

/**
* An interface for defining security callbacks with an identifier.
*/
export type IdentifiableSecurityCallback = {
// The identifier for the security callback.
id: string;
// The condition for when the security check should be executed. Defaults to 'always'.
when: string | null;
// The arguments for the security callback.
arguements?: Record<string, unknown>;
// 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
52 changes: 52 additions & 0 deletions src/core/domains/auth/services/SecurityReader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@

import { IdentifiableSecurityCallback } from "@src/core/domains/auth/services/Security";
import { IRouteResourceOptions } from "@src/core/domains/express/interfaces/IRouteResourceOptions";
import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t";

class SecurityReader {

/**
* Finds a security callback in the security callbacks of the given route resource options.
*
* @param options - The route resource options containing the security callbacks.
* @param id - The id of the security callback to find.
* @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition.
* @returns The found security callback, or undefined if not found.
*/
public static findFromRouteResourceOptions(options: IRouteResourceOptions, id: string, when?: string): IdentifiableSecurityCallback | undefined {
return this.find(options.security ?? [], id, when);
}

/**
* Finds a security callback from the security callbacks associated with the given request.
*
* @param req - The request object containing the security callbacks.
* @param id - The id of the security callback to find.
* @param when - The optional when condition. If specified, the security callback will only be found if it matches this condition.
* @returns The found security callback, or undefined if not found.
*/
public static findFromRequest(req: BaseRequest, id: string, when?: string): IdentifiableSecurityCallback | undefined {
return this.find(req.security ?? [], id, when);
}

/**
* Finds a security callback in the given array of security callbacks.
*
* @param security - The array of security callbacks to search.
* @param options - The route resource options containing the security callbacks.
* @param id - The id of the security callback to find.
* @param when - The when condition to match. If not provided, the method will return the first match.
* @returns The security callback if found, or undefined if not found.
*/
public static find(security: IdentifiableSecurityCallback[], id: string, when?: string): IdentifiableSecurityCallback | undefined {
return security?.find(security => {

const matchesWhenCondition = when !== 'always' && security.when === when;

return security.id === id && matchesWhenCondition;
});
}

}

export default SecurityReader
2 changes: 2 additions & 0 deletions src/core/domains/express/actions/resourceCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import responseError from '@src/core/domains/express/requests/responseError';
import { BaseRequest } from "@src/core/domains/express/types/BaseRequest.t";
import { Response } from 'express';


/**
* Creates a new instance of the model
*
Expand All @@ -21,6 +22,7 @@ export default async (req: BaseRequest, res: Response, options: IRouteResourceOp
catch (err) {
if (err instanceof Error) {
responseError(req, res, err)
return;
}

res.status(500).send({ error: 'Something went wrong' })
Expand Down
Loading