backend: streamline authentication validators and improve error handling
This commit is contained in:
parent
e6d04aca16
commit
3fdf2144ab
@ -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<boolean>;
|
||||
|
||||
/**
|
||||
* Validates the authentication credentials and sets the session.
|
||||
*/
|
||||
validate(req: Request): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>)[]): 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<boolean> {
|
||||
const token = await getAccessToken(req);
|
||||
return !!token;
|
||||
},
|
||||
|
||||
if (!token) {
|
||||
throw errorWithControl(errorUnauthorized(), false);
|
||||
async validate(req: Request): Promise<void> {
|
||||
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<boolean> {
|
||||
const token = await getParticipantToken(req);
|
||||
return !!token;
|
||||
},
|
||||
|
||||
async validate(req: Request): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const apiKey = req.headers[INTERNAL_CONFIG.API_KEY_HEADER];
|
||||
return !!apiKey;
|
||||
},
|
||||
|
||||
if (!apiKey) {
|
||||
throw errorWithControl(errorUnauthorized(), false);
|
||||
}
|
||||
async validate(req: Request): Promise<void> {
|
||||
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<boolean> {
|
||||
// Anonymous access is always available
|
||||
return true;
|
||||
},
|
||||
|
||||
req.session = req.session || {};
|
||||
req.session.user = user;
|
||||
async validate(req: Request): Promise<void> {
|
||||
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;
|
||||
};
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user