Channels: Authentication with token but no Authorization header

Created on 26 Jan 2017  路  30Comments  路  Source: django/channels

We use react-native so this issue happens in JavaScript, but on a mobile device (either Android or iOS), not in a browser.

When I open a new websocket connection in a browser tab where I am logged in to our website, the browser adds a bunch of headers to the request, including cookie with my auto login token. In this case @channel_session_user_from_http picks up on my user and authenticates.

We use django-rest-framework with rest_framework.authentication.TokenAuthentication in the app.

When I try and open a websocket from my react-native app there is no session. As far as I have been able to find out I also have no way of manually setting headers on the 'upgrade' request that initiates the websocket connection after instantiating with new Websocket(). For each XMLHttpRequest I set an 'Authorization' header as follows:
request.setRequestHeader('Authorization',Token ${authToken})

I found in the docs we are able to use the querystring session_key= like so:
socket = new WebSocket("ws://127.0.0.1:9000/?session_key=abcdefg");
But I only have the auth token in our app, and that did not seem to work here, while the token did get eaten before ws_connect gets called from websocket.connect.

I'm running Daphne with one worker to test this, but I did set up nginx to pass everything to Daphne. Everything I do in the browser seems to work, like all normal http and using websockets, because the browser handles auth once I am logged in.

The error I get in the worker is:
AttributeError: 'AnonymousUser' object has no attribute 'get_events_with_edit_permissions'
so the actual error is Django has no way of finding out which user I am from the connect request.
I get the following message['headers']: (this was without using session_key)

[   [b'upgrade', b'websocket'],
    [b'x-forwarded-for', b'46.129.153.456'],
    [b'user-agent', b'okhttp/3.4.1'],
    [b'x-real-ip', b'46.129.153.456'],
    [b'origin', b'https://www.chipta.com/'],
    [b'host', b'www.chipta.com'],
    [b'sec-websocket-version', b'13'],
    [b'sec-websocket-key', b'8XSr4kJslkaVBam74sYKXOg=='],
    [b'connection', b'upgrade'],
    [b'x-forwarded-proto', b'https'],
    [b'accept-encoding', b'gzip']
]

When I connect from the browser I get a lot more: (and my user is known, so channel_session_user_from_http works)

[   [b'upgrade', b'websocket'],
    [b'x-forwarded-for', b'46.129.153.456'],
    [   b'accept',
        b'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'],
    [b'accept-encoding', b'gzip, deflate, br'],
    [   b'user-agent',
        b'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:50.0) Gecko/20100101 Firefox/50.0'],
    [b'sec-websocket-version', b'13'],
    [b'connection', b'upgrade'],
    [b'sec-websocket-extensions', b'permessage-deflate'],
    [b'accept-language', b'en-US,en;q=0.5'],
    [b'x-real-ip', b'46.129.153.456'],
    [b'pragma', b'no-cache'],
    [b'origin', b'https://www.chipta.com'],
    [b'host', b'www.chipta.com'],
    [b'sec-websocket-key', b'gnjFTRgXZqbLLbtArAy/2w=='],
    [b'x-forwarded-proto', b'https'],
    [   b'cookie',
        b'_ga=GA1.2.1566485573.1481795145; _pk_id.1.5643=7983629406bf1d80.1481807462.1.1465807462.1481832162.; fbm_300453640081362=base_domain=.chipta.com; returningVisitor=1; fblo_300457561081362=y; csrftoken=TZmxiC7iIJuIpG01tVBWvIpdGSzkt0M5oYYflvP0R2XbGSNyotu3PxulZdKxR2je; chipta-name-change-popup-seen=1; sessionid=w6co0t54sddct0dajos9htrbaprkunl15; PHPSESSID=qa7toehr7hxogu6sog485ksq20'],
    [b'cache-control', b'no-cache']]

So my question is, is there support for token authentication? If so, where can I find the docs and otherwise, my issue is the lack thereof.

How should we move forward from here?

Most helpful comment

channels dont support token auth, but i create one mixin for this

https://gist.github.com/leonardoo/9574251b3c7eefccd84fc38905110ce4

All 30 comments

channels dont support token auth, but i create one mixin for this

https://gist.github.com/leonardoo/9574251b3c7eefccd84fc38905110ce4

That's awesome. But does it use Django token or rest framework token?
I just added @rest_token_user to my connect function and it doesn't seem to pick up on it yet.

Perhaps I have to ditch rest_framework_auth_token and use our own token model (I will have to do that at some point anyway). That means changing the /auth-token view for obtaining the token but then I could also just call our own authenticate instead of using a fake request like in the mixins.

@ThaJay i use a django rest token and get the token from the url send in the ws "ws://localhost:8000/?token=token" and the HTTP_AUTHORIZATION toke is in lower case, maybe is the problem

excuse me, I have been at this problem too long and forgot to pull on the server.
I have a user! Indeed using www.website.com/socket/group?token=<auth_token>

You are the man!

