70

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.

ThaJay
  • 1,758
  • 1
  • 19
  • 30
  • 1
    https://github.com/jaquan1227/django-channel-jwt-auth check this one you can put jwt in the query and it will get the user which has that user id. – 반지하 Jul 06 '18 at 00:50

9 Answers9

61

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),
        ]),
    ),

})
rluts
  • 858
  • 1
  • 9
  • 11
  • 6
    I too am using django channels version 2. How are you connecting passing authorization header token when connecting to websockets? – Kakar Apr 06 '18 at 04:29
  • This is the right answer. Don't use Channels 1. It is obsolete. If you want to reject the user, instead of doing pass return a consumer class that just has a connect method that sends some json like access_denied and handle that on your frontend – kagronick May 06 '18 at 17:57
  • 1
    We also moved to channels 2 and I used this answer to help with the implementation so I accepted it now instead of my own. – ThaJay Jan 17 '19 at 13:03
  • 27
    what do I have to do on the client/javascript side? – sureshvv Mar 02 '19 at 06:47
  • 4
    Anyone could help me on how to pass the authorization header with websockets? – Gabriel Garcia May 29 '19 at 22:10
  • did you figure it out? @GabrielGarcia – prosper1 Feb 13 '20 at 21:33
  • @prosper1 No dice, I'm sending the token on the first websocket message after establishing the connection to authenticate the user – Gabriel Garcia Feb 13 '20 at 22:05
  • 4
    I'm confused how people are getting this to work. According to this SO answer, websocket connections can't customize the headers -- https://stackoverflow.com/questions/4361173/http-headers-in-websockets-client-api – Sean D. Jun 05 '20 at 00:37
  • 1
    I get this error when running that code `You cannot call this from an async context - use a thread or sync_to_async.` with channels 2.4.0 – Anatol Oct 24 '20 at 09:54
  • is this solution outdated? it's different from the official doc? https://channels.readthedocs.io/en/stable/topics/authentication.html – Suicide Bunny Nov 15 '20 at 06:55
  • 1
    the solution is outdated for Django 3+ You cannot get token object directly in __call __ method. But you can move all database query to other function with @database_sync_to_async decorator https://channels.readthedocs.io/en/stable/topics/databases.html – rluts Dec 04 '20 at 13:03
  • See also Kannanravindran's comment: https://gist.github.com/rluts/22e05ed8f53f97bdd02eafdf38f3d60a#gistcomment-3166469 – rluts Dec 04 '20 at 13:13
23

If you are using Django Channels 3 you can use this code: https://gist.github.com/AliRn76/1fb99688315bedb2bf32fc4af0e50157

middleware.py

from django.contrib.auth.models import AnonymousUser
from rest_framework.authtoken.models import Token
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware

@database_sync_to_async
def get_user(token_key):
    try:
        token = Token.objects.get(key=token_key)
        return token.user
    except Token.DoesNotExist:
        return AnonymousUser()

class TokenAuthMiddleware(BaseMiddleware):
    def __init__(self, inner):
        super().__init__(inner)

    async def __call__(self, scope, receive, send):
        try:
            token_key = (dict((x.split('=') for x in scope['query_string'].decode().split("&")))).get('token', None)
        except ValueError:
            token_key = None
        scope['user'] = AnonymousUser() if token_key is None else await get_user(token_key)
        return await super().__call__(scope, receive, send)

routing.py

from channels.security.websocket import AllowedHostsOriginValidator
from channels.routing import ProtocolTypeRouter, URLRouter
from .middleware import TokenAuthMiddleware
from main.consumers import MainConsumer
from django.conf.urls import url

application = ProtocolTypeRouter({
        'websocket': AllowedHostsOriginValidator(
            TokenAuthMiddleware(
                URLRouter(
                    [
                        url(r"^main/$", MainConsumer.as_asgi()),
                    ]
                )
            )
        )
    })
