Day 2 : Save history of chat messages with Django and Django channel

Create a chat application with Ionic and Django – Series – Part two

Day 2 : Save history of chat messages with Django and Django channel

In the previous tutorial, we saw how to setup a Django project, and create models to manage a Chat application. Then we saw how to implement Django channel to have the foundation of our Chat backend server with Django.

In this tutorial, we will use our Chat and Message models to save our chats history, then we will add authentication to secure our application and we will also add an API with Django Rest Framework to allow our Ionic mobile application interacts with our backend.

Using our Django models with Django channel to implement a chat

In previous journey, we saw that we can create and access a chat using any string for our chat name, just by accessing an url like these :

http://127.0.0.1:8000/chat/lobby/
http://127.0.0.1:8000/chat/toto/
http://127.0.0.1:8000/chat/titi/
...

Because in the routing.py file we just define a path like this:

websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer.as_asgi()),
]

Now we will secure our channel and allow access only to existing Chat. To do that while etablishing the chat connexion, we will check that the chat identifier exists otherwise the connexion will be rejected.

To do so, we will modify our consumers.py file like this:

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        try:
            self.room_name = self.scope['url_route']['kwargs']['room_name']
            chat = Chat.objects.get(id=self.room_name)
            self.room_group_name = 'chat_%s' % str(chat.id)
            # Join room group
            async_to_sync(self.channel_layer.group_add)(
                self.room_group_name,
                self.channel_name
            )

            self.accept()
        except Chat.DoesNotExist as e:
            print(e)
            return

Nothing really difficult. We get the Chat id from the url path and we check in our Chat model if we have one with such id. If so we can etablish a Chat connexion otherwise an exception is thrown and we return without etablishing connexion.

The Chat id is an UDID field (like any ID in our models)

  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

So security is even stronger because it will not be a "guessable" id such as usual integers (1,2…)

Finally our last step is to modify the routing.py url pattern regex to match UDID format:

websocket_urlpatterns = [
    re_path('ws/chat/(?P<room_name>[-a-zA-Z0-9_]+)/', consumers.ChatConsumer.as_asgi()),
]

And voilà, now if you try to access any url like previous one, it will fails.

To test our modifications, we can use the Django Admin to create a chat, grab the ID and then access our url with this id.

To create a Chat, we need two users. We already created a superuser so we can create a new one, or use the admin to create Users. To do so, just register the User model in the admin.py file :

admin.site.register(User)

Saving messages to have chat history

Now we will modify our consumers.py file to save history of sent messages. To do so we will send a json message containing information required by our models.py such as the refChat, refUser, message, type and extraData

{
    refChat : refChat
    author : refUser
    message : message
    type : 0
    extraData : ""
}

So we first need to modify our room.html file to hardcode values (for now):

document.querySelector('#chat-message-submit').onclick = function(e) {
            const messageInputDom = document.querySelector('#chat-message-input');
            const message = messageInputDom.value;
            chatSocket.send(JSON.stringify({
                "message"': message,
                "refChat" : "53713a91-e936-4169-9fef-4bc870a0babc",
                "author" : "9052a080-24d8-4a0e-8d9a-742213c0bf91",
                "type" : 0,
                "extraData" : ""
            }));
            messageInputDom.value = '';
        };

I have hardcoded the refChat and author with values that i got from the admin/databases. You need to adapt with your own values.

Now we can modify the receive method of our consumers.py:

def receive(self, text_data):
    data_json = json.loads(text_data)
    print("===Received")
    print(data_json)

    message = data_json['message']
    # save message to database
    self.new_message(data_json)
    # Send message to room group
    async_to_sync(self.channel_layer.group_send)(
        self.room_group_name,
        {
            'type': 'chat_message',
            'message': message
        }
    )

The method expects a JSON, grabs the message from the JSON and transmit to the room as usual, but before that it call a new_message function to save values to database. Here is the code of the new_message function:

def new_message(self,data):
    message = Message()
    message.refChat_id = data["refChat"]
    message.message = data["message"]
    message.author_id = data["author"]
    message.isRead = False
    message.type = data["type"]
    message.extraData = data["extraData"]
    message.save()

Now you can try as usual the chat room to send a message : http://127.0.0.1:8000/chat/53713a91-e936-4169-9fef-4bc870a0babc/

Don’t forget to replace the chatId in the url with your own value

After that go to the Django Admin and check that a value exists in your Message model

Admin

Super easy. We will learn later how to use the type and extraData parameters and how to not hardcoded refChat and author while developping the real Ionic application.

Next step, we will secure our Chat to avoid anonymous user connexion.

Secure our chat

To secure our chat, users will need to be authenticated. To manage this easily, we will install the Django Channels Jwt Auth Middleware library. I choose this library because in our Ionic application, users will have to authenticate and we will use JWT token authentication.
First we need to install the library by adding it in our requirements.txt file:

Django==3.1.7
pillow==8.0.1
psycopg2==2.8.5 --no-binary psycopg2
channels==3.0.3
channels-redis==3.2.0
django-channels-jwt-auth-middleware==1.0.0

or install it manually :

pip install django-channels-jwt-auth-middleware==1.0.0

And then we need to modify our asgi.py file to add a JWTAuthMiddlewareStack

# chattuto/asgi.py
import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
import chat.routing
from django_channels_jwt_auth_middleware.auth import JWTAuthMiddlewareStack

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chattuto.settings")

application = ProtocolTypeRouter({
  "http": get_asgi_application(),
  "websocket": JWTAuthMiddlewareStack(
        URLRouter(
            chat.routing.websocket_urlpatterns
        )
    ),
})