This is nice but what if I need to enable both HTTP session authorization and if there is no session (mobile app) use token if there is one and if not use AnonymousUser. I tried to edit mixin from @leonardoo but I am not successful. I can assign authenticated user at the end of the inner function to message.user but I cannot see it in receive function. I have suspicion that user_from_http overrides it and I am unable to find out how to run my code after it.

I tried that and it does not seem to work with both. It seemed indeed like they override each other. We thought about changing the mixin but we don't need it right now so we left it.
the HTTP session auth mixin seems to give AnonymousUser without session but the token mixin gives noneType.

I think it's possible to make it work with a few changes to the token mixin and putting it after the http mixin.

I just solve it by storing the Token in channel_session and then on every message if the user is unauthenticated and there is saved Token in session - log the user in.
It is not nice (perform authenticate on every message) but it's working. If anybody has some better solution I would love to hear it. I'll have to fix it anyway once we go to production (probably in a few months).

I'm trying to secure my socket, so only users with a token can access the data.

I tried replacing the @channel_session with @rest_token_user, without success. How am I supposed to set it up?

Here is my consumers.py

from socketauth import rest_token_user
import json

@rest_token_user
def ws_connect(message):
    prefix, label = message['path'].strip('/').split('/')
    room = Room.objects.get(label=label)
    chathistory = room.messages.all().order_by('timestamp')
    Group('chat-' + label).add(message.reply_channel)
    message.rest_token_user['room'] = room.label
    message.reply_channel.send({'text': json.dumps(list(chathistory.values('pk', 'handle', 'message')))})

@rest_token_user
def ws_receive(message):
    label = message.rest_token_user['room']
    room = Room.objects.get(label=label)
    data = json.loads(message['text'])
    m = room.messages.create(handle=data['handle'], message=data['message'])
    Group('chat-'+label).send({'text': json.dumps([m.as_dict()])})

@channel_session
def ws_disconnect(message):
    # import pdb; pdb.set_trace()
    label = message.channel_session['room']
    Group('chat-'+label).discard(message.reply_channel)

I can't help with @rest_token_user I'm afraid, it's not part of Channels.

@andrewgodwin

No worries.

But do you have a solution for securing a socket? I wish to only allow users with token to access it. Can I setup a view which routes to a socket that requires a token?

You can have users login, and then open the socket. Or, if it's mobile, let them open the socket, and if the token isn't valid, have the server close the socket.

How do I set that up? Where do I do the token check?

You can do all auth checks in a websocket.connect consumer. Until you either send/accept from that consumer, the socket is held in handshaking state; if you send {"close": True} back from that consumer, the socket will be rejected during handshake and never opened.

This means, in that consumer, you can take the token passed in using a URL path or header (as you have access to them there), do a check, then either accept or reject the connection.

I dont' quite follow. Could you provide or link me to an example?

It's in the docs. http://channels.readthedocs.io/en/stable/getting-started.html

You need to explicitly accept WebSocket connections if you override connect by sending accept: True - you can also reject them at connection time, before they open, by sending close: True.

So, to reject the connection:

message.reply_channel.send({"close": True})

Thanks, I'm trying but not having any success with it. From the example, it looks like you can just use user.username to get the value.

However, this doesn't seem right. It blocks all connections- even if the user is logged in and has a token. Do I need to use requests to import the token sent from the user via the HTTP header?

For my case, I'm wanting to use the same JWT token the user gets when they log in. Can I use JWT tokens?

I tried doing message.user.username which just returned "". Is this the user I'm sending from the app?

@channel_session_user_from_http
def ws_connect(message):
    # message.reply_channel.send({"close": True})
    print(user.username)
    prefix, label = message['path'].strip('/').split('/')
    room = Room.objects.get(label=label)
    chathistory = room.messages.all().order_by('timestamp')
    Group('chat-' + label).add(message.reply_channel)
    message.channel_session['room'] = room.label
    message.reply_channel.send({'text': json.dumps(list(chathistory.values('pk', 'handle', 'message')))})

The @channel_session_user_from_http decorator only works if you're using standard Django session cookies, not JWT tokens. If you're doing your own token stuff, you'll need to decode and check it yourself in the ws_connect method you have there, and then decide if they should be allowed or not.

The decorator is only needed on ws_connect:

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

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

we're using django-rest-framework and the token is received in the app via an earlier authentication request to their /auth-token view.

just add token= in the querystring like so:
wss://www.website.com/socket/group?token=65asd4f32a4sdf34asf3as54d
and the decorator will pick up on it.

If you're not using django-rest-framework you can consume the querystring in your own way. Read the rest_token_user decorator for how to get it.

@ThaJay Thanks. I am using Django Rest Framework, so this should work

Where do I import @rest_token_user from? I keep getting the import error

you have to save the mixin @leonardoo shared in your own project. It is not channels.

What do you mean by 'save the mixin'? I've added it to my project, then in consumers.py I've done from auth_leonardoo import rest_token_user.

If I put a print statement on line 62 of your mixin print(auth_token) I can see the token. However, how do I access it in my consumers (ie in the code below)?