Ali Rn
  • 1,114
  • 8
  • 19
  • 1
    In my case I have to omit `AllowedHostsOriginValidator`. Django3.1 and channel3. – joe Sep 26 '21 at 11:36
  • 1
    Will this run only once on handshake? Or on each 'message' ? – Mazhar Ali Jan 20 '22 at 21:09
  • @MazharAli Only on handshake – Ali Rn Mar 05 '22 at 07:11
  • What if we want to fail the handshake if `Token.DoesNotExist`? Do we avoid catching the error? – Beast Jul 01 '22 at 12:56
  • Similar to a comment above, what do I have to do on the client/javascript side to establish this connection while sending the token? – Adam Jul 01 '22 at 18:04
  • @Adam you can close the connection with specific `close_code` on `connect()` if `self.scope['user']` is Anonymous – Ali Rn Jul 02 '22 at 06:10
  • @AliRn but how do we make the request from the client side (more specifically using Javascript) so that we can establish the connection while sending the token? In other words, is it something like, `const socket = new Websocket("ws://URL/?token=TOKEN")`? – Adam Jul 02 '22 at 21:36
  • 1
    @Adam yes, and you can customize the `token` key – Ali Rn Jul 03 '22 at 04:52
  • @AliRn thank you. So in your example, if we wanted to send a token as a query string (assuming this is on `localhost`), the request would be to "ws://localhost:8000/main/?token=TOKEN")? If so, as per your example, we retrieve said token in `TokenAuthMiddleware` as `scope['query_string']`? – Adam Jul 03 '22 at 16:13
  • @Adam dude try it and print them you understand it better in that way. – Ali Rn Jul 03 '22 at 17:11
  • @AliRn thank you so much! What you have suggested works. Just one last question: is there a way to get all the connected users from the `consumers`? – Adam Jul 04 '22 at 10:33
  • @AliRn I've posted a new question here: https://stackoverflow.com/questions/72858187/list-of-connected-users-in-a-room-with-django-channels – Adam Jul 04 '22 at 14:24
  • 1
    Could this answer be improved by avoiding `@database_sync_to_async` by using the [Async ORM](https://docs.djangoproject.com/en/4.2/releases/4.1/#asynchronous-orm-interface) interface in Django 4.1+? – phoenix Apr 08 '23 at 23:09
12

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.

ThaJay
  • 1,758
  • 1
  • 19
  • 30
  • What is the `get_group` function doing? Could you show a sample of your models if that would elp. Thanks – Ycon Apr 15 '17 at 01:58
  • Here it is, I made the example more complete. It's just some basic string manipulation. – ThaJay Apr 18 '17 at 12:58
  • I've been at this for quite a while and for the life of me I can't get it working. Did you have success with this? – ergusto May 30 '17 at 18:48
  • Yes for me it works just like the example. I noticed `get_id` was not in there yet so I added it as well. It just returns the group id from group name. Are you able to use those same tokens to authenticate in other ways? (not through websockets) What is your problem specifically? – ThaJay Jun 01 '17 at 09:38
  • @ThaJay I'm getting a user on connect but not receive. The token doesn't seem to be getting picked up on receive, but the function is being run. – ergusto Jun 02 '17 at 10:02
  • There can only be a token on connect, that is the only time in a websocket lifecycle you use a normal HTTP request (so it has an url which to you have added the token querystring). Once the connection has been established you only have `message` and auth is done. I will add a message and disconnect handler to my example. No need to check auth again each message. – ThaJay Jun 06 '17 at 10:55
  • Sorry, I thought that was already in the example. Have added it now but it's just like in the channels docs. Thanks for pointing it out. – ThaJay Jul 17 '17 at 15:33
  • @ThaJay Hi! I too am using django-rest-framework but with your solution I am getting `message.user` as `None`. Could you please have a look into my [SO question](https://stackoverflow.com/questions/46230340/how-to-authenticate-a-user-in-websocket-connection-in-django-channels-when-using) – Robin Sep 15 '17 at 02:29
  • @Robin Your use case looks a lot like mine. We do not use jwt though. but just 'rest_framework.authtoken' and 'rest_framework_expiring_authtoken'. I don't know if that would make a difference but it's an obvious change compared to our project. – ThaJay Sep 15 '17 at 13:47
  • I can advise you to read the mixin, perhaps the auth process works a bit different. – ThaJay Sep 15 '17 at 13:55
  • 1
    Just highlight the importance of the line 47 of leonardoo's mixin: request.META["HTTP_AUTHORIZATION"] = "token {}".format(auth_token) In my case the token auth header is "Bearer", not "token" so I just had to change it to: request.META["HTTP_AUTHORIZATION"] = "Bearer {}".format(auth_token) – Daniel G.F. Apr 30 '18 at 10:08
11

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))

nak
  • 690
  • 6
  • 15
  • I am using this same answer but ```self.scope['user']``` in the Consumer is still returning ```AnonymousUser``` any help would be highly appreciated. – Mwibutsa Floribert Jul 24 '20 at 12:58
1

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.

  • This is not true. I upvoted incorrectly but I can't change it any more. The only security risk is the users browser history, but there is no mention of a browser in the question, because this case is about a mobile app and even if it were in a browser, you don't visit the websocket url, it would be generated and requested in js so it would probably not even show up in the history anyway. https://stackoverflow.com/questions/499591/are-https-urls-encrypted – ThaJay Apr 24 '18 at 14:04
  • These urls will show up in man-in-the-middle attacks or proxy-server scenarios. Tokens will show up in access logs of such proxy servers – Vishal Pathak Apr 24 '18 at 14:50
  • The receiving webserver needs the URL of course, and if there is a man in the middle, all data is compromised. But we use ssl; both https and wss. So in case of a proxy there will be a secure tunnel through the proxy, unrelated to the connection to the proxy itself. Host names will be visible of course, but we don't care about that. – ThaJay Apr 24 '18 at 16:32
  • 2
    Yes, thanks for updating me. Its not a security issue. Initially deleted this solution but keeping it for now as another way around. – Vishal Pathak Apr 29 '18 at 13:30
  • It could be a security issue since URLs could be logged on proxies. https://stackoverflow.com/a/52749848/11249006 – Jonas Freire Jul 04 '22 at 20:54
  • @JonasFreire Interesting share. I agree the querystring solution is not ideal and I would not advise it for high security solutions like for instance banking. In those critical cases an autorefresh token like mentioned here and also on your link would indeed improve the situation. But the actual problem here in my opinion is the ws upgrade request not supporting authentication headers in all cases. It would be best if that were addressed. – ThaJay Jun 02 '23 at 17:55
