nestjs / nest

A progressive Node.js framework for building efficient, scalable, and enterprise-grade server-side applications with TypeScript/JavaScript 🚀
https://nestjs.com
MIT License
66.89k stars 7.56k forks source link

Support adding guards on gateway’s handleConnection method. #882

Open xmlking opened 6 years ago

xmlking commented 6 years ago

I'm submitting a...


[ ] Regression 
[ ] Bug report
[x] Feature request
[ ] Documentation issue or request
[ ] Support request => Please do not submit support request here, instead post your question on Stack Overflow.

Current behavior

Currently gateway’s handleConnection method don’t support guards.

Expected behavior

It would be helpful set current user on socket after successfully connected.

Minimal reproduction of the problem with instructions

What is the motivation / use case for changing the behavior?

Environment


Nest version: X.Y.Z


For Tooling issues:
- Node version: XX  
- Platform:  

Others:

Morb0 commented 5 years ago

So what about to add support guards for handleConnection?

KBPatel-Fintech commented 5 years ago

what happen on this?? is this implement or not??

iangregsondev commented 5 years ago

Also I am wondering about this.

binarytracer commented 5 years ago

just newly stumble on here, similar implementation by this

Hwan-seok commented 4 years ago

Instead of using authGuard on handleConnection, I think the following approach can solve this problem alternatively.

Example chat server

// in gateway
async handleConnection(socket) {
    const user: User = await this.jwtService.verify(
      socket.handshake.query.token,
      true
    );

    this.connectedUsers = [...this.connectedUsers, String(user._id)];

    // Send list of connected users
    this.server.emit('users', this.connectedUsers);
  }
// in jwtService
  async verify(token: string, isWs: boolean = false): Promise<User | null> {
    try {
      const payload = <any>jwt.verify(token, APP_CONFIG.jwtSecret);
      const user = await this.usersService.findById(payload.sub._id);

      if (!user) {
        if (isWs) {
          throw new WsException('Unauthorized access');
        } else {
          throw new HttpException(
            'Unauthorized access',
            HttpStatus.BAD_REQUEST
          );
        }
      }

      return user;
    } catch (err) {
      if (isWs) {
        throw new WsException(err.message);
      } else {
        throw new HttpException(err.message, HttpStatus.BAD_REQUEST);
      }
    }
  }

Reference

cenk1cenk2 commented 4 years ago

I would love if this was an option for lifecycle hooks as well.

Sikora00 commented 4 years ago

What is the status of this?

ChrisKatsaras commented 4 years ago

@Hwan-seok Thanks for providing your solution but I have one question. I don't see how this implementation prevents the server from crashing, similar to what is being reported in #2028 . Am I missing something?

Hwan-seok commented 4 years ago

Hi @ChrisKatsaras, A lot of time has passed since my last nestjs gateway coding.. so maybe I cannot explain it accurately. Please be aware of it.

Websocket Exception Filters and Guards are not do their job on HandleConnection and it is intended as kamilmysliwiec said. Look what I found at https://github.com/nestjs/nest/issues/336

So, my past solution seems to be wrong, I think it should be change as follows

  async handleConnection(socket) {
      const user = whatEverFindOrVerifyUser();
      if (!user) {
        socket.disconnect(true);     // you can omit "true"
    }
  }

I found socket.disconnect from https://socket.io/docs/server-api/#socket-disconnect-close

I hope it will help!

ChrisKatsaras commented 4 years ago

Thanks for the quick response @Hwan-seok . This is very helpful!

ChrisKatsaras commented 4 years ago

Circling back to the original question, are there any plans to add guards to the handleConnection method in the future? @kamilmysliwiec

ChrisKatsaras commented 4 years ago

@Hwan-seok I've also noticed one minor issue with your solution. In handleConnection we verify and disconnect the connection if it's invalid. This works fine except there is a period of time between when the handleConnection function starts and when the authentication returns the result where the connection can receive events without being properly authenticated. Has anyone found a solution for this? Any help is greatly appreciated! 😃

tonivj5 commented 4 years ago

As @ChrisKatsaras, I have suffered from this too. However, I don't think that that lapse of time is a minor issue :sweat_smile:

ChrisKatsaras commented 4 years ago

As @ChrisKatsaras, I have suffered from this too. However, I don't think that that lapse of time is a minor issue 😅

I agree, @tonivj5 ! Here's to hoping someone can help us out 🤞

Hwan-seok commented 4 years ago

@ChrisKatsaras I forgot to attach await statement on my above example. You're using await statement on const user = await whatEverFindOrVerifyUser(); right?

Because it does not make sense that jumps over Connection handshake step on lifecycle

ChrisKatsaras commented 4 years ago

Hey @Hwan-seok ! Thanks for the quick reply. Unfortunately, adding await doesn't solve the problem due to the fact that the WebSocket establishes the connection (either before or at the beginning of handleConnection). For this reason, it doesn't matter if you await or not as the connection has been established and can receive events emitted from the server (assuming they are scoped to that socket e.g global emission).

The good news is I did find another solution to the problem! We can make use of NestJS Adapters https://docs.nestjs.com/websockets/adapter in order to determine if the connection is valid. The code below is a skeleton of how you can go about validating incoming connections.

export class AuthenticatedWsIoAdapter extends IoAdapter {
    createIOServer(port: number, options?: any): any {
        options.allowRequest = async (request, allowFunction)  => {
        // Do your validation here
            // return allowFunction(null, true); Success
            // return allowFunction("FORBIDDEN", false); Failure
        }
        return super.createIOServer(port, options);
    }
}

By extending IoAdapter, we can use createIOServer and write custom validation for the allowRequest function. Here is a more detailed description of allowRequest:

Screen Shot 2020-05-20 at 2 12 10 PM

All you need to do then is use the adapter like so:

app.useWebSocketAdapter(new AuthenticatedWsIoAdapter(app));

After this, connections will be validated through the allowRequest function before entering handleConnection in our gateway.

Hope this helps you, @tonivj5 and let me know if I missed anything!

tonivj5 commented 4 years ago

Thanks @ChrisKatsaras! I didn't explore that solution :clap:. The only problem I still see, it's that it's out of the DI (injector) :cry:. I inject a service and depend on providers to check user authenticity.

xWiiLLz commented 4 years ago

Hey @tonivj5 , I just used @ChrisKatsaras 's answer and it works great!

The only problem I still see, it's that it's out of the DI (injector) :cry:

The IoAdapter's constructor has an INestApplicationContext, which means you can use DI! :smile:

I've used it like such:

import { INestApplicationContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { extract, parse } from 'query-string';

export class AuthenticatedSocketIoAdapter extends IoAdapter {
  private readonly jwtService: JwtService;
  constructor(private app: INestApplicationContext) {
    super(app);
    this.jwtService = this.app.get(JwtService);
  }

  createIOServer(port: number, options?: SocketIO.ServerOptions): any {
    options.allowRequest = async (request, allowFunction) => {
      const token = parse(extract(request.url))?.token as string;
      const verified = token && (await this.jwtService.verify(token));
      if (verified) {
        return allowFunction(null, true);
      }

      return allowFunction('Unauthorized', false);
    };

    return super.createIOServer(port, options);
  }
}

Hope this helps!

ChrisKatsaras commented 4 years ago

Thanks for adding that information @xWiiLLz ! Glad I could help out 😄

tonivj5 commented 4 years ago

Thank you very much @xWiiLLz and @ChrisKatsaras! :clap::+1:

I will test it with my use-case :smiley:

Jamie452 commented 4 years ago

I've been playing around with @ChrisKatsaras & @xWiiLLz 's approach, but am struggling to figure out how you would be able to tell which user is authenticated within future messages after the handshake.

In the past, with vanilla socket.io, I'd handle the handshake in a similar way but also store the user's ID (sometimes even a copy of the user) on the socket during the handshake, which could then be retrieved by accessing socket.user.id (or whatever) during future messages.

Is it possible to replicate this with Nest's approach to WebSockets?

dsebastien commented 4 years ago

@Jamie452 I'm also looking into this; curious if you find something out. I'll report here / blog about it if I do :)

xWiiLLz commented 4 years ago

@Jamie452 I'm also looking into this; curious if you find something out. I'll report here / blog about it if I do :)

Hey, I can share the workaround I'm using in my personal project later tonight. Will get back to you both!

Jamie452 commented 4 years ago

@Jamie452 I'm also looking into this; curious if you find something out. I'll report here / blog about it if I do :)

@dsebastien what I ended up doing was a combination of the two previously posted suggestions. It's far from perfect but lets me get on for the time being.

I'm checking the JWT is valid in options.allowRequest and then storing the user's data on the socket in the gateways handleConnection method, where you can get the handshake headers using socket.handshake.headers.authorization.

Here's what my code looks like, interested to see how you handled it @xWiiLLz;

authenticated-socket-io.adapter.ts

export class AuthenticatedSocketIoAdapter extends IoAdapter {

  private httpStrategy: HttpStrategy

  constructor(
    private app: INestApplicationContext,
  ) {
    super(app)
    this.httpStrategy = this.app.get(HttpStrategy)
  }

  create(port: number, options?: any): any {
    return this.createIOServer(port, options)
  }

  createIOServer(port: number, options?: ServerOptions): any {

    options.allowRequest = async (request, allowFunction) => {
      try {
        // This is where I validate the user has a valid JWT token, but don't necessarily care who they are
        await this.httpStrategy.validate(request.headers.authorization.replace("Bearer ", ""))
      } catch (e) {
        console.warn("Failed to authenticate user:", e)
        return allowFunction("Unauthorized", false)
      }
      return allowFunction(null, true)
    }

    return server
  }
}

app.gateway.ts

interface SocketWithUserData extends Socket {
  userData: UserJWTData
}

async handleConnection(socket: SocketWithUserData, ...args: any[]) {
    try {
      socket.userData = await this.httpStrategy.validate(socket.handshake.headers.authorization.replace("Bearer ", ""))
    } catch (e) {
      this.logger.error("Socket disconnected within handleConnection() in AppGateway:", e)
      socket.disconnect(true)
      return
    }
}

@SubscribeMessage("whoAmI")
  onWhoAmI(socket: SocketWithUserData, data: any): void {
    socket.emit("whoAmI", socket.userData)
}
xWiiLLz commented 4 years ago

Hey,

So I guess my code is doing something similar to @Jamie452 , but using guards and the JwtService.

Here's the relevant parts.

connected-socket.ts

import * as SocketIO from 'socket.io';

export interface ConnectedSocket extends SocketIO.Socket {
  conn: SocketIO.EngineSocket & {
    token: string;
    userId: number;
  };
}

base-gateway.ts

@UsePipes(new ValidationPipe())
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(SocketSessionGuard)
@UseFilters(new SocketExceptionFilter())
export class BaseGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(protected jwtService: JwtService) {}

  @WebSocketServer()
  protected server: Server;

  async handleConnection(client: ConnectedSocket, ...args: any[]) {
    const authorized = await SocketSessionGuard.verifyToken(
      this.jwtService,
      client,
      client.handshake.query.token,
    );

    if (!authorized) {
      throw new UnauthorizedException();
    }

    console.log(`${client.conn.userId} Connected to gateway`);
  }
...
}

socket-session.guard.ts

@Injectable()
export class SocketSessionGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    console.log('SocketSession activated');
    const client = context?.switchToWs()?.getClient<ConnectedSocket>();
    return SocketSessionGuard.verifyToken(
      this.jwtService,
      client,
      client.request['token'],
    );
  }

  static async verifyToken(
    jwtService: JwtService,
    socket: ConnectedSocket,
    token?: string,
  ) {
    if (
      socket.conn.userId &&
      (await jwtService.verifyAsync(socket.conn.token))
    ) {
      return true;
    }

    if (!token) return false;

    socket.conn.token = token;
    const { sub } = await jwtService.decode(token);
    socket.conn.userId = sub;
    console.log(`Setting connection userId to "${sub}"`);
    return true;
  }
}

Hope this helps 😊

Sorry about not providing this sample earlier, was in the middle of moving and completely forgot about that thread!

woodcockjosh commented 4 years ago

@xWiiLLz why do you have @UseGuards(SocketSessionGuard) that's not actually doing anything? Why not inject the guard in the constructor and make the method non-static? Also why are you putting the token in the query rather than the header?

xWiiLLz commented 4 years ago

@xWiiLLz why do you have @UseGuards(SocketSessionGuard) that's not actually doing anything? Why not inject the guard in the constructor and make the method non-static? Also why are you putting the token in the query rather than the header?

I assume the validateConnection is just called once (at the handshake). With the guard, you can verify the token (expiry, token refresh logic...) at every call, as you would with a REST controller.

Science001 commented 3 years ago

Is there a way we can get the namespace the socket is trying to connect, in the adapter? I want to run different verifications for different namespace connections and the request URL doesn't seem to hold the namespace in it.

DmitrySergeyev commented 3 years ago

Possible implementation of authorization for native ws and WsAdapter with correct handshake response. Auth can be different for every gateway (as it required in my case).

some.gateway.ts

...
import * as net from "net";
import {Server} from 'ws';
import {getClientIp} from 'request-ip';
...
afterInit(wss: Server): any {
        const httpServer: net.Server = wss._server;
        httpServer.removeAllListeners("upgrade");
        httpServer.on('upgrade', async (req, socket, head) => {
            const userIp = getClientIp(req);

            try {
                await this.authUserService.check(req.headers);
                this.logger.log(`Auth good for user with IP: ${userIp}`);

                wss.handleUpgrade(req, socket, head, function done(ws) {
                    wss.emit('connection', ws, req);
                });
            } catch (e) {
                if (e instanceof ValidationError) {
                    this.logger.warn(`Auth bad for user with IP ${userIp}: ${e.message}`);
                    return abortHandshake(socket, 401);
                } else {
                    this.logger.error(`Auth user error: ${e.message}`, e.stack);
                    return abortHandshake(socket, 500);
                }
            }
        });
    }
...

abortHandshake implementation can be found here

P.S. As far I undestand, this behavior also can be achieved with using verifyClient function of ws, similar to allowRequest and socketio, but it's not a best practice

Manuelbaun commented 3 years ago

Hey guys, when using the ws not socket.io lib for websockets, intercepting the websocket upgrade and performe some validation can also be done using the websocket gateway decorator like this:

@WebSocketGateway({
    cookie: 'sid',
    path: '/ws',
    allowUpgrades: false,
    transports: ['websocket'],
    verifyClient: (info, cb) => {
        console.log(info.req);
        cb(true, 200, 'you are verified', {
            cookie: 'sid: hello world',
        });
    },
})

the verifyClient option gets past down to the websocket-server.js. There is no need to change anything, except to provide the needed information, that this option exist. I am not sure, how this could work with Authgards and session etc. If you have an idea how to use it with express-session, let me know.

For more information, I have submitted a feature request

I hope this helps anybody.

Juraj-Masiar commented 2 years ago

Thank you @xWiiLLz for the great examples! Especially the adapter and guard! One thing I would like to mention is that for the security reasons you shouldn't put your secret token in the URL, but use a header instead. Luckily the code will stay the same because you have access to the headers in both places:

const token = request.headers.authorization;  // in adapter
const token = client.handshake.headers.authorization;  // in guard

And on the client side you just add the token when creating the socket.io-client instance, for example:

const socket = io(environment.host, {
  extraHeaders: {
    authorization: userToken,
  },
});
kasvith commented 2 years ago

Maybe we should add some samples provided here in the docs

Grandkhan commented 2 years ago

@DmitrySergeyev Hi, your code is very interesting, but how do you access wss._server ?

rluvaton commented 2 years ago

abortHandshake implementation can be found here

abortHandshake at the time of the author commented was here and now it's here

the link was to the master branch and not to specific commit

jackykwandesign commented 2 years ago

same happened in #9231 Its quite dumb to do auth in each emited events in server, anyone find a way to access original socket.io and apply a middleware on it?

https://socket.io/docs/v4/middlewares/ Clearly mentioned that we can apply a middleware for one-time auth only when connection establish, before actual connect to server

jackykwandesign commented 2 years ago

But in the adapter i go through all parameters i can use, i find nothing helpfun for socket auth example in here image

Adapter
  options {
    transports: [ 'websocket' ],
    cors: { origin: '*' },
    allowRequest: [AsyncFunction (anonymous)]
  }
  options.path undefined
  request IncomingMessage {
    _readableState: ReadableState {
      objectMode: false,
      highWaterMark: 16384,
      buffer: BufferList { head: null, tail: null, length: 0 },
      length: 0,
      pipes: [],
      flowing: null,
      ended: true,
      endEmitted: false,
      reading: false,
      constructed: true,
      sync: true,
      needReadable: false,
      emittedReadable: true,
      readableListening: false,
      resumeScheduled: false,
      errorEmitted: false,
      emitClose: true,
      autoDestroy: true,
      destroyed: false,
      errored: null,
      closed: false,
      closeEmitted: false,
      defaultEncoding: 'utf8',
      awaitDrainWriters: null,
      multiAwaitDrain: false,
      readingMore: true,
      dataEmitted: false,
      decoder: null,
      encoding: null,
      [Symbol(kPaused)]: null
    },
    _events: [Object: null prototype] {},
    _eventsCount: 0,
    _maxListeners: undefined,
    socket: <ref *1> Socket {
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: null,
      _readableState: ReadableState {
        objectMode: false,
        highWaterMark: 16384,
        buffer: BufferList { head: null, tail: null, length: 0 },
        length: 0,
        pipes: [],
        flowing: null,
        ended: false,
        endEmitted: false,
        reading: true,
        constructed: true,
        sync: false,
        needReadable: true,
        emittedReadable: false,
        readableListening: false,
        resumeScheduled: false,
        errorEmitted: false,
        emitClose: false,
        autoDestroy: true,
        destroyed: false,
        errored: null,
        closed: false,
        closeEmitted: false,
        defaultEncoding: 'utf8',
        awaitDrainWriters: null,
        multiAwaitDrain: false,
        readingMore: false,
        dataEmitted: false,
        decoder: null,
        encoding: null,
        [Symbol(kPaused)]: false
      },
      _events: [Object: null prototype] { end: [Function: onReadableStreamEnd] },
      _eventsCount: 1,
      _maxListeners: undefined,
      _writableState: WritableState {
        objectMode: false,
        highWaterMark: 16384,
        finalCalled: false,
        needDrain: false,
        ending: false,
        ended: false,
        finished: false,
        destroyed: false,
        decodeStrings: false,
        defaultEncoding: 'utf8',
        length: 0,
        writing: false,
        corked: 0,
        sync: true,
        bufferProcessing: false,
        onwrite: [Function: bound onwrite],
        writecb: null,
        writelen: 0,
        afterWriteTickInfo: null,
        buffered: [],
        bufferedIndex: 0,
        allBuffers: true,
        allNoop: true,
        pendingcb: 0,
        constructed: true,
        prefinished: false,
        errorEmitted: false,
        emitClose: false,
        autoDestroy: true,
        errored: null,
        closed: false,
        closeEmitted: false,
        [Symbol(kOnFinished)]: []
      },
      allowHalfOpen: true,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: Server {
        maxHeaderSize: undefined,
        insecureHTTPParser: undefined,
        _events: [Object: null prototype],
        _eventsCount: 5,
        _maxListeners: undefined,
        _connections: 1,
        _handle: [TCP],
        _usingWorkers: false,
        _workers: [],
        _unref: false,
        allowHalfOpen: true,
        pauseOnConnect: false,
        httpAllowHalfOpen: false,
        timeout: 0,
        keepAliveTimeout: 5000,
        maxHeadersCount: null,
        maxRequestsPerSocket: 0,
        headersTimeout: 60000,
        requestTimeout: 0,
        _connectionKey: '6::::4005',
        [Symbol(IncomingMessage)]: [Function: IncomingMessage],
        [Symbol(ServerResponse)]: [Function: ServerResponse],
        [Symbol(kCapture)]: false,
        [Symbol(async_id_symbol)]: 15
      },
      _server: Server {
        maxHeaderSize: undefined,
        insecureHTTPParser: undefined,
        _events: [Object: null prototype],
        _eventsCount: 5,
        _maxListeners: undefined,
        _connections: 1,
        _handle: [TCP],
        _usingWorkers: false,
        _workers: [],
        _unref: false,
        allowHalfOpen: true,
        pauseOnConnect: false,
        httpAllowHalfOpen: false,
        timeout: 0,
        keepAliveTimeout: 5000,
        maxHeadersCount: null,
        maxRequestsPerSocket: 0,
        headersTimeout: 60000,
        requestTimeout: 0,
        _connectionKey: '6::::4005',
        [Symbol(IncomingMessage)]: [Function: IncomingMessage],
        [Symbol(ServerResponse)]: [Function: ServerResponse],
        [Symbol(kCapture)]: false,
        [Symbol(async_id_symbol)]: 15
      },
      parser: null,
      on: [Function: socketListenerWrap],
      addListener: [Function: socketListenerWrap],
      prependListener: [Function: socketListenerWrap],
      setEncoding: [Function: socketSetEncoding],
      _paused: false,
      [Symbol(async_id_symbol)]: 18,
      [Symbol(kHandle)]: TCP {
        reading: true,
        onconnection: null,
        _consumed: true,
        [Symbol(owner_symbol)]: [Circular *1]
      },
      [Symbol(kSetNoDelay)]: false,
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: null,
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(kCapture)]: false,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0,
      [Symbol(RequestTimeout)]: undefined
    },
    httpVersionMajor: 1,
    httpVersionMinor: 1,
    httpVersion: '1.1',
    complete: true,
    rawHeaders: [
      'Host',
      'localhost:4005',
      'Connection',
      'Upgrade',
      'Pragma',
      'no-cache',
      'Cache-Control',
      'no-cache',
      'User-Agent',
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36',
      'Upgrade',
      'websocket',
      'Origin',
      'http://localhost:4002',
      'Sec-WebSocket-Version',
      '13',
      'Accept-Encoding',
      'gzip, deflate, br',
      'Accept-Language',
      'zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7',
      'Sec-WebSocket-Key',
      'MciI8xIiBAf5mUHc0MWT+Q==',
      'Sec-WebSocket-Extensions',
      'permessage-deflate; client_max_window_bits'
    ],
    rawTrailers: [],
    aborted: false,
    upgrade: true,
    url: '/socket.io/?EIO=4&transport=websocket',
    method: 'GET',
    statusCode: null,
    statusMessage: null,
    client: <ref *1> Socket {
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: null,
      _readableState: ReadableState {
        objectMode: false,
        highWaterMark: 16384,
        buffer: BufferList { head: null, tail: null, length: 0 },
        length: 0,
        pipes: [],
        flowing: null,
        ended: false,
        endEmitted: false,
        reading: true,
        constructed: true,
        sync: false,
        needReadable: true,
        emittedReadable: false,
        readableListening: false,
        resumeScheduled: false,
        errorEmitted: false,
        emitClose: false,
        autoDestroy: true,
        destroyed: false,
        errored: null,
        closed: false,
        closeEmitted: false,
        defaultEncoding: 'utf8',
        awaitDrainWriters: null,
        multiAwaitDrain: false,
        readingMore: false,
        dataEmitted: false,
        decoder: null,
        encoding: null,
        [Symbol(kPaused)]: false
      },
      _events: [Object: null prototype] { end: [Function: onReadableStreamEnd] },
      _eventsCount: 1,
      _maxListeners: undefined,
      _writableState: WritableState {
        objectMode: false,
        highWaterMark: 16384,
        finalCalled: false,
        needDrain: false,
        ending: false,
        ended: false,
        finished: false,
        destroyed: false,
        decodeStrings: false,
        defaultEncoding: 'utf8',
        length: 0,
        writing: false,
        corked: 0,
        sync: true,
        bufferProcessing: false,
        onwrite: [Function: bound onwrite],
        writecb: null,
        writelen: 0,
        afterWriteTickInfo: null,
        buffered: [],
        bufferedIndex: 0,
        allBuffers: true,
        allNoop: true,
        pendingcb: 0,
        constructed: true,
        prefinished: false,
        errorEmitted: false,
        emitClose: false,
        autoDestroy: true,
        errored: null,
        closed: false,
        closeEmitted: false,
        [Symbol(kOnFinished)]: []
      },
      allowHalfOpen: true,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: Server {
        maxHeaderSize: undefined,
        insecureHTTPParser: undefined,
        _events: [Object: null prototype],
        _eventsCount: 5,
        _maxListeners: undefined,
        _connections: 1,
        _handle: [TCP],
        _usingWorkers: false,
        _workers: [],
        _unref: false,
        allowHalfOpen: true,
        pauseOnConnect: false,
        httpAllowHalfOpen: false,
        timeout: 0,
        keepAliveTimeout: 5000,
        maxHeadersCount: null,
        maxRequestsPerSocket: 0,
        headersTimeout: 60000,
        requestTimeout: 0,
        _connectionKey: '6::::4005',
        [Symbol(IncomingMessage)]: [Function: IncomingMessage],
        [Symbol(ServerResponse)]: [Function: ServerResponse],
        [Symbol(kCapture)]: false,
        [Symbol(async_id_symbol)]: 15
      },
      _server: Server {
        maxHeaderSize: undefined,
        insecureHTTPParser: undefined,
        _events: [Object: null prototype],
        _eventsCount: 5,
        _maxListeners: undefined,
        _connections: 1,
        _handle: [TCP],
        _usingWorkers: false,
        _workers: [],
        _unref: false,
        allowHalfOpen: true,
        pauseOnConnect: false,
        httpAllowHalfOpen: false,
        timeout: 0,
        keepAliveTimeout: 5000,
        maxHeadersCount: null,
        maxRequestsPerSocket: 0,
        headersTimeout: 60000,
        requestTimeout: 0,
        _connectionKey: '6::::4005',
        [Symbol(IncomingMessage)]: [Function: IncomingMessage],
        [Symbol(ServerResponse)]: [Function: ServerResponse],
        [Symbol(kCapture)]: false,
        [Symbol(async_id_symbol)]: 15
      },
      parser: null,
      on: [Function: socketListenerWrap],
      addListener: [Function: socketListenerWrap],
      prependListener: [Function: socketListenerWrap],
      setEncoding: [Function: socketSetEncoding],
      _paused: false,
      [Symbol(async_id_symbol)]: 18,
      [Symbol(kHandle)]: TCP {
        reading: true,
        onconnection: null,
        _consumed: true,
        [Symbol(owner_symbol)]: [Circular *1]
      },
      [Symbol(kSetNoDelay)]: false,
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: null,
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(kCapture)]: false,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0,
      [Symbol(RequestTimeout)]: undefined
    },
    _consuming: false,
    _dumped: false,
    parser: null,
    _query: [Object: null prototype] { EIO: '4', transport: 'websocket' },
    [Symbol(kCapture)]: false,
    [Symbol(kHeaders)]: {
      host: 'localhost:4005',
      connection: 'Upgrade',
      pragma: 'no-cache',
      'cache-control': 'no-cache',
      'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36',
      upgrade: 'websocket',
      origin: 'http://localhost:4002',
      'sec-websocket-version': '13',
      'accept-encoding': 'gzip, deflate, br',
      'accept-language': 'zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7',
      'sec-websocket-key': 'MciI8xIiBAf5mUHc0MWT+Q==',
      'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits'
    },
    [Symbol(kHeadersCount)]: 24,
    [Symbol(kTrailers)]: null,
    [Symbol(kTrailersCount)]: 0,
    [Symbol(RequestTimeout)]: undefined
  }
  request.headers {
    host: 'localhost:4005',
    connection: 'Upgrade',
    pragma: 'no-cache',
    'cache-control': 'no-cache',
    'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36',
    upgrade: 'websocket',
    origin: 'http://localhost:4002',
    'sec-websocket-version': '13',
    'accept-encoding': 'gzip, deflate, br',
    'accept-language': 'zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7',
    'sec-websocket-key': 'MciI8xIiBAf5mUHc0MWT+Q==',
    'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits'
  }
  request.socket.eventNames [ 'end' ]
  socket.auth undefined
client connect 5csfs8KnqMl1L3ZKAAAC

I can not find any useful information if i want to do authentication for specific namespace in websocket gateway For example, in client i put a jwt token in socket.io-client's option's auth I can print it out in GateWay handleConnection's parameter, but i can not find any data in adapter image

client.handshake.auth {
  jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySUQiOiI0NDBjMjg0YS03YmFkLTQ3NDgtYjg3MS0yZjRiNmY5Mjg2ZWEiLCJpYXQiOjE2NDUzNTc3NjQsImV4cCI6MTY0NTk2MjU2NH0.ad8pa4gujJ5fzTNnAkQAHe_dSWiZQH993S6snBxbGfA'
}
jackykwandesign commented 2 years ago

Hey @tonivj5 , I just used @ChrisKatsaras 's answer and it works great!

The only problem I still see, it's that it's out of the DI (injector) 😢

The IoAdapter's constructor has an INestApplicationContext, which means you can use DI! 😄

I've used it like such:

import { INestApplicationContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { extract, parse } from 'query-string';

export class AuthenticatedSocketIoAdapter extends IoAdapter {
  private readonly jwtService: JwtService;
  constructor(private app: INestApplicationContext) {
    super(app);
    this.jwtService = this.app.get(JwtService);
  }

  createIOServer(port: number, options?: SocketIO.ServerOptions): any {
    options.allowRequest = async (request, allowFunction) => {
      const token = parse(extract(request.url))?.token as string;
      const verified = token && (await this.jwtService.verify(token));
      if (verified) {
        return allowFunction(null, true);
      }

      return allowFunction('Unauthorized', false);
    };

    return super.createIOServer(port, options);
  }
}

Hope this helps!

It looks good but should be pass the JWT token in URL query parameter ? It looks not safe

jackykwandesign commented 2 years ago

I've been playing around with @ChrisKatsaras & @xWiiLLz 's approach, but am struggling to figure out how you would be able to tell which user is authenticated within future messages after the handshake.

In the past, with vanilla socket.io, I'd handle the handshake in a similar way but also store the user's ID (sometimes even a copy of the user) on the socket during the handshake, which could then be retrieved by accessing socket.user.id (or whatever) during future messages.

Is it possible to replicate this with Nest's approach to WebSockets?

It also how i deal with auth in native websocket, by storing information i need into context when connected So i can access whenever i want I can believe the removal of middleware support, it is a must for any further customization. I can not store any information into socket in handleConnect function The only way i can access is to put a Guard function on every subscribeMessage, and it is wasting overhead of the connection to check token, get user info in DB, blablabla in EVERY event emit

jackykwandesign commented 2 years ago

Hey,

So I guess my code is doing something similar to @Jamie452 , but using guards and the JwtService.

Here's the relevant parts.

connected-socket.ts

import * as SocketIO from 'socket.io';

export interface ConnectedSocket extends SocketIO.Socket {
  conn: SocketIO.EngineSocket & {
    token: string;
    userId: number;
  };
}

base-gateway.ts

@UsePipes(new ValidationPipe())
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(SocketSessionGuard)
@UseFilters(new SocketExceptionFilter())
export class BaseGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(protected jwtService: JwtService) {}

  @WebSocketServer()
  protected server: Server;

  async handleConnection(client: ConnectedSocket, ...args: any[]) {
    const authorized = await SocketSessionGuard.verifyToken(
      this.jwtService,
      client,
      client.handshake.query.token,
    );

    if (!authorized) {
      throw new UnauthorizedException();
    }

    console.log(`${client.conn.userId} Connected to gateway`);
  }
...
}

socket-session.guard.ts

@Injectable()
export class SocketSessionGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    console.log('SocketSession activated');
    const client = context?.switchToWs()?.getClient<ConnectedSocket>();
    return SocketSessionGuard.verifyToken(
      this.jwtService,
      client,
      client.request['token'],
    );
  }

  static async verifyToken(
    jwtService: JwtService,
    socket: ConnectedSocket,
    token?: string,
  ) {
    if (
      socket.conn.userId &&
      (await jwtService.verifyAsync(socket.conn.token))
    ) {
      return true;
    }

    if (!token) return false;

    socket.conn.token = token;
    const { sub } = await jwtService.decode(token);
    socket.conn.userId = sub;
    console.log(`Setting connection userId to "${sub}"`);
    return true;
  }
}

Hope this helps 😊

Sorry about not providing this sample earlier, was in the middle of moving and completely forgot about that thread!

I don't know if it is a bug or not, Guard is not working in Gateway level You can reference to my issue at #9231

jackykwandesign commented 2 years ago

This is my workaround solution, perfectly before handleConnection and without any adapter I create a namespace specific middleware function, use jwtService from jwtModule and my userService as parameter

auth.middleware.ts

import { JwtService } from '@nestjs/jwt';
import { Socket } from 'socket.io';
import { JwtPayload } from 'src/auth/jwt/jwt-payload.interface';
import { Player } from 'src/typs';
import { UserService } from 'src/user/user.service';

export interface AuthSocket extends Socket {
  user: Player;
}
export type SocketMiddleware = (socket: Socket, next: (err?: Error) => void) => void
export const WSAuthMiddleware = (jwtService:JwtService, userService:UserService):SocketMiddleware =>{
  return async (socket:AuthSocket, next) => {
    try {
      const jwtPayload = jwtService.verify(
        socket.handshake.auth.jwt ?? '',
      ) as JwtPayload;
      const userResult = await userService.getUser(jwtPayload.userID);
      if (userResult.isSuccess) {
        socket.user = userResult.data;
        next();
      } else {
        next({
          name: 'Unauthorizaed',
          message: 'Unauthorizaed',
        });
      }
    } catch (error) {
      next({
        name: 'Unauthorizaed',
        message: 'Unauthorizaed',
      });
    }
  }
}

ws.gateway.ts

import {
  UseFilters,
  UseGuards,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import {
  ConnectedSocket,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
  MessageBody,
} from '@nestjs/websockets';
import { Server as SocketIOServer, Socket } from 'socket.io';
import { CreateGameDataDTO } from './dto/message-dto';
import { WsClientEvent } from '../typs';
import { WSCommExceptionsFilter } from './WSFilter.filter';
import { NestGateway } from '@nestjs/websockets/interfaces/nest-gateway.interface';
import { JwtService } from '@nestjs/jwt';
import { UserService } from 'src/user/user.service';
import { AuthSocket, WSAuthMiddleware } from './auth.middleware';

@UsePipes(new ValidationPipe())
@UseFilters(new WSCommExceptionsFilter())
@WebSocketGateway({
  transports: ['websocket'],
  cors: {
    origin: '*',
  },
  namespace: '/', //this is the namespace, which manager.socket(nsp) connect to
})
export class WsRoomGateway implements NestGateway {

  constructor(
    private readonly jwtService:JwtService,
    private readonly userService:UserService,
  ){}
  @WebSocketServer()
  server: SocketIOServer;

  afterInit(server: SocketIOServer) {
    const middle = WSAuthMiddleware(this.jwtService, this.userService)
    server.use(middle)
    console.log(`WS ${WsRoomGateway.name} init`);
  }
  handleDisconnect(client: Socket) {
    console.log('client disconnect', client.id);
  }

  handleConnection(client: AuthSocket, ...args: any[]) {
    console.log('client connect', client.id, client.user);
  }

  @SubscribeMessage(WsClientEvent.CREATE_ROOM)
  handleCreateRoom(
    @ConnectedSocket() client: AuthSocket,
    @MessageBody() body: CreateGameDataDTO,
  ) {
    client.send('test', body);
  }
}

In console, when user senc connect request with jwt Token in auth option of socket.io-client or anywhere you want accessable in Socket, it will be authed before handleConnect, and user obj will be availble in cutomized Socket AuthSocket image image

If user not pass the jwt Token, the next(err) will reject the connection, make whole websocket connection much clear, no need to do Guard auth in all @SubscribeMessage

image

benbai123 commented 2 years ago

Based on Manuelbaun's answer, I've implemented auth for ws connection, my implementation :

  1. Use common http request to get one time token with jwt auth token.
  2. Use that one time token to get WebSocket connection by attach that token in get param
  3. Verify that token in verifyClient of WebSocketGateway decorator, you can get that token from info.req.url within verifyClient(info, cb), set info.req.client.isValid to true if verified
  4. Add @UseGuards(WebSocketGuard) above @SubscribeMessage('events') if needed
  5. In WebSocketGuard, implement canActive as below
export class WebSocketGuard extends AuthGuard('websocket') {
  canActivate(context: ExecutionContext) {
    const client = context.switchToWs().getClient();
    return client._socket.isValid;
  }  
}
xtrinch commented 1 year ago

This issue has been open since 2018, and I think none of the solutions are too great, when NestJS itself should just allow attaching guards to handleConnection.

Why was this marked as won't fix without at least an explanation given?

kingnebby commented 1 year ago

@jackykwandesign has the best answer. Thank you.

MaksimKiselev commented 1 year ago

In my case fetching user data is asynchronous, so I found this workaround:

import { ConnectedSocket, OnGatewayConnection, SubscribeMessage, WebSocketGateway, } from '@nestjs/websockets';
import { UseGuards } from '@nestjs/common';
import { WsAuthGuard } from './ws-auth.guard';
import { WsAuthService } from './ws-auth.service';
import { Socket } from 'socket.io';

@WebSocketGateway(3001, { cors: true, transports: ['websocket'], path: '/io' })
@UseGuards(WsAuthGuard)
export class SocketIoController implements OnGatewayConnection {
  constructor(private readonly wsAuthService: WsAuthService) {}

  handleConnection(@ConnectedSocket() client: Socket) {
    this.wsAuthService.onClientConnect(client);
  }

  @SubscribeMessage('test')
  async test(@ConnectedSocket() client: Socket) {
    return JSON.stringify(client.data);
  }
}
import { Injectable } from '@nestjs/common';
import { Socket } from 'socket.io';

@Injectable()
export class WsAuthService {
  private initializationMap = new Map<string, Promise<any>>();

  onClientConnect(socket: Socket) {
    this.initializationMap.set(socket.id, this.initialize(socket));
  }

  async finishInitialization(socket: Socket): Promise<any> {
    await this.initializationMap.get(socket.id);
  }

  private async initialize(socket: Socket): Promise<any> {
    // asynchronously get user data
    const user = await new Promise((resolve) =>
      setTimeout(() => resolve({ id: 1234, hasPower: true, foo: 'bar' }), 2000),
    );

    // store result to socket data
    socket.data.user = user;

    this.initializationMap.delete(socket.id);
  }
}
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { WsAuthService } from './ws-auth.service';
import { Socket } from 'socket.io';

@Injectable()
export class WsAuthGuard implements CanActivate {
  constructor(private readonly wsAuthService: WsAuthService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const client = context.switchToWs().getClient<Socket>();

    // wait until client data initialization will be finished
    await this.wsAuthService.finishInitialization(client);

    // your canActivate logic
    return client.data.user.hasPower;
  }
}

The logic: 1) Delegate client handleConnection to WsAuthService.onClientConnect 2) Store client initialization Promise in WsAuthService 3) Await promise resolution in WsAuthGuard (event will not be processed until Guard will not finish check)

image

Cheers 🍻

Related: https://github.com/nestjs/nest/issues/1254 https://github.com/nestjs/nest/issues/336

yingl commented 1 year ago

Strongly support you! The auth should be at handleConnection, to auth it in message handler is totally of no sense. If the connection is created securely, the auth check is useless here.

We wrote our 1st version of code in Java, and now want to migrate to NestJS and hit this problem.