openvidu/backend/src/middlewares/auth.middleware.ts

279 lines
8.3 KiB
TypeScript

import { MeetTokenMetadata, OpenViduMeetPermissions, ParticipantRole, User, UserRole } from '@typings-ce';
import { NextFunction, Request, RequestHandler, Response } from 'express';
import rateLimit from 'express-rate-limit';
import { ClaimGrants } from 'livekit-server-sdk';
import ms from 'ms';
import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js';
import {
errorInsufficientPermissions,
errorInvalidApiKey,
errorInvalidParticipantRole,
errorInvalidParticipantToken,
errorInvalidRecordingToken,
errorInvalidToken,
errorInvalidTokenSubject,
errorUnauthorized,
internalError,
OpenViduMeetError,
rejectRequestFromMeetError
} from '../models/index.js';
import {
AuthService,
LoggerService,
ParticipantService,
RoomService,
TokenService,
UserService
} from '../services/index.js';
/**
* 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.
*
* @param validators List of validators to check if the request is authorized
* @returns RequestHandler middleware
*/
export const withAuth = (...validators: ((req: Request) => Promise<void>)[]): 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();
} catch (error) {
if (isErrorWithControl(error)) {
lastError = error.error;
if (error.stopValidation) {
// Stop checking other validators
break;
}
}
}
}
if (lastError) {
return rejectRequestFromMeetError(res, lastError);
}
const error = internalError('authenticating user');
return rejectRequestFromMeetError(res, error);
};
};
// Configure token validatior for role-based access
export const tokenAndRoleValidator = (role: UserRole) => {
return async (req: Request) => {
const token = req.cookies[INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME];
if (!token) {
throw errorWithControl(errorUnauthorized(), false);
}
const tokenService = container.get(TokenService);
let payload: ClaimGrants;
try {
payload = await tokenService.verifyToken(token);
} catch (error) {
throw errorWithControl(errorInvalidToken(), true);
}
const username = payload.sub;
const userService = container.get(UserService);
const user = username ? await userService.getUser(username) : null;
if (!user) {
throw errorWithControl(errorInvalidTokenSubject(), true);
}
if (!user.roles.includes(role)) {
throw errorWithControl(errorInsufficientPermissions(), false);
}
req.session = req.session || {};
req.session.user = user;
};
};
// Configure token validator for participant access
export const participantTokenValidator = async (req: Request) => {
await validateTokenAndSetSession(req, INTERNAL_CONFIG.PARTICIPANT_TOKEN_COOKIE_NAME);
// 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 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) => {
await validateTokenAndSetSession(req, INTERNAL_CONFIG.RECORDING_TOKEN_COOKIE_NAME);
// 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);
}
};
const validateTokenAndSetSession = async (req: Request, cookieName: string) => {
const token = req.cookies[cookieName];
if (!token) {
throw errorWithControl(errorUnauthorized(), false);
}
const tokenService = container.get(TokenService);
let payload: ClaimGrants;
try {
payload = await tokenService.verifyToken(token);
const user = await getAuthenticatedUserOrAnonymous(req);
req.session = req.session || {};
req.session.tokenClaims = payload;
req.session.user = user;
} catch (error) {
throw errorWithControl(errorInvalidToken(), true);
}
};
// Configure API key validatior
export const apiKeyValidator = async (req: Request) => {
const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER];
if (!apiKey) {
throw errorWithControl(errorUnauthorized(), false);
}
try {
const authService = container.get(AuthService);
const isValidApiKey = await authService.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;
};
// Allow anonymous access
export const allowAnonymous = async (req: Request) => {
const user = await getAuthenticatedUserOrAnonymous(req);
req.session = req.session || {};
req.session.user = user;
};
// Return the authenticated user if available, otherwise return an anonymous user
const getAuthenticatedUserOrAnonymous = async (req: Request): Promise<User> => {
const userService = container.get(UserService);
let user: User | null = null;
// Check if there is a user already authenticated
const token = req.cookies[INTERNAL_CONFIG.ACCESS_TOKEN_COOKIE_NAME];
if (token) {
try {
const tokenService = container.get(TokenService);
const payload = await tokenService.verifyToken(token);
const username = payload.sub;
user = username ? await userService.getUser(username) : null;
} catch (error) {
const logger = container.get(LoggerService);
logger.debug('Token found but invalid:' + error);
}
}
if (!user) {
user = userService.getAnonymousUser();
}
return user;
};
// Limit login attempts to avoid brute force attacks
const loginLimiter = rateLimit({
windowMs: ms('5m'),
limit: 5,
skipSuccessfulRequests: true,
message: 'Too many login attempts, please try again later'
});
export const withLoginLimiter = (req: Request, res: Response, next: NextFunction) => {
// Bypass rate limiting in test environment
if (process.env.NODE_ENV === 'test') {
return next();
}
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;
};