From 6e6eaef6bb9c7ac735d58b4a3c8c06ff93950401 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Tue, 24 Sep 2024 23:07:29 +0100 Subject: [PATCH] Added scopes to authentication domain, updated authorizeMiddleware to allow for scope checking --- .../2024-09-06-create-api-token-table.ts | 3 +- src/app/models/auth/ApiToken.ts | 21 +++++++++++++ .../domains/auth/factory/apiTokenFactory.ts | 3 +- .../domains/auth/interfaces/IApitokenModel.ts | 5 ++- .../domains/auth/interfaces/IAuthService.ts | 6 ++-- src/core/domains/auth/services/AuthService.ts | 12 +++---- .../express/middleware/authorizeMiddleware.ts | 31 ++++++++++++++++++- 7 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/app/migrations/2024-09-06-create-api-token-table.ts b/src/app/migrations/2024-09-06-create-api-token-table.ts index c59b82dab..e5a7f546c 100644 --- a/src/app/migrations/2024-09-06-create-api-token-table.ts +++ b/src/app/migrations/2024-09-06-create-api-token-table.ts @@ -1,6 +1,6 @@ +import ApiToken from "@src/app/models/auth/ApiToken"; import BaseMigration from "@src/core/domains/migrations/base/BaseMigration"; import { DataTypes } from "sequelize"; -import ApiToken from "@src/app/models/auth/ApiToken"; export class CreateApiTokenMigration extends BaseMigration { @@ -17,6 +17,7 @@ export class CreateApiTokenMigration extends BaseMigration { await this.schema.createTable(this.table, { userId: DataTypes.STRING, token: DataTypes.STRING, + scopes: DataTypes.JSON, revokedAt: DataTypes.DATE }) } diff --git a/src/app/models/auth/ApiToken.ts b/src/app/models/auth/ApiToken.ts index 85d3d6076..708f65804 100644 --- a/src/app/models/auth/ApiToken.ts +++ b/src/app/models/auth/ApiToken.ts @@ -20,9 +20,14 @@ class ApiToken extends Model implements IApiTokenModel { public fields: string[] = [ 'userId', 'token', + 'scopes', 'revokedAt' ] + public json: string[] = [ + 'scopes' + ] + /** * Disable createdAt and updatedAt timestamps */ @@ -39,6 +44,22 @@ class ApiToken extends Model implements IApiTokenModel { }) } + /** + * Checks if the given scope(s) are present in the scopes of this ApiToken + * @param scopes The scope(s) to check + * @returns True if all scopes are present, false otherwise + */ + public hasScope(scopes: string | string[]): boolean { + const currentScopes = this.getAttribute('scopes') ?? []; + scopes = typeof scopes === 'string' ? [scopes] : scopes; + + for(const scope of scopes) { + if(!currentScopes.includes(scope)) return false; + } + + return true; + } + } export default ApiToken diff --git a/src/core/domains/auth/factory/apiTokenFactory.ts b/src/core/domains/auth/factory/apiTokenFactory.ts index 62c67130f..5f86e45a9 100644 --- a/src/core/domains/auth/factory/apiTokenFactory.ts +++ b/src/core/domains/auth/factory/apiTokenFactory.ts @@ -22,10 +22,11 @@ class ApiTokenFactory extends Factory { * @param {IUserModel} user * @returns {IApiTokenModel} */ - createFromUser(user: IUserModel): IApiTokenModel { + createFromUser(user: IUserModel, scopes: string[] = []): IApiTokenModel { return new this.modelCtor({ userId: user.data?.id, token: tokenFactory(), + scopes: scopes, revokedAt: null, }) } diff --git a/src/core/domains/auth/interfaces/IApitokenModel.ts b/src/core/domains/auth/interfaces/IApitokenModel.ts index 35acb054f..225e87371 100644 --- a/src/core/domains/auth/interfaces/IApitokenModel.ts +++ b/src/core/domains/auth/interfaces/IApitokenModel.ts @@ -1,12 +1,15 @@ +/* eslint-disable no-unused-vars */ import { IModel } from "@src/core/interfaces/IModel"; import IModelData from "@src/core/interfaces/IModelData"; export interface IApiTokenData extends IModelData { userId: string; - token: string + token: string; + scopes: string[]; revokedAt: Date | null; } export default interface IApiTokenModel extends IModel { user(): Promise; + hasScope(scopes: string | string[]): boolean; } \ No newline at end of file diff --git a/src/core/domains/auth/interfaces/IAuthService.ts b/src/core/domains/auth/interfaces/IAuthService.ts index 694d4392f..bbce1c2bd 100644 --- a/src/core/domains/auth/interfaces/IAuthService.ts +++ b/src/core/domains/auth/interfaces/IAuthService.ts @@ -56,7 +56,7 @@ export interface IAuthService extends IService { * @returns {Promise} The JWT token * @memberof IAuthService */ - createJwtFromUser: (user: IUserModel) => Promise; + createJwtFromUser: (user: IUserModel, scopes?: string[]) => Promise; /** * Creates a new ApiToken model from the User @@ -65,7 +65,7 @@ export interface IAuthService extends IService { * @returns {Promise} The new ApiToken model * @memberof IAuthService */ - createApiTokenFromUser: (user: IUserModel) => Promise; + createApiTokenFromUser: (user: IUserModel, scopes?: string[]) => Promise; /** * Revokes a token. @@ -84,7 +84,7 @@ export interface IAuthService extends IService { * @returns {Promise} The JWT token * @memberof IAuthService */ - attemptCredentials: (email: string, password: string) => Promise; + attemptCredentials: (email: string, password: string, scopes?: string[]) => Promise; /** * Generates a JWT. diff --git a/src/core/domains/auth/services/AuthService.ts b/src/core/domains/auth/services/AuthService.ts index a77d9b814..b44b4c7f2 100644 --- a/src/core/domains/auth/services/AuthService.ts +++ b/src/core/domains/auth/services/AuthService.ts @@ -58,8 +58,8 @@ export default class AuthService extends Service implements IAuthSe * @param user * @returns */ - public async createApiTokenFromUser(user: IUserModel): Promise { - const apiToken = new ApiTokenFactory().createFromUser(user) + public async createApiTokenFromUser(user: IUserModel, scopes: string[] = []): Promise { + const apiToken = new ApiTokenFactory().createFromUser(user, scopes) await apiToken.save(); return apiToken } @@ -69,8 +69,8 @@ export default class AuthService extends Service implements IAuthSe * @param user * @returns */ - async createJwtFromUser(user: IUserModel): Promise { - const apiToken = await this.createApiTokenFromUser(user); + async createJwtFromUser(user: IUserModel, scopes: string[] = []): Promise { + const apiToken = await this.createApiTokenFromUser(user, scopes); return this.jwt(apiToken) } @@ -139,7 +139,7 @@ export default class AuthService extends Service implements IAuthSe * @param password * @returns */ - async attemptCredentials(email: string, password: string): Promise { + async attemptCredentials(email: string, password: string, scopes: string[] = []): Promise { const user = await this.userRepository.findOneByEmail(email) as IUserModel; if (!user?.data?.id) { @@ -150,7 +150,7 @@ export default class AuthService extends Service implements IAuthSe throw new UnauthorizedError() } - return this.createJwtFromUser(user) + return this.createJwtFromUser(user, scopes) } /** diff --git a/src/core/domains/express/middleware/authorizeMiddleware.ts b/src/core/domains/express/middleware/authorizeMiddleware.ts index 1283abc77..804c2fb20 100644 --- a/src/core/domains/express/middleware/authorizeMiddleware.ts +++ b/src/core/domains/express/middleware/authorizeMiddleware.ts @@ -1,9 +1,35 @@ +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 responseError from '@src/core/domains/express/requests/responseError'; import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t'; import { NextFunction, Response } from 'express'; +/** + * Validates that the scopes in the api token match the required scopes for the request. + * If the scopes do not match, it will throw a ForbiddenResourceError. + * If no api token is found, it will throw a UnauthorizedError. + * @param scopes The scopes required for the request + * @param req The request object + * @param res The response object + */ +const validateScopes = async (scopes: string[], req: BaseRequest, res: Response) => { + if(scopes.length === 0) { + return; + } + + const apiToken = req.apiToken; + + if(!apiToken) { + responseError(req, res, new UnauthorizedError(), 401); + return; + } + + if(!apiToken.hasScope(scopes)) { + responseError(req, res, new ForbiddenResourceError('Required scopes missing from authorization'), 403); + } +} + /** * Authorize middleware * @@ -16,7 +42,7 @@ import { NextFunction, Response } from 'express'; * @param {NextFunction} next - The next function * @returns {Promise} */ -export const authorizeMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { +export const authorizeMiddleware = (scopes: string[] = []) => async (req: BaseRequest, res: Response, next: NextFunction): Promise => { try { // Authorize the request @@ -25,6 +51,9 @@ export const authorizeMiddleware = () => async (req: BaseRequest, res: Response, // and sets the user in the App await AuthRequest.attemptAuthorizeRequest(req); + // Validate the scopes if the authorization was successful + validateScopes(scopes, req, res); + next(); } catch (error) {