Deploying a Django App on Okteto

Image of Deploying a Django App on Okteto

An RBAS system helps manage users and prevents certain information from being exposed to everyone. It also ensures that the actions users can perform can be grouped based on roles. In this article, we will be building and deploying a Django Role-based Authorization Service(RBAS) API on Okteto.

What is Okteto ?

Okteto is a developer platform powered by Kubernetes that enables anyone to develop and deploy cloud-native applications.

What is Django?

Django is a high-level Python Web framework very well suited for building APIs. Django comes with a lot of out of the box functionality, making your development faster and easier.

Prerequisites

  • Knowledge of building APIs
  • Basic Knowledge of Django
  • Basic Knowledge of Cloud Infrastructure
  • Python 3.7+ installed

Scaffold Project

To start building, we will first create and activate a virtual environment for our project:

1
2
$ python3 -m venv venv
$ source venv/bin/activate

Next, we install Django:

1
(venv)$ pip install django

With Django installed, go ahead and run the django-admin command. This command from Django helps bootstrap your project, creating essential directories and files to get started.

1
(venv)$ django-admin startproject simple_rbas

Navigate into the new project created:

1
(venv)$ cd simple_rbas

Create a requirements.text file in the root folder and add the following packages:

1
2
3
4
5
django
djangorestframework
django-environ
psycopg2-binary
gunicorn

Install the packages using the pip command:

1
(venv)$ pip install -r requirements.txt 

The django-environ package is used to connect to .env files, psycopg2-binary is used to connect Django to a Postgres database, and gunicorn is used in running our server for development and production.

Verify that the application has been successfully created by running the command:

1
(venv)$ python manage.py runserver

Development

In this section, we will be building up our model, serializers, permissions for our views, and routing to our views. We will also be setting up our Dockerfile, and Okteto stack manifest file for our project.

Setting Up Project

To get started with building our app, we’ll create an app inside our project. Run this command inside your project directory:

1
(venv)$ python manage.py startapp users

This command creates a new directory named users in your project directory, looking like this:

1
2
3
4
5
6
7
8
9
├──users
├── migrations
├── __init__.py
├── __init__.py
├── admin.py
├── apps.py
├── models.py
├── tests.py
├── views.py

For our authentication, we will be using http-only cookies and not localStorage, to set our authentication. It is recommended to use http-only-cookies to reduce the risk of haivng security issues.

We will be creating an authentication middleware for the project. Django provides us with middleware for authenticating users, but we will override and create our middleware because we will be using cookies. Create a new file backends.py and paste the code below into it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from django.middleware.csrf import CsrfViewMiddleware
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.conf import settings
from django.urls import reverse
from django.utils.encoding import force_text, force_bytes
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.contrib.auth import get_user_model
from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication
from jose import JWTError, jwt

User = get_user_model()
ENCRYPTION_ALGORITHM = "HS256"

def generate_jwt_token(data, min_to_expire=None):
"""
Generates and returns account token with expiration of min_to_expire minutes
:param data:
:param min_to_expire:
Returns:
"""
payload = data.copy()
if min_to_expire is None:
min_to_expire = 15
expire = datetime.utcnow() + timedelta(minutes=min_to_expire)
payload.update({"exp": expire})
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=ENCRYPTION_ALGORITHM)
return token

