@@ -8,8 +8,24 @@ import {
88 MessageBody ,
99} from '@nestjs/websockets' ;
1010import { Logger } from '@nestjs/common' ;
11+ import { ConfigService } from '@nestjs/config' ;
1112import { Server , Socket } from 'socket.io' ;
13+ import { WsAuthService } from '../auth/ws-auth.service' ;
14+ import type { AuthUser } from '../auth/current-user.decorator' ;
1215
16+ /**
17+ * Events gateway — real-time PMS event broadcasts.
18+ *
19+ * Security model:
20+ * - On connect: verify a JWT passed via `handshake.auth.token` or `?token=`.
21+ * Invalid/missing tokens cause immediate disconnect.
22+ * - On joinProperty: the requested propertyId must appear in the user's
23+ * JWT `property_ids` custom claim (mapped onto AuthUser.propertyIds).
24+ * Admin/platform roles can join any property.
25+ * - Dev bypass: when AUTH_ENABLED=false (matching the HTTP JwtAuthGuard),
26+ * connections skip verification to keep local dev frictionless. Production
27+ * defaults to enforced.
28+ */
1329@WebSocketGateway ( {
1430 cors : {
1531 origin : [ 'http://localhost:5173' , 'http://localhost:3000' ] ,
@@ -18,12 +34,42 @@ import { Server, Socket } from 'socket.io';
1834} )
1935export class EventsGateway implements OnGatewayConnection , OnGatewayDisconnect {
2036 private readonly logger = new Logger ( EventsGateway . name ) ;
37+ private readonly authEnabled : boolean ;
2138
2239 @WebSocketServer ( )
2340 server ! : Server ;
2441
25- handleConnection ( client : Socket ) {
26- this . logger . log ( `Client connected: ${ client . id } ` ) ;
42+ constructor (
43+ private readonly wsAuth : WsAuthService ,
44+ private readonly configService : ConfigService ,
45+ ) {
46+ this . authEnabled =
47+ this . configService . get < string > ( 'AUTH_ENABLED' , 'true' ) !== 'false' ;
48+ }
49+
50+ async handleConnection ( client : Socket ) {
51+ if ( ! this . authEnabled ) {
52+ this . logger . log ( `Client connected (auth disabled): ${ client . id } ` ) ;
53+ return ;
54+ }
55+
56+ const token = this . extractToken ( client ) ;
57+ if ( ! token ) {
58+ this . logger . warn ( `Rejecting WS ${ client . id } : no token` ) ;
59+ client . disconnect ( true ) ;
60+ return ;
61+ }
62+
63+ try {
64+ const user = await this . wsAuth . verify ( token ) ;
65+ client . data . user = user ;
66+ this . logger . log ( `Client connected: ${ client . id } (sub=${ user . sub } )` ) ;
67+ } catch ( err : any ) {
68+ this . logger . warn (
69+ `Rejecting WS ${ client . id } : token verification failed — ${ err ?. message ?? err } ` ,
70+ ) ;
71+ client . disconnect ( true ) ;
72+ }
2773 }
2874
2975 handleDisconnect ( client : Socket ) {
@@ -36,6 +82,25 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
3682 @MessageBody ( ) data : { propertyId : string } ,
3783 ) {
3884 if ( ! data ?. propertyId ) return ;
85+
86+ if ( this . authEnabled ) {
87+ const user = client . data . user as AuthUser | undefined ;
88+ if ( ! user ) {
89+ client . emit ( 'error' , { message : 'Not authenticated' } ) ;
90+ return ;
91+ }
92+ if ( ! this . userCanAccessProperty ( user , data . propertyId ) ) {
93+ this . logger . warn (
94+ `WS ${ client . id } (sub=${ user . sub } ) denied joinProperty ${ data . propertyId } ` ,
95+ ) ;
96+ client . emit ( 'error' , {
97+ message : 'Forbidden: not a member of this property' ,
98+ propertyId : data . propertyId ,
99+ } ) ;
100+ return ;
101+ }
102+ }
103+
39104 const room = `property:${ data . propertyId } ` ;
40105 client . join ( room ) ;
41106 this . logger . debug ( `Client ${ client . id } joined room ${ room } ` ) ;
@@ -60,4 +125,30 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
60125 timestamp : new Date ( ) . toISOString ( ) ,
61126 } ) ;
62127 }
128+
129+ private extractToken ( client : Socket ) : string | null {
130+ const authToken = ( client . handshake . auth as any ) ?. token ;
131+ if ( typeof authToken === 'string' && authToken . length > 0 ) {
132+ return authToken . replace ( / ^ B e a r e r \s + / i, '' ) ;
133+ }
134+ const queryToken = client . handshake . query ?. [ 'token' ] ;
135+ if ( typeof queryToken === 'string' && queryToken . length > 0 ) {
136+ return queryToken ;
137+ }
138+ const headerAuth = client . handshake . headers ?. authorization ;
139+ if ( typeof headerAuth === 'string' && headerAuth . length > 0 ) {
140+ return headerAuth . replace ( / ^ B e a r e r \s + / i, '' ) ;
141+ }
142+ return null ;
143+ }
144+
145+ private userCanAccessProperty ( user : AuthUser , propertyId : string ) : boolean {
146+ // Platform-level roles that bypass property scoping (same intent as the
147+ // HTTP RolesGuard — admins can cross tenants for ops).
148+ const platformRoles = new Set ( [ 'admin' , 'platform_admin' , 'superadmin' ] ) ;
149+ if ( user . roles ?. some ( ( r ) => platformRoles . has ( r ) ) ) return true ;
150+
151+ const allowed = user . propertyIds ?? [ ] ;
152+ return allowed . includes ( propertyId ) ;
153+ }
63154}
0 commit comments