However, it keeps telling me 'Message' object has no attribute 'channel_session'. Any ideas? Here's my code:

@rest_token_user
def ws_connect(message):
    prefix, label = message['path'].strip('/').split('/')
    room = Room.objects.get(label=label)
    chathistory = room.messages.all().order_by('timestamp')
    Group('chat-' + label).add(message.reply_channel)
    message.channel_session['room'] = room.label
    message.reply_channel.send({'text': json.dumps([msg.as_dict() for msg in chathistory.all()])})

@channel_session
def ws_receive(message):
    label = message.channel_session['room']
    room = Room.objects.get(label=label)
    data = json.loads(message['text'])
    m = room.messages.create(handle=data['handle'], message=data['message'])
    Group('chat-'+label).send({'text': json.dumps([m.as_dict()])})

@channel_session
def ws_disconnect(message):
    # import pdb; pdb.set_trace()
    label = message.channel_session['room']
    Group('chat-'+label).discard(message.reply_channel)

Also, how am I meant to send the information from the frontend?

After using the mixin, and the correct token is used with the upgrade / connect request, the message will have a user so we do this:

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

Sorry, I should have included it in my previous example.
As you can see, we have has_permission() implemented on the User model, so it can just check it's instance.

If there is no token or the token is invalid, there will be no user on the message.

Maybe this should be on stackoverflow though.
edit: I just made it so (click)

So the ws_connect checks IF the users token is valid. If is is, it goes to the next function connect_app?

But I'm still unclear on what the get_group or get_group_category is doing. I keep getting the error get_group is not defined. FWIW, my models are as follows:

class Room(models.Model):
    name = models.TextField()
    label = models.SlugField(unique=True)
    def __str__(self):
        return self.label

class Message(models.Model):
    room = models.ForeignKey(Room, related_name='messages')
    handle = models.TextField()
    message = models.TextField()
    timestamp = models.DateTimeField(default=timezone.now, db_index=True)

it's just some string manipulation with the url you get on message. I added it to the stackoverflow answer.

@ThaJay I spent a few hours trying to understand your paste in StackOverflow, but I couldn't interpret it to work with my models... I do appreciate your help on this. Here's what I've attempted (and failed) at. Also please specify what the URL should be sending. Thanks,

https://dpaste.de/9wjj

We use the URL wss://www.website.com/ws/app_1234 That is why I remove ws/ from message.content['path']. There is a feature in Daphne to do that as well but it works this way so I left it for now.

Then we get the first part 'app' because that is not the only thing using websockets. If the connection is for 'app' we check permissions and if granted we add the connection the the group of connections for that id (let's call it room number).

Are you using print statements or a python shell to test your code and read message.content?
prettyprint is really good for printing objects to the console.

You can add logging to the auth_token to see what internal behavior actually does.

I think you forgot to add the connection to the group you want it in. Or do you only want the reply channel?

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

Just a question

Did someone got this working with data bindings/ multiplexers? I am working for now with decorators wrapping the connect method of the demultiplexer

def oauth2_demultiplexer_connect(func):
    """
    Checks the presence of a "access_token" request parameter and tries to
    authenticate the user based on its content.
    """

    @wraps(func)
    def inner(multiplexer, message, *args, **kwargs):
        # Taken from channels.session.http_session
        try:
            if "method" not in message.content:
                message.content['method'] = "FAKE"
            request = AsgiRequest(message)
        except Exception as e:
            raise ValueError("Cannot parse HTTP message - are you sure this is a HTTP consumer? %s" % e)

        token = request.GET.get("access_token", None)
        if token:
            try:
                user = AccessToken.objects.get(token=token).user
                message.token = token
                message.user = user
                print("Message user is", message.user)

            except Exception as exec:
                print(exec)
        else:
            raise ValueError("No token was found")

        return func(multiplexer, message, *args, **kwargs)

    return inner

but i don't think its the smartest way

I would like to have it more like extending the consumer class provided by the binding with my custom implementation for JWT/OAuth support.

consumers = {
        "aaaa": extend(RestTokenMixin, ABinding.consumer),
        "bbbb": extend(RestTokenMixin, BBinding.consumer)
    }

Does that sound like a proper solution?

Just in case anyone is still confused wit this, I found this seems to work well:

def connect(self):
        if str(self.scope["user"]) == "AnonymousUser":
            params = urllib.parse.parse_qs(self.scope['query_string'].decode('utf8'))
            token = params.get('token', (None,))[0]
            self.scope["user"] = Token.objects.get(key=token).user

        #Now continue with the normal flow
        user = self.scope["user"]

Only thing I think to consider is that this will leave the token in your webserver log files, so if that became compromised, all your tokens could be stolen. Perhaps worth sanitising those logs as they go in (something like if url contains token= remove everything after that from the log)

And obviously https is a must.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

davidfstr picture davidfstr  路  18Comments

dgilge picture dgilge  路  30Comments

Ya2s picture Ya2s  路  22Comments

djangojack picture djangojack  路  19Comments

agateblue picture agateblue  路  29Comments