1
from rest_framework_simplejwt.tokens import UntypedToken
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from jwt import decode as jwt_decode
from urllib.parse import parse_qs
from django.contrib.auth import get_user_model
from channels.db import database_sync_to_async
from django.conf import settings


@database_sync_to_async
def get_user(user_id):
    User = get_user_model()
    try:
        return User.objects.get(id=user_id)
    except User.DoesNotExist:
        return 'AnonymousUser'


class TokenAuthMiddleware:

    def __init__(self, app):
        # Store the ASGI application we were passed
        self.app = app

    async def __call__(self, scope, receive, send):
        # Look up user from query string (you should also do things like
        # checking if it is a valid user ID, or if scope["user"] is already
        # populated).

        token = parse_qs(scope["query_string"].decode("utf8"))["token"][0]
        print(token)
        try:
            # This will automatically validate the token and raise an error if token is invalid
            is_valid = UntypedToken(token)
        except (InvalidToken, TokenError) as e:
            # Token is invalid
            print(e)
            return None
        else:
            #  Then token is valid, decode it
            decoded_data = jwt_decode(token, settings.SECRET_KEY, algorithms=["HS256"])
            print(decoded_data)

            scope['user'] = await get_user(int(decoded_data.get('user_id', None)))

            # Return the inner application directly and let it run everything else

        return await self.app(scope, receive, send) 

Asgi like this

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from django.urls import path

from channelsAPI.routing import websocket_urlpatterns
from channelsAPI.token_auth import TokenAuthMiddleware

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'VirtualCurruncy.settings')

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": TokenAuthMiddleware(
        URLRouter([
            path("virtualcoin/", websocket_urlpatterns),
        ])
    ),
})
1

ovveride custom AuthMiddleware

from urllib.parse import parse_qs

from channels.auth import AuthMiddleware
from channels.db import database_sync_to_async
from channels.sessions import CookieMiddleware, SessionMiddleware

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


@database_sync_to_async
def get_user(scope):
    query_string = parse_qs(scope['query_string'].decode())
    token = query_string.get('token')
    if not token:
        return AnonymousUser()
    try:
        user = Token.objects.get(key=token[0]).user

    except Exception as exception:
        return AnonymousUser()
    if not user.is_active:
        return AnonymousUser()
    return user


class TokenAuthMiddleware(AuthMiddleware):
    async def resolve_scope(self, scope):
        scope['user']._wrapped = await get_user(scope)


def TokenAuthMiddlewareStack(inner):
    return CookieMiddleware(SessionMiddleware(TokenAuthMiddleware(inner)))

import the TokenAuthMiddlewareStack middleware in asgi.py

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application

from chat.api.router_ws import urlpatterns_websocket
from .middleware import TokenAuthMiddlewareStack

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.

application = ProtocolTypeRouter({
  "http": get_asgi_application(),
  "websocket": AllowedHostsOriginValidator(
        TokenAuthMiddlewareStack(
            URLRouter(urlpatterns_websocket)
        )
    ),
})

In frontend:new WebSocket(ws://8000/{your_path}?token=${localStorage.getItem('token')})

In Consumer: you can access the requested user as self.scope["user"]

SaimumIslam27
  • 971
  • 1
  • 8
  • 14
1

channels-auth-token-middlewares provides a QueryStringSimpleJWTAuthTokenMiddleware to support token authentication out of the box when using Simple JWT with Django REST Framework.

Update INSTALLED_APPS:

INSTALLED_APPS = [
    # base django apps (django.contrib.auth is required)
    # other apps this one depends on (like rest_framework if it's necessary)
    'channels_auth_token_middlewares',
    # custom apps
]

Insert QueryStringSimpleJWTAuthTokenMiddleware into your ASGI application stack:

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AllowedHostsOriginValidator(
            QueryStringSimpleJWTAuthTokenMiddleware(
                URLRouter(...),
            ),
        ),
    }
)

Clients pass their JWT token into the token query parameter:

from websocket import create_connection

token = "EXAMPLE_TOKEN"
ws = create_connection(f"ws://127.0.0.1/ws/?token={token}")

The authenticated User (or AnonymousUser if the JWT is invalid) will be populated into the "user" key of the scope passed to the Consumer

class MyAsyncCommunicator(AsyncWebsocketConsumer):
    async def connect(self) -> None:
        user = self.scope["user"]
        # Validate user before accepting the Websocket Connection
        # For example:
        if not user.is_authenticated or user.is_anonymous:
            # Handle unauthorized.
phoenix
  • 7,988
  • 6
  • 39
  • 45
0

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
Lukas E.
  • 99
  • 1
  • 6