Now we will modify our consumers.py file to reject each unauthenticated connexion:

def connect(self):
    try:
        user = self.scope['user']
        print("=== user %s" % user)
        if str(user)=="AnonymousUser":
            print("==Not authorized")
            return
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        chat = Chat.objects.get(id=self.room_name)
        self.room_group_name = 'chat_%s' % str(chat.id)
        # Join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )

        self.accept()
    except Chat.DoesNotExist as e:
        print(e)
        return

If a user is unauthenticated the library provides an AnonymousUser value so we just need to check the value of our user and reject the connexion if it equals.

Now if you try to access the chat room, you will see in the console logs, that an error occurs and that the connexion is closed.

Implementing an API with Dango Rest Framework

At this step of the tutorial, we need to implement the JWT token authentication to be able to access and use our chat room, but we also want to manipulate the Chat and Message models to do C.R.U.D operations (Create, Read, Update, Delete). It’s time to implement an API for that.

This API will be the link between our Django backend and our Ionic application to communicate.

As a reminder JWT (Json Web Token) is an authentication system based on secured token. To obtain a token, the user needs to provide his credentials. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token.

Django Simple JWT is a simple package to manage JWT authentication with Django.

Django DJOSER is another package with more features and which include Django Simple JWT. So we will use this package.

Django Rest Framework is a powerful and flexible toolkit for building Web APIs.

Let’s add them to our requirements.txt

djoser==2.1.0
djangorestframework_simplejwt==4.6.0
djangorestframework==3.12.2

Then we need to modify our settings.py and INSTALLED_APPS dictionnary to list them:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'channels',
    'chat',
    'rest_framework',
    'djoser',
]

And add new routes to our urls.py file :

from django.conf.urls import include
from django.urls import path
from django.contrib import admin

urlpatterns = [
    path('chat/', include('chat.urls')),
    path('admin/', admin.site.urls),
    path('auth/', include('djoser.urls')),
    path('auth/', include('djoser.urls.jwt')),
]

Ok now with all that in place, we can use Django DJOSER to get our JWT token. Available endpoints can be found in the documentation but we will focus on the:

  • /jwt/create/ (JSON Web Token Authentication)
    • Use to authenticate and obtain a JWT TOKEN
  • /jwt/refresh/ (JSON Web Token Authentication)
    • Use to refresh an expired JWT Token
  • /jwt/verify/ (JSON Web Token Authentication)
    • Use to verify if a JWT Token is still valid

JWT Tokens expires very quickly (5 minutes). It’s possible to modify some configuration values in the settings.py

from datetime import timedelta

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=180),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,

    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
    'VERIFYING_KEY': None,
    'AUDIENCE': None,
    'ISSUER': None,

    'AUTH_HEADER_TYPES': ('Bearer',),
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',

    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',

    'JTI_CLAIM': 'jti',

    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=30),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=180),
}

Ok now let’s try to authenticate a User. We can try with one of the superuser we created earlier.

curl -X POST \
  http://127.0.0.1:8000/auth/jwt/create/ \
  -H 'Content-Type: application/json' \
  -d '{"email": "csurbier@idevotion.fr", "password": "YOURPASSWORD"}'

Don’t forger to replace with your user and password values

And you should get a JSON response:

{
"refresh":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYzMTUyMDg2MSwianRpIjoiNzc0Mjk1Y2I4OTA1NDZlMzkyM2Y0OWI1ZGVhZTNmM2EiLCJ1c2VyX2lkIjoiOTA1MmEwODAtMjRkOC00YTBlLThkOWEtNzQyMjEzYzBiZjkxIn0.lwvDFS-2CR_xKHBPe4rvPnT_IKVac1oVtTaM-Abuo2I",
"access":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjE1OTcwNjYxLCJqdGkiOiJjNGQ4ZTkzZjhhZjM0MzcxODMxYWM1MDNkZGJjMjk5NiIsInVzZXJfaWQiOiI5MDUyYTA4MC0yNGQ4LTRhMGUtOGQ5YS03NDIyMTNjMGJmOTEifQ.__IVxioV8RWahcdpC6GLeEUuk52r-UZ4zFoNgDoCzPM"
}

The response contains the access token (our JWT token) and the refresh token to use when the access token expires.

If credentials are not correct or if the TOKEN has expired, the endpoint will return an HTTP 401 response (unauthorised).

To get a new token just use the refresh token

curl -X POST \
  http://127.0.0.1:8000/auth/jwt/refresh/ \
  -H 'Content-Type: application/json' \
  -d '{"refresh": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYzMTUyMDg2MSwianRpIjoiNzc0Mjk1Y2I4OTA1NDZlMzkyM2Y0OWI1ZGVhZTNmM2EiLCJ1c2VyX2lkIjoiOTA1MmEwODAtMjRkOC00YTBlLThkOWEtNzQyMjEzYzBiZjkxIn0.lwvDFS-2CR_xKHBPe4rvPnT_IKVac1oVtTaM-Abuo2I"}'

In next tutorial, we will continue to implement our Django Rest Framework API to have all required stuff to be able to start our Ionic application.

Questions / Answers

  1. How to avoid having integers as primary keys in a model ?

    Use an UDID by implement this line of code in the model

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
  2. What is the use of JWT for ?

    JSON WEB TOKEN is used to secure API

  3. What is an access token ?

    An access token is obtained with user credentials and is used on each request to access resources.

  4. What is the use of a refresh token ?

    An access token is expiring after some delay. A Refresh token is used to get a new fresh access token

Christophe Surbier