def decrypt_jwt_token(token):
"""
Decrypts authentication token and returns content
:param token:
:return:
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY)
except JWTError:
payload = None
return payload

class CSRFCheck(CsrfViewMiddleware):
def _reject(self, request, reason):
"""
We are returning the failure reason instead of an HttpResponse
:param request:
:param reason:
:return:
"""
return reason

class SafeJWTAuthentication(BaseAuthentication):
"""
Custom Authentication Class that authenticates users from cookies (http-only)
instead of from Authorization key in headers
"""
def authenticate(self, request):
"""
Is required to be implemented (from BaseAuthentication)
:param request:
:return:
"""
authorization_cookie = request.COOKIES.get("Authorization") or request.headers.get("Authorization")
if not authorization_cookie:
return None
try:
access_token = authorization_cookie.split(" ")[1]
payload = decrypt_jwt_token(access_token)
except JWTError:
return None
if not payload:
return None
user = User.objects.filter(id=payload["user_id"]).first()
if user is None:
raise exceptions.AuthenticationFailed("User not found")
if not user.is_active:
raise exceptions.AuthenticationFailed("User is inactive")
self.enforce_csrf(request)
return user, None
def enforce_csrf(self, request):
"""
Enforces CSRF Validation
:param request:
:return:
"""
return

In the code block above, generate_jwt_token() is a function that the application will use in generating a jwt_token once the user has been authenticated. On the other hand, decrypt_jwt_token() is used in our authentication middleware and SafeJWTAuthentication is used to get the user data when a user accesses an endpoint.

Next, we will be adding our authentication middleware to our configuration.

In simple_rbas/settings.py, add the following changes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pathlib import Path

import environ

ROOT_DIR = (
environ.Path(__file__) - 2
)

# Build paths inside the project like this: BASE_DIR / 'subdir'.

BASE_DIR = Path(__file__).resolve().parent.parent

env = environ.Env(
# set casting, default value
DEBUG=(bool, False),
)

# Build paths inside the project like this: BASE_DIR / 'subdir'.

BASE_DIR = Path(__file__).resolve().parent.parent
READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=True)

if READ_DOT_ENV_FILE:

# OS environment variables take precedence over variables from .env
env.read_env(str(ROOT_DIR.path(".env")))

In the code block above, we are specifying the directory the .env file we will be creating.

Create a .env file in your root directory:

1
(venv)$ touch .env

Add the following to the .env file:

1
2
3
SECRET_KEY=fake-secret-key
DEBUG=True
COOKIE_SECURE=False

Replace fake-secret-key with the SECRET_KEY value in settings.py.

Our env keys will only be used for local development.

Change the SECRET_KEY and DEBUG variable in settings.py;

1
2
3
4
5
6
7
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env.str("SECRET_KEY")

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool("DEBUG")

ALLOWED_HOSTS = ['*']

Next, add the users app to the INSTALLED_APPS list:

1
2
3
4
5
6
7
8
9
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'users'
]

Change the DATABASE variable:

1
DATABASES = {"default": env.db("DATABASE_URL", default= "sqlite:////" + str(BASE_DIR) + '/db.sqlite3')}

Finally, add the following lines at the end of the file;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AUTH_USER_MODEL = "users.User"

REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
"users.backends.SafeJWTAuthentication",
# 'rest_framework.authentication.BasicAuthentication',
# 'rest_framework.authentication.SessionAuthentication',
],
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
"DEFAULT_FILTER_BACKENDS": (
"rest_framework.filters.SearchFilter",
),
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S.%fZ",
}

# COOKIE SECURE FLAG
COOKIE_SECURE = env.bool("COOKIE_SECURE")
COOKIE_TIME = 1800

In the code block above, we are configuring the Django rest framework settings, setting AUTH_USER_MODEL to the User class we will create in the users app, and adding COOKIE_TIME and COOKIE_SECURE to be false in local and true in our Docker production.

Setting Up Docker and Okteto

We will start by creating a bash script, run_web.sh, that creates our migration files, runs migrations, and then starts our server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/sh

# prepare init migration

python manage.py makemigrations myproject
echo "Created migrations"

# migrate db, so we have the latest db schema

python manage.py migrate
echo "Migrated DB to latest version"

#start server

echo "Starting server"
gunicorn simple_rbas.wsgi

Next, create a Dockerfile in the root folder add the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FROM python:3.9-buster

RUN mkdir /simple_rbas
RUN pip install -U pip

COPY ./requirements.txt ./simple_rbas/requirements.txt
RUN pip install --no-compile -r /simple_rbas/requirements.txt

ENV PYTHONWARNINGS ignore
ENV PYTHONDONTWRITEBYTECODE=true
ENV PYTHONUNBUFFERED 1
ENV PATH="${PATH}:/root/.local/bin"
ENV PYTHONPATH=.

COPY . /simple_rbas/

WORKDIR /simple_rbas
CMD ["run_web.sh"]

Create an okteto-stack.yml, and add the following lines:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: simple-rbas
services:
rbas-db:
image: postgres:11
ports:
- 5432
environment:
- POSTGRES_USER=pguser
- POSTGRES_PASSWORD=pgpass
- POSTGRES_DB=pgdb
resources:
cpu: 100m
memory: 128Mi

simple-rbas:
public: true
image:okteto.dev/simple-rbas:latest
build: .
replicas: 1
command: bash -c "gunicorn simple_rbas.wsgi --reload -b 0.0.0.0:8000"
ports:
- 8000
resources:
cpu: 100m
memory: 128Mi
env_file:
- .env
environment:
- DJANGO_READ_DOT_ENV_FILE=true
- DATABASE_URL=postgresql://pguser:[email protected]:5432/pgdb
- COOKIE_SECURE=true
- DEBUG=false

The okteto-stack.yml file is similar to the docker-compose.yml file. In the stack file, we are configuring two services:

  1. rbas-db for our database.
  2. simple-rbas our web API.

Our services are linked together through the DATABASE_URL, and both services are exposed to a specific port, 5432 and 8000.

The rbas-db is created from the postgres11 image, and simple-rbas is based on the image created by our Dockerfile.

Initializing Models and Permissions

In users/models.py, add the following lines:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
OWNER = 0
INVESTOR = 1
ADMIN = 2
WRITE = 3
READ_ONLY = 4
LEVEL_CHOICES = (
(OWNER, "Owner"),
(INVESTOR, "Investor"),
(ADMIN, "Admin"),
(WRITE, "Read/Write"),
(READ_ONLY, "Read Only"),
)
level = models.SmallIntegerField(choices=LEVEL_CHOICES, db_index=True, default=READ_ONLY)

The user’s level property will determine authorization on the API. Only Owner users can have access to all views.
. Only users with the Owner property can have access to all views.

Next, create a users/serializers.py file to serialize and deserialize responses and requests for our users when signing up.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import django.contrib.auth.password_validation as validators
from django.contrib.auth import get_user_model
from django.core import exceptions

from rest_framework import serializers

User = get_user_model()

class SignUpSerializer(serializers.Serializer):
email = serializers.EmailField()
first_name = serializers.CharField()
last_name = serializers.CharField()
username = serializers.CharField()
level = serializers.IntegerField(required=False)
password = serializers.CharField(trim_whitespace=False)
def validate_email(self, email):
"""
Raises exception if email already exist
:return:
"""

try:
user = User.objects.get(email=email)
if user.is_active:
raise serializers.ValidationError("user with that email already exists")
except User.DoesNotExist:
return email
def create(self, validated_data):
"""
Create the user at DB level
:param validated_data:
:return:
"""

level = validated_data.get("level")
email = validated_data.get("email")
self.validate_email(email)

if level > 4 or level < 0:
raise serializers.ValidationError("level does not exist")
user = User.objects.create_user(**validated_data)
return user

class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ("username", "email", "first_name", "last_name", "level",)

Next create a permissions.py file in the users directory and add the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from rest_framework import permissions

class OnlyStaffOwnerUserPermission(permissions.BasePermission):
"""
Custom user API permissions.
- Normal users can't send requests
- Staff and Owner can do everything
"""

message = 'Only Staff or Owner Users can access this endpoint.'
def has_permission(self, request, view):
allowed_level = {0, 2, 3,}
return request.user.level in allowed_level

class OnlyAdminOwnerUserPermission(permissions.BasePermission):
"""
Custom user API permissions.
- Normal users can't send requests
- Staff, Investor and Owner can do everything
"""

message = 'Only Admin Staff or Owner Users can access this endpoint.'
def has_permission(self, request, view):
return request.user.level == 2 or request.user.level == 0

class OnlyInvestorOwnerUserPermission(permissions.BasePermission):
"""
Custom user API permissions.
- Normal users can't send requests
- Investor and Owners can do everything
"""

message = 'Only Investors or Owners can access this endpoint.'
def has_permission(self, request, view):
return request.user.level <= 1

class OnlyOwnerUserPermission(permissions.BasePermission):
"""
Custom user API permissions.
- Normal users can't send requests
- Owners can do everything
"""

message = 'Only Owners can access this endpoint.'
def has_permission(self, request, view):
return request.user.level == 0

Each permission class will be added to our views to allow only users with matching levels to access the endpoints and throwing the message when the user level doesn’t fit.

Views and Routing

In this section, we will create the views for each of the available roles and then route each view to an endpoint.

Add the following in the users/views.py file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth import get_user_model

from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from rest_framework.viewsets import GenericViewSet

from users.serializers import SignUpSerializer, UserSerializer
from users.permissions import (
OnlyStaffOwnerUserPermission,
OnlyAdminOwnerUserPermission,
OnlyInvestorOwnerUserPermission,
OnlyOwnerUserPermission
)
from users.backends import generate_jwt_token

User = get_user_model()

class SignUpView(GenericViewSet):
def create(self, request):
"""
Create User,
"""
serializer = SignUpSerializer(data=request.data)
if not serializer.is_valid():
return Response({"error": serializer.errors, "success": False}, status=status.HTTP_403_FORBIDDEN)
user = serializer.save()
user_serializer = UserSerializer(user)
return Response(
{"user": user_serializer.data, "success": True},
status=status.HTTP_201_CREATED,
)

class LoginView(GenericViewSet):
def login(self, request):
"""
Login User and return authentication token (POST REQUEST)
:param request:
:return:
"""
username = request.data.get("username")
password = request.data.get("password")
user = authenticate(username=username, password=password)
if not user:
return Response({"error": "Invalid Credentials", "success": False}, status=status.HTTP_401_UNAUTHORIZED)
token = generate_jwt_token({"user_id": user.pk}, min_to_expire=1800)
content = {
"success": True,
"message": "You've successfully logged in",
"email": user.email,
"user_level": user.level,
"token": f"Bearer {token}",
"user_id": user.id,
}
response = Response(data=content, status=status.HTTP_200_OK)
response.set_cookie(
"Authorization",
value=f"Bearer {token}",
httponly=True,
max_age=settings.COOKIE_TIME,
expires=settings.COOKIE_TIME,
samesite="None",
secure=settings.COOKIE_SECURE, # Cookie is sent from client only over HTTP when flag turned on
)
return response

The SignUpView is called for creating new users and roles in the system. The LogInView is used in authenticating users in the system. Although the token is sent back as a response, it is not used because it is also sent as a cookie to be read by the browser or postman.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class AllUser(GenericViewSet):
def get(self, request):
message = "Every User can visit this url, even unsigned users"

return Response(
{"message": message , "success": True},
status=status.HTTP_200_OK,
)

class OnlyAuthenticatedUser(GenericViewSet):
permission_classes = [IsAuthenticated]
def get(self, request):
user = User.objects.get(username=request.user.username)
user_serializer = UserSerializer(user)
message = "Every authenticated user have access here"
return Response(
{"user": user_serializer.data, "message": message, "success": True},
status=status.HTTP_200_OK,
)

The AllUser class view accepts every user who hits the endpoint, signed up or not, and OnlyAuthenticatedUser class is accessible to only signed up and authenticated users. This is made available by the IsAuthenticated permission.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class OnlyStaffOwnerUser(GenericViewSet):
permission_classes = [IsAuthenticated, OnlyStaffOwnerUserPermission]
def get(self, request):
user = User.objects.get(username=request.user.username)
user_serializer = UserSerializer(user)
message = "You can only see this if you're a staff or owner"
return Response(
{"user": user_serializer.data, "message": message, "success": True},
status=status.HTTP_200_OK,
)

class OnlyAdminStaffOwnerUser(GenericViewSet):
permission_classes = [IsAuthenticated, OnlyAdminOwnerUserPermission]
def get(self, request):
user = User.objects.get(username=request.user.username)
user_serializer = UserSerializer(user)
message = "You can only see this if you're an admin staff or owner"
return Response(
{"user": user_serializer.data, "message": message, "success": True},
status=status.HTTP_200_OK,
)

class OnlyInvestorAndOwnerUser(GenericViewSet):
permission_classes = [IsAuthenticated, OnlyInvestorOwnerUserPermission]
def get(self, request):
user = User.objects.get(username=request.user.username)
user_serializer = UserSerializer(user)
message = "You can only see this if you're an investor or owner"
return Response(
{"user": user_serializer.data, "message": message, "success": True},
status=status.HTTP_200_OK,
)

class OnlyOwnerUser(GenericViewSet):
permission_classes = [IsAuthenticated, OnlyOwnerUserPermission]
def get(self, request):
user = User.objects.get(username=request.user.username)
user_serializer = UserSerializer(user)
message = "You can only see this if you're an owner"

return Response(
{"user": user_serializer.data, "message": message, "success": True},
status=status.HTTP_200_OK,
)

In the code block above, the OnlyStaffOwnerUser class can only be accessible to authenticated staff and Owner, using the permission classes IsAuthenticated and OnlyStaffOwnerUserPermission.

  1. OnlyAdminStaffOwnerUser is only available to admin staff and Owner.
  2. OnlyInvestorAndOwnerUser is also only available to Investors and Owner
  3. OnlyOwnerUser is only available to owners.

Let’s assign the views we created to routes. In urls.py, add the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from users.views import *

urlpatterns = [
path('admin/', admin.site.urls),
]

urlpatterns += [
path('user-auth/', LoginView.as_view({"post": "login"}), name = "user-login"),
path('user-signup/', SignUpView.as_view({"post": "create"}), name = "user-signup"),
path('all-user/', AllUser.as_view({"post": "get"}), name = "all-users"),
path('authenticated-user/', OnlyAuthenticatedUser.as_view({"post": "get"}), name = "authenticated-user"),
path('only-staff/', OnlyStaffOwnerUser.as_view({"post": "get"}), name = "only-staff"),
path('only-admin/', OnlyAdminStaffOwnerUser.as_view({"post": "get"}), name = "only-admin"),
path('only-investor-owner/', OnlyInvestorAndOwnerUser.as_view({"post": "get"}), name = "only-investor-owner"),
path('only-owner/', OnlyOwnerUser.as_view({"post": "get"}), name = "only-owner"),
]

Deploying to Okteto

With the application completed, we will now proceed to deploying to Okteto. We’ll be using the Okteto CLI to deploy our application.

To install Okteto CLI, use the command below:

1
$ brew install okteto

Not using MacOS? Check out the guide for installing on your OS on the installation page.

Before using the Okteto CLI, you need to log into the service to allow you to create, delete, push and synchronise changes from your local machine. You only need to do this once.

Log in by using the command below:

1
$ okteto login

A browser tab will open automatically, confirming that you have now logged in. If this the first time that you use Okteto, it will ask you to log in with your GitHub identity.

okteto

Next, run the command below to download your k8s credentials for a Kubernetes namespace:

1
$ okteto namespace

Next, we’ll deploy the application using the stack file we created earlier:

1
$ okteto stack deploy --build

This command will build our API’s container image using our Dockerfile and then deploy the services in Okteto Cloud using the stack file’s instructions.

Now our API is live!!!

Testing

We’ll be using Postman to test our newly deployed application. Postman is an API client that allows us test APIs we build in place. If you don’t have the Postman client installed, follow the instructions on the downloads page to install it on your machine.

Retrieve the endpoint provided by Okteto from your dashboard:

Dashboard

Here are some few tests on Postman:

Sign Up User:

Signup

Login User:

Login

Admin User:

Admin User

Conclusion

In this article, you’ve learned and successfully built and deployed a Role-based Authorization System with Django, Docker, and Okteto.

Deployment and Development with Okteto are elegant and straightforward. Okteto also helps with hosting your application, creating accessibility and great accessibility to your project. You can find the code used in this article on GitHub.