frontend: Add session storage service and integrate moderator secret handling in room access validation

This commit is contained in:
Carlos Santos 2025-03-13 12:38:08 +01:00
parent 61f1efd021
commit b4ac4933bf
9 changed files with 224 additions and 26 deletions

View File

@ -3,3 +3,4 @@ export * from './extract-query-params.guard';
export * from './validate-room-access.guard'; export * from './validate-room-access.guard';
export * from './application-mode.guard'; export * from './application-mode.guard';
export * from './participant-name.guard'; export * from './participant-name.guard';
export * from './replace-moderator-secret.guard';

View File

@ -7,10 +7,11 @@ export const checkParticipantNameGuard: CanActivateFn = async (route, state) =>
const router = inject(Router); const router = inject(Router);
const contextService = inject(ContextService); const contextService = inject(ContextService);
const roomName = route.params['room-name']; const roomName = route.params['room-name'];
const hasParticipantName = !!contextService.getParticipantName();
// Check if participant name exists in the service // Check if participant name exists in the service
if (!contextService.getParticipantName()) { if (!hasParticipantName) {
// Redirect to a page where the user can input their participant name // Redirect to a page where the participant can input their participant name
return router.navigate([`${roomName}/participant-name`], { return router.navigate([`${roomName}/participant-name`], {
queryParams: { originUrl: state.url, t: Date.now() }, queryParams: { originUrl: state.url, t: Date.now() },
skipLocationChange: true skipLocationChange: true

View File

@ -0,0 +1,76 @@
import { inject } from '@angular/core';
import { Location } from '@angular/common';
import { CanActivateFn, NavigationEnd } from '@angular/router';
import { Router } from '@angular/router';
import { ContextService, HttpService, SessionStorageService } from '../services';
import { filter, take } from 'rxjs';
/**
* Guard that replaces the moderator secret in the URL with the publisher secret.
*
* This guard checks if the current participant is a moderator. If so, it retrieves the moderator and publisher secrets
* for the current room and updates the session storage with the moderator secret. It then replaces the secret in the URL
* with the publisher secret.
*
* @param route - The activated route snapshot.
* @param state - The router state snapshot.
* @returns A promise that resolves to `true` if the operation is successful, otherwise `false`.
*
* @throws Will log an error and return `false` if an error occurs during the process.
*/
export const replaceModeratorSecretGuard: CanActivateFn = async (route, state) => {
const httpService = inject(HttpService);
const contextService = inject(ContextService);
const router = inject(Router);
const location: Location = inject(Location);
const sessionStorageService = inject(SessionStorageService);
try {
router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
take(1)
)
.subscribe(async () => {
if (contextService.isModeratorParticipant()) {
const roomName = contextService.getRoomName();
const { moderatorSecret, publisherSecret } = await getUrlSecret(httpService, roomName);
sessionStorageService.setModeratorSecret(roomName, moderatorSecret);
// Replace secret in URL by the publisher secret
const queryParams = { ...route.queryParams, secret: publisherSecret };
const urlTree = router.createUrlTree([], { queryParams, queryParamsHandling: 'merge' });
const newUrl = router.serializeUrl(urlTree);
location.replaceState(newUrl);
}
});
return true;
} catch (error) {
console.error('error', error);
return false;
}
};
const getUrlSecret = async (
httpService: HttpService,
roomName: string
): Promise<{ moderatorSecret: string; publisherSecret: string }> => {
const { moderatorRoomUrl, publisherRoomUrl } = await httpService.getRoom(
roomName,
'moderatorRoomUrl,publisherRoomUrl'
);
const extractSecret = (urlString: string, type: string): string => {
const url = new URL(urlString);
const secret = url.searchParams.get('secret');
if (!secret) throw new Error(`${type} secret not found`);
return secret;
};
const publisherSecret = extractSecret(publisherRoomUrl, 'Publisher');
const moderatorSecret = extractSecret(moderatorRoomUrl, 'Moderator');
return { publisherSecret, moderatorSecret };
};

View File

@ -1,6 +1,6 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, CanActivateFn } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, CanActivateFn } from '@angular/router';
import { ContextService, HttpService } from '../services'; import { ContextService, HttpService, SessionStorageService } from '../services';
/** /**
* Guard to validate the access to a room. * Guard to validate the access to a room.
@ -12,30 +12,38 @@ export const validateRoomAccessGuard: CanActivateFn = async (
const httpService = inject(HttpService); const httpService = inject(HttpService);
const contextService = inject(ContextService); const contextService = inject(ContextService);
const router = inject(Router); const router = inject(Router);
const sessionStorageService = inject(SessionStorageService);
const { roomName, participantName, secret } = extractParams(route); const { roomName, participantName, secret } = extractParams(route);
const storageSecret = sessionStorageService.getModeratorSecret(roomName);
if (contextService.isEmbeddedMode() && !contextService.getParticipantName()) {
await redirectToUnauthorized(router, 'invalid-participant');
return false;
}
try { try {
// Generate a participant token // Generate a participant token
const response = await httpService.generateParticipantToken({ roomName, participantName, secret }); const response = await httpService.generateParticipantToken({
roomName,
participantName,
secret: storageSecret || secret
});
contextService.setToken(response.token); contextService.setToken(response.token);
return true; return true;
} catch (error: any) { } catch (error: any) {
if (error.status === 409) { console.error('Error generating participant token:', error);
// Participant already exists switch (error.status) {
// Redirect to a page where the user can input their participant name case 409:
await router.navigate([`${roomName}/participant-name`], { // Participant already exists.
queryParams: { originUrl: _state.url, accessError: 'participant-exists', t: Date.now() }, // Send a timestamp to force update the query params and show the error message in participant name input form
skipLocationChange: true await router.navigate([`${roomName}/participant-name`], {
}); queryParams: { originUrl: _state.url, accessError: 'participant-exists', t: Date.now() },
return false; skipLocationChange: true
});
break;
case 406:
await redirectToUnauthorized(router, 'unauthorized-participant');
break;
default:
await redirectToUnauthorized(router, 'invalid-room');
} }
return await redirectToUnauthorized(router, 'invalid-room'); return false;
} }
}; };

View File

@ -7,7 +7,8 @@ import {
validateRoomAccessGuard, validateRoomAccessGuard,
applicationModeGuard, applicationModeGuard,
extractQueryParamsGuard, extractQueryParamsGuard,
checkParticipantNameGuard checkParticipantNameGuard,
replaceModeratorSecretGuard
} from '../guards'; } from '../guards';
import { import {
AboutComponent, AboutComponent,
@ -77,11 +78,17 @@ export const baseRoutes: Routes = [
{ {
path: ':room-name', path: ':room-name',
component: VideoRoomComponent, component: VideoRoomComponent,
canActivate: [applicationModeGuard, extractQueryParamsGuard, checkParticipantNameGuard, validateRoomAccessGuard], canActivate: [
applicationModeGuard,
extractQueryParamsGuard,
checkParticipantNameGuard,
validateRoomAccessGuard,
replaceModeratorSecretGuard
]
}, },
{ {
path: ':room-name/participant-name', path: ':room-name/participant-name',
component: ParticipantNameFormComponent, component: ParticipantNameFormComponent
}, },
// Redirect all other routes to home // Redirect all other routes to home

View File

@ -23,13 +23,20 @@ export class HttpService {
return this.deleteRequest(`${this.pathPrefix}/${this.apiVersion}/rooms/${roomName}`); return this.deleteRequest(`${this.pathPrefix}/${this.apiVersion}/rooms/${roomName}`);
} }
listRooms(): Promise<OpenViduMeetRoom[]> { listRooms(fields?: string): Promise<OpenViduMeetRoom[]> {
//TODO: Add 'fields' query param for filtering rooms by fields let path = `${this.pathPrefix}/${this.apiVersion}/rooms/`;
return this.getRequest(`${this.pathPrefix}/${this.apiVersion}/rooms`); if (fields) {
path += `?fields=${encodeURIComponent(fields)}`;
}
return this.getRequest(path);
} }
getRoom(roomName: string): Promise<OpenViduMeetRoom> { getRoom(roomName: string, fields?: string): Promise<OpenViduMeetRoom> {
return this.getRequest(`${this.pathPrefix}/${this.apiVersion}/rooms/${roomName}`); let path = `${this.pathPrefix}/${this.apiVersion}/rooms/${roomName}`;
if (fields) {
path += `?fields=${encodeURIComponent(fields)}`;
}
return this.getRequest(path);
} }
generateParticipantToken(tokenOptions: TokenOptions): Promise<{ token: string }> { generateParticipantToken(tokenOptions: TokenOptions): Promise<{ token: string }> {

View File

@ -5,3 +5,4 @@ export * from './global-preferences/global-preferences.service';
export * from './room/room.service'; export * from './room/room.service';
export * from './notification/notification.service'; export * from './notification/notification.service';
export * from './webcomponent-manager/webcomponent-manager.service'; export * from './webcomponent-manager/webcomponent-manager.service';
export * from './session-storage/session-storage.service';

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { SessionStorageService } from './session-storage.service';
describe('SessionStorageService', () => {
let service: SessionStorageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(SessionStorageService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,81 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
/**
* Service for managing session storage operations.
* Provides methods to store, retrieve, and remove data from sessionStorage.
*/
export class SessionStorageService {
constructor() {}
/**
* Stores a moderator secret for a specific room.
*
* @param roomName The room name.
* @param secret The secret string.
*/
public setModeratorSecret(roomName: string, secret: string): void {
this.set(`moderator_secret_${roomName}`, secret);
}
/**
* Retrieves the moderator secret for a specific room.
*
* @param roomName The room name.
* @returns The stored secret or null if not found.
*/
public getModeratorSecret(roomName: string): string | null {
return this.get<string>(`moderator_secret_${roomName}`) ?? null;
}
/**
* Removes the moderator secret for a specific room.
*
* @param roomName The room name.
*/
public removeModeratorSecret(roomName: string): void {
this.remove(`moderator_secret_${roomName}`);
}
/**
* Clears all data stored in sessionStorage.
*/
public clear(): void {
sessionStorage.clear();
}
/**
* Stores a value in sessionStorage.
* The value is converted to a JSON string before saving.
*
* @param key The key under which the value will be stored.
* @param value The value to be stored (any type).
*/
protected set(key: string, value: any): void {
const jsonValue = JSON.stringify(value);
sessionStorage.setItem(key, jsonValue);
}
/**
* Retrieves a value from sessionStorage.
* The value is parsed from JSON back to its original type.
*
* @param key The key of the item to retrieve.
* @returns The stored value or null if the key does not exist.
*/
protected get<T>(key: string): T | null {
const jsonValue = sessionStorage.getItem(key);
return jsonValue ? (JSON.parse(jsonValue) as T) : null;
}
/**
* Removes a specific item from sessionStorage.
*
* @param key The key of the item to remove.
*/
protected remove(key: string): void {
sessionStorage.removeItem(key);
}
}