How do you authenticate a websocket with token aut

2020-02-07 18:49发布

问题:

We want to use django-channels for our websockets but we need to authenticate as well. We have a rest api running with django-rest-framework and there we use tokens to authenticate a user, but the same functionality does not seem to be built into django-channels.

回答1:

For Django-Channels 2 you can write custom authentication middleware https://gist.github.com/rluts/22e05ed8f53f97bdd02eafdf38f3d60a

token_auth.py:

from channels.auth import AuthMiddlewareStack
from rest_framework.authtoken.models import Token
from django.contrib.auth.models import AnonymousUser


class TokenAuthMiddleware:
    """
    Token authorization middleware for Django Channels 2
    """

    def __init__(self, inner):
        self.inner = inner

    def __call__(self, scope):
        headers = dict(scope['headers'])
        if b'authorization' in headers:
            try:
                token_name, token_key = headers[b'authorization'].decode().split()
                if token_name == 'Token':
                    token = Token.objects.get(key=token_key)
                    scope['user'] = token.user
            except Token.DoesNotExist:
                scope['user'] = AnonymousUser()
        return self.inner(scope)

TokenAuthMiddlewareStack = lambda inner: TokenAuthMiddleware(AuthMiddlewareStack(inner))

routing.py:

from django.urls import path

from channels.http import AsgiHandler
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack

from yourapp.consumers import SocketCostumer
from yourapp.token_auth import TokenAuthMiddlewareStack

application = ProtocolTypeRouter({
    "websocket": TokenAuthMiddlewareStack(
        URLRouter([
            path("socket/", SocketCostumer),
        ]),
    ),

})


回答2:

This answer is valid for channels 1.

You can find all information in this github issue: https://github.com/django/channels/issues/510#issuecomment-288677354

I will summarise the discussion here.

  1. copy this mixin into your project: https://gist.github.com/leonardoo/9574251b3c7eefccd84fc38905110ce4

  2. apply the decorator to ws_connect

the token is received in the app via an earlier authentication request to the /auth-token view in django-rest-framework. We use a querystring to send the token back to django-channels. If you're not using django-rest-framework you can consume the querystring in your own way. Read the mixin for how to get to it.

  1. After using the mixin, and the correct token is used with the upgrade / connect request, the message will have a user like in the example below. As you can see, we have has_permission() implemented on the User model, so it can just check its instance. If there is no token or the token is invalid, there will be no user on the message.

    #  get_group, get_group_category and get_id are specific to the way we named
    #  things in our implementation but I've included them for completeness.
    #  We use the URL `wss://www.website.com/ws/app_1234?token=3a5s4er34srd32`

    def get_group(message):
        return message.content['path'].strip('/').replace('ws/', '', 1)


    def get_group_category(group):
        partition = group.rpartition('_')

        if partition[0]:
            return partition[0]
        else:
            return group


    def get_id(group):
        return group.rpartition('_')[2]


    def accept_connection(message, group):
        message.reply_channel.send({'accept': True})
        Group(group).add(message.reply_channel)


    #  here in connect_app we access the user on message
    #  that has been set by @rest_token_user

    def connect_app(message, group):
        if message.user.has_permission(pk=get_id(group)):
            accept_connection(message, group)


    @rest_token_user
    def ws_connect(message):
        group = get_group(message) # returns 'app_1234'
        category = get_group_category(group) # returns 'app'

        if category == 'app':
            connect_app(message, group)


    # sends the message contents to everyone in the same group

    def ws_message(message):
        Group(get_group(message)).send({'text': message.content['text']})


    # removes this connection from its group. In this setup a
    # connection wil only ever have one group.

    def ws_disconnect(message):
        Group(get_group(message)).discard(message.reply_channel)


thanks to github user leonardoo for sharing his mixin.



回答3:

The following Django-Channels 2 middleware authenticates JWTs generated by djangorestframework-jwt .

The token can be set via the djangorestframework-jwt http APIs, and it will also be sent for WebSocket connections if JWT_AUTH_COOKIE is defined.

settings.py

