From 3fdf2144ab235b4a4cb93ced5ba24d943c95e1f9 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Thu, 6 Nov 2025 17:48:46 +0100 Subject: [PATCH] backend: streamline authentication validators and improve error handling --- .../src/middlewares/auth.middleware.ts | 313 ++++++++++-------- meet-ce/backend/src/routes/user.routes.ts | 8 +- 2 files changed, 177 insertions(+), 144 deletions(-) diff --git a/meet-ce/backend/src/middlewares/auth.middleware.ts b/meet-ce/backend/src/middlewares/auth.middleware.ts index b14e8d04..64d16087 100644 --- a/meet-ce/backend/src/middlewares/auth.middleware.ts +++ b/meet-ce/backend/src/middlewares/auth.middleware.ts @@ -14,7 +14,6 @@ import { errorInvalidToken, errorInvalidTokenSubject, errorUnauthorized, - internalError, OpenViduMeetError, rejectRequestFromMeetError } from '../models/index.js'; @@ -28,137 +27,183 @@ import { } from '../services/index.js'; import { getAccessToken, getParticipantToken, getRecordingToken } from '../utils/index.js'; +/** + * Interface for authentication validators. + * Each validator must implement methods to check if credentials are present and to validate them. + */ +interface AuthValidator { + /** + * Checks if the authentication credentials for this validator are present in the request. + * This allows the middleware to skip validation for methods that are not being used. + */ + isPresent(req: Request): Promise; + + /** + * Validates the authentication credentials and sets the session. + */ + validate(req: Request): Promise; +} + /** * This middleware allows to chain multiple validators to check if the request is authorized. - * If any of the validators grants access, the request is allowed to continue, skipping the rest of the validators. - * If none of the validators grants access, the request is rejected with an unauthorized error. + * First checks which authentication methods are present in the request, then validates only those methods. + * If any of the validators grants access, the request is allowed to continue. + * If none of the validators grants access, the request is rejected with the most recent error. * * @param validators List of validators to check if the request is authorized * @returns RequestHandler middleware */ -export const withAuth = (...validators: ((req: Request) => Promise)[]): RequestHandler => { +export const withAuth = (...validators: AuthValidator[]): RequestHandler => { return async (req: Request, res: Response, next: NextFunction) => { let lastError: OpenViduMeetError | null = null; for (const validator of validators) { try { - await validator(req); - // If any middleware granted access, it is not necessary to continue checking the rest - return next(); + if (await validator.isPresent(req)) { + await validator.validate(req); + // If any validator grants access, allow the request to continue + return next(); + } } catch (error) { - if (isErrorWithControl(error)) { - lastError = error.error; - - if (error.stopValidation) { - // Stop checking other validators - break; - } + if (error instanceof OpenViduMeetError) { + lastError = error; } } } - if (lastError) { - return rejectRequestFromMeetError(res, lastError); - } - - const error = internalError('authenticating user'); - return rejectRequestFromMeetError(res, error); + lastError = lastError || errorUnauthorized(); + return rejectRequestFromMeetError(res, lastError); }; }; -// Configure token validatior for role-based access -export const tokenAndRoleValidator = (role: UserRole) => { - return async (req: Request) => { - const token = await getAccessToken(req); +/** + * Token and role validator for role-based access. + * Validates JWT tokens and checks if the user has at least one of the required roles. + * + * @param roles One or more roles that are allowed to access the resource + */ +export const tokenAndRoleValidator = (...roles: UserRole[]): AuthValidator => { + return { + async isPresent(req: Request): Promise { + const token = await getAccessToken(req); + return !!token; + }, - if (!token) { - throw errorWithControl(errorUnauthorized(), false); + async validate(req: Request): Promise { + const token = await getAccessToken(req); + + if (!token) { + throw errorUnauthorized(); + } + + const tokenService = container.get(TokenService); + let payload: ClaimGrants; + + try { + payload = await tokenService.verifyToken(token); + } catch (error) { + throw errorInvalidToken(); + } + + const username = payload.sub; + const userService = container.get(UserService); + const user = username ? await userService.getUser(username) : null; + + if (!user) { + throw errorInvalidTokenSubject(); + } + + // Check if user has at least one of the required roles + const hasRequiredRole = roles.some((role) => user.roles.includes(role)); + + if (!hasRequiredRole) { + throw errorInsufficientPermissions(); + } + + req.session = req.session || {}; + req.session.user = user; + } + }; +}; + +/** + * Participant token validator for room access. + * Validates participant tokens and checks role permissions. + */ +export const participantTokenValidator: AuthValidator = { + async isPresent(req: Request): Promise { + const token = await getParticipantToken(req); + return !!token; + }, + + async validate(req: Request): Promise { + const token = await getParticipantToken(req); + await validateTokenAndSetSession(req, token); + + // Check if the participant role is provided in the request headers + // This is required to distinguish roles when multiple are present in the token + const participantRole = req.headers[INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER]; + const allRoles = [ParticipantRole.MODERATOR, ParticipantRole.SPEAKER]; + + if (!participantRole || !allRoles.includes(participantRole as ParticipantRole)) { + throw errorInvalidParticipantRole(); } - const tokenService = container.get(TokenService); - let payload: ClaimGrants; + // Check that the specified role is present in the token claims + let metadata: MeetTokenMetadata; try { - payload = await tokenService.verifyToken(token); + const participantService = container.get(ParticipantService); + metadata = participantService.parseMetadata(req.session?.tokenClaims?.metadata || '{}'); } catch (error) { - throw errorWithControl(errorInvalidToken(), true); + const logger = container.get(LoggerService); + logger.error('Invalid participant token:', error); + throw errorInvalidParticipantToken(); } - const username = payload.sub; - const userService = container.get(UserService); - const user = username ? await userService.getUser(username) : null; + const roles = metadata.roles; + const hasRole = roles.some( + (r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole + ); - if (!user) { - throw errorWithControl(errorInvalidTokenSubject(), true); + if (!hasRole) { + throw errorInsufficientPermissions(); } - if (!user.roles.includes(role)) { - throw errorWithControl(errorInsufficientPermissions(), false); - } - - req.session = req.session || {}; - req.session.user = user; - }; + // Set the participant role in the session + req.session!.participantRole = participantRole as ParticipantRole; + } }; -// Configure token validator for participant access -export const participantTokenValidator = async (req: Request) => { - const token = await getParticipantToken(req); - await validateTokenAndSetSession(req, token); +/** + * Recording token validator for recording access. + * Validates recording tokens with specific metadata. + */ +export const recordingTokenValidator: AuthValidator = { + async isPresent(req: Request): Promise { + const token = await getRecordingToken(req); + return !!token; + }, - // Check if the participant role is provided in the request headers - // This is required to distinguish roles when multiple are present in the token - const participantRole = req.headers[INTERNAL_CONFIG.PARTICIPANT_ROLE_HEADER]; - const allRoles = [ParticipantRole.MODERATOR, ParticipantRole.SPEAKER]; + async validate(req: Request): Promise { + const token = await getRecordingToken(req); + await validateTokenAndSetSession(req, token); - if (!participantRole || !allRoles.includes(participantRole as ParticipantRole)) { - throw errorWithControl(errorInvalidParticipantRole(), true); - } - - // Check that the specified role is present in the token claims - let metadata: MeetTokenMetadata; - - try { - const participantService = container.get(ParticipantService); - metadata = participantService.parseMetadata(req.session?.tokenClaims?.metadata || '{}'); - } catch (error) { - const logger = container.get(LoggerService); - logger.error('Invalid participant token:', error); - throw errorWithControl(errorInvalidParticipantToken(), true); - } - - const roles = metadata.roles; - const hasRole = roles.some( - (r: { role: ParticipantRole; permissions: OpenViduMeetPermissions }) => r.role === participantRole - ); - - if (!hasRole) { - throw errorWithControl(errorInsufficientPermissions(), true); - } - - // Set the participant role in the session - req.session!.participantRole = participantRole as ParticipantRole; -}; - -// Configure token validator for recording access -export const recordingTokenValidator = async (req: Request) => { - const token = await getRecordingToken(req); - await validateTokenAndSetSession(req, token); - - // Validate the recording token metadata - try { - const roomService = container.get(RoomService); - roomService.parseRecordingTokenMetadata(req.session?.tokenClaims?.metadata || '{}'); - } catch (error) { - const logger = container.get(LoggerService); - logger.error('Invalid recording token:', error); - throw errorWithControl(errorInvalidRecordingToken(), true); + // Validate the recording token metadata + try { + const roomService = container.get(RoomService); + roomService.parseRecordingTokenMetadata(req.session?.tokenClaims?.metadata || '{}'); + } catch (error) { + const logger = container.get(LoggerService); + logger.error('Invalid recording token:', error); + throw errorInvalidRecordingToken(); + } } }; const validateTokenAndSetSession = async (req: Request, token: string | undefined) => { if (!token) { - throw errorWithControl(errorUnauthorized(), false); + throw errorUnauthorized(); } const tokenService = container.get(TokenService); @@ -172,48 +217,58 @@ const validateTokenAndSetSession = async (req: Request, token: string | undefine req.session.tokenClaims = payload; req.session.user = user; } catch (error) { - throw errorWithControl(errorInvalidToken(), true); + throw errorInvalidToken(); } }; -// Configure API key validatior -export const apiKeyValidator = async (req: Request) => { - const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER]; +/** + * API key validator for service-to-service authentication. + * Validates API keys from request headers. + */ +export const apiKeyValidator: AuthValidator = { + async isPresent(req: Request): Promise { + const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER]; + return !!apiKey; + }, - if (!apiKey) { - throw errorWithControl(errorUnauthorized(), false); - } + async validate(req: Request): Promise { + const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER]; + + if (!apiKey) { + throw errorUnauthorized(); + } - try { const apiKeyService = container.get(ApiKeyService); const isValidApiKey = await apiKeyService.validateApiKey(apiKey as string); if (!isValidApiKey) { throw errorInvalidApiKey(); } - } catch (error) { - if (error instanceof OpenViduMeetError) { - throw errorWithControl(error, true); - } else { - const logger = container.get(LoggerService); - logger.error('Error validating API key:', error); - throw errorWithControl(internalError('validating API key'), true); - } + + const userService = container.get(UserService); + const apiUser = userService.getApiUser(); + + req.session = req.session || {}; + req.session.user = apiUser; } - - const userService = container.get(UserService); - const apiUser = userService.getApiUser(); - - req.session = req.session || {}; - req.session.user = apiUser; }; -// Allow anonymous access -export const allowAnonymous = async (req: Request) => { - const user = await getAuthenticatedUserOrAnonymous(req); +/** + * Anonymous access validator. + * Always present and allows unauthenticated access with an anonymous user. + */ +export const allowAnonymous: AuthValidator = { + async isPresent(): Promise { + // Anonymous access is always available + return true; + }, - req.session = req.session || {}; - req.session.user = user; + async validate(req: Request): Promise { + const user = await getAuthenticatedUserOrAnonymous(req); + + req.session = req.session || {}; + req.session.user = user; + } }; // Return the authenticated user if available, otherwise return an anonymous user @@ -259,21 +314,3 @@ export const withLoginLimiter = (req: Request, res: Response, next: NextFunction return loginLimiter(req, res, next); }; - -// OpenViduMeetError with control to stop checking other validators -interface ErrorWithControl { - error: OpenViduMeetError; - stopValidation: boolean; -} - -const errorWithControl = (error: OpenViduMeetError, stopValidation: boolean): ErrorWithControl => { - const errorWithControl: ErrorWithControl = { - error, - stopValidation - }; - return errorWithControl; -}; - -const isErrorWithControl = (error: unknown): error is ErrorWithControl => { - return typeof error === 'object' && error !== null && 'error' in error && 'stopValidation' in error; -}; diff --git a/meet-ce/backend/src/routes/user.routes.ts b/meet-ce/backend/src/routes/user.routes.ts index c96ac6e2..a3e86e3d 100644 --- a/meet-ce/backend/src/routes/user.routes.ts +++ b/meet-ce/backend/src/routes/user.routes.ts @@ -9,14 +9,10 @@ userRouter.use(bodyParser.urlencoded({ extended: true })); userRouter.use(bodyParser.json()); // Users Routes -userRouter.get( - '/profile', - withAuth(tokenAndRoleValidator(UserRole.ADMIN), tokenAndRoleValidator(UserRole.USER)), - userCtrl.getProfile -); +userRouter.get('/profile', withAuth(tokenAndRoleValidator(UserRole.ADMIN, UserRole.USER)), userCtrl.getProfile); userRouter.post( '/change-password', - withAuth(tokenAndRoleValidator(UserRole.ADMIN), tokenAndRoleValidator(UserRole.USER)), + withAuth(tokenAndRoleValidator(UserRole.ADMIN, UserRole.USER)), validateChangePasswordRequest, userCtrl.changePassword );