JWT_AUTH = {
    'JWT_AUTH_COOKIE': 'JWT',     # the cookie will also be sent on WebSocket connections
}

routing.py:

from channels.routing import ProtocolTypeRouter, URLRouter
from django.urls import path
from json_token_auth import JsonTokenAuthMiddlewareStack
from yourapp.consumers import SocketCostumer

application = ProtocolTypeRouter({
    "websocket": JsonTokenAuthMiddlewareStack(
        URLRouter([
            path("socket/", SocketCostumer),
        ]),
    ),

})

json_token_auth.py

from http import cookies

from channels.auth import AuthMiddlewareStack
from django.contrib.auth.models import AnonymousUser
from django.db import close_old_connections
from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication


class JsonWebTokenAuthenticationFromScope(BaseJSONWebTokenAuthentication):
    """
    Extracts the JWT from a channel scope (instead of an http request)
    """

    def get_jwt_value(self, scope):
        try:
            cookie = next(x for x in scope['headers'] if x[0].decode('utf-8') == 'cookie')[1].decode('utf-8')
            return cookies.SimpleCookie(cookie)['JWT'].value
        except:
            return None


class JsonTokenAuthMiddleware(BaseJSONWebTokenAuthentication):
    """
    Token authorization middleware for Django Channels 2
    """

    def __init__(self, inner):
        self.inner = inner

    def __call__(self, scope):

        try:
            # Close old database connections to prevent usage of timed out connections
            close_old_connections()

            user, jwt_value = JsonWebTokenAuthenticationFromScope().authenticate(scope)
            scope['user'] = user
        except:
            scope['user'] = AnonymousUser()

        return self.inner(scope)


def JsonTokenAuthMiddlewareStack(inner):
    return JsonTokenAuthMiddleware(AuthMiddlewareStack(inner))



回答4:

I believe sending token in query string can expose token even inside HTTPS protocols. To come around such issue I have used the following steps:

  1. Create a token based REST API endpoint which creates temporary session and respond back with this session_key (This session is set to expire in 2 minutes)

    login(request,request.user)#Create session with this user
    request.session.set_expiry(2*60)#Make this session expire in 2Mins
    return Response({'session_key':request.session.session_key})
    
  2. Use this session_key in query parameter in channels parameter

I understand there is one extra API call but I believe it's much more secure than sending token in URL string.

Edit: This is just another approach to this problem, as discussed in comments, get parameters are exposed only in urls of http protocols, which should be avoided in anyhow.



回答5:

Regarding Channels 1.x

As already pointed out here the mixin by leonardoo is the easiest way: https://gist.github.com/leonardoo/9574251b3c7eefccd84fc38905110ce4

I think, however, it is somewhat confusing to figure out what the mixin is doing and what not, so I will try to make that clear:

When looking for a way to access message.user using the native django channels decorators you would have to implement it like this:

@channel_session_user_from_http
def ws_connect(message):
  print(message.user)
  pass

@channel_session_user
def ws_receive(message):
  print(message.user)
  pass

@channel_session_user
def ws_disconnect(message):
  print(message.user)
  pass

Channels does that by authenticating the user, creating a http_session and then converting the http_session in a channel_session, which uses the reply channel instead of cookies to identify the client. All this is done in channel_session_user_from_http. Have a look at the channels source code for more detail: https://github.com/django/channels/blob/1.x/channels/sessions.py

leonardoo's decorator rest_token_user does, however, not create a channel session it simply stores the user in the message object in ws_connect. As the token is not sent again in ws_receive and the message object is not available either, in order to get the user in ws_receive and ws_disconnect as well, you would have to store it in the session yourself. This would be a easy way to do this:

@rest_token_user #Set message.user
@channel_session #Create a channel session
def ws_connect(message):
    message.channel_session['userId'] = message.user.id
    message.channel_session.save()
    pass

@channel_session
def ws_receive(message):
    message.user = User.objects.get(id = message.channel_session['userId'])
    pass

@channel_session
def ws_disconnect(message):
    message.user = User.objects.get(id = message.channel_session['userId'])
    pass