This month has been crazy learning about a TON of new and different “user security” topics. It was very overwhelming at times with the variety of different solutions to chose from and ways to implement them. With users finally working in the database and the model, the rest of the Django support for adding multiple users, each user having their own data, and proper user authentication, became pretty straightforward (especially with a bit of coaching from Chat GPT). Here are the topics I’ll cover:
- REST Framework SimpleJWT
- Django REST Framework User Methods
- RESTful API Calls
- User Data Security
- Environment Settings
REST Framework Simple JSON Web Tokens (JWT)
The old style of authentication that I used back at CalPERS was a simple login with a username and password. Once the user was authenticated, the browser session used an isLogged property in the $_SESSION: authentication was never tracked inside the back end. In hind site, it would have been easy for malicious JavaScript to call each back-end URL. Many modern React Sites use JSON Web Tokens (JWT) for a more robust form of authentication. The access token is passed in through the headers instead of the main data portion. Since this is Django we’re talking about, most of the work is already done for you and you merely do an import of the modules, with very little extra work.
To me, picking the JWT was a good middle-of-the-road selection. A more advanced choice would have been to use your Google Profile for user authentication with OAuth2. However, those features would have taken even more time to learn and implement: time that I needed to devote to getting this release out and start using it for my January Job Hunting. I also stumbled into enough other illuminating information to know that, eventually, I’ll need to do a solid rebuild (mostly front end only) sometime in the not-too-distant future. But for this implementation, JWT is good enough!
Enabling JWT also meant enabling a few changes in the settings.py file, including:
- Setting the DJANGO_ENV to have code dependent upon whether to set values for Development or Production
- Enabling the REST Framework Simple JWT
- Enabling CORS headers and middleware
- Enabling strong passwords and their validators
# settings.py
from pathlib import Path
import os
import environ
from datetime import timedelta
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Initialize environment variables from .env file
env = environ.Env()
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
# Optionally, for production, you can specify the file manually:
# environ.Env.read_env(os.path.join(BASE_DIR, '.env.production'))
DJANGO_ENV = env('DJANGO_ENV', default='production')
# Read the environment variable ENABLE_AUTH, default to True for production
ENABLE_AUTH = os.getenv('ENABLE_AUTH', 'True') == 'True'
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_simplejwt',
'corsheaders',
'job_search'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
]
AUTH_USER_MODEL = 'job_search.CustomUser'
# CORS configuration
CORS_ORIGIN_ALLOW_ALL = env.bool('CORS_ORIGIN_ALLOW_ALL', False)
CORS_ALLOW_CREDENTIALS = env.bool('CORS_ALLOW_CREDENTIALS', True)
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['localhost'])
CORS_ALLOW_HEADERS = [
'authorization',
'content-type',
'x-refresh-token',
]
# CORS settings based on CORS_ORIGIN_ALLOW_ALL
if CORS_ORIGIN_ALLOW_ALL:
# Allow all origins (i.e., open CORS policy)
CORS_ALLOWED_ORIGINS = []
else:
# Use specific allowed origins
CORS_ALLOWED_ORIGINS = env.list('CORS_ALLOWED_ORIGINS', default=['http://localhost:3000'])
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False,
'UPDATE_LAST_LOGIN': False,
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
ENABLE_STRONG_PASSWORDS = env.bool('ENABLE_STRONG_PASSWORDS', default=True)
if ENABLE_STRONG_PASSWORDS:
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
{
'NAME': 'job_search.custom_user.custom_user_validators.StrongPasswordValidator',
},
]
else:
# Disable strong password validation if the flag is set to False
AUTH_PASSWORD_VALIDATORS = []
Django REST Framework User Methods
The functions that JWT needs are relatively straightforward: authenticate the user, refresh the token, and logout. This was also the time to add in functionality for a user to register themself with Django. One of the other things that I discovered: ChatGPT can do a great job putting together pylint doc strings. So, here is my final view code for user authentication.
# job_search/custom_user/custom_user_views.py
import os
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.views import TokenRefreshView
from django.contrib.auth import authenticate
from django.contrib.auth.password_validation import validate_password
from django.conf import settings
from job_search.models import CustomUser
from job_search.custom_user.custom_user_serializer import (
CustomUserSerializer,
CustomUserListSerializer,
)
DJANGO_ENV = settings.DJANGO_ENV
@api_view(['POST'])
def authenticate_user(request):
"""
Authenticate a user and provide JWT tokens upon successful login.
Args:
request (Request): The HTTP POST request containing 'username' and 'password' in the request body.
Returns:
Response:
- 200 OK: If authentication is successful, returns a dictionary with:
- 'access': Access token (str).
- 'refresh': Refresh token (str).
- 'user': Serialized user data (dict) using the CustomUserSerializer.
- 400 BAD REQUEST: If 'username' or 'password' is missing in the request data.
- 401 UNAUTHORIZED: If the provided credentials are invalid.
Raises:
None
Example:
POST /api/authenticate_user/
Request body:
{
"username": "testuser",
"password": "password123"
}
Response:
{
"access": "eyJ0eXAiOiJKV1QiLCJh...",
"refresh": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 1,
"username": "testuser",
"email": "testuser@example.com",
...
}
}
"""
username = request.data.get('username')
password = request.data.get('password')
if not username or not password:
return Response({'error': 'Username and password are required.'}, status=status.HTTP_400_BAD_REQUEST)
user = authenticate(username=username, password=password)
if user:
# Create JWT token for the user
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
user_data = CustomUserSerializer(user).data
# Return access token, refresh token, and serialized user data
return Response({
'access': str(access_token),
'refresh': str(refresh),
'user': user_data, # Return the serialized user data
}, status=status.HTTP_200_OK)
else:
return Response({'error': 'Invalid credentials.'}, status=status.HTTP_401_UNAUTHORIZED)
class TokenRefreshCustomView(TokenRefreshView):
"""
Custom refresh token view for handling JWT token refreshes.
This class extends SimpleJWT's built-in `TokenRefreshView` to provide
additional error handling and customized responses for token refresh operations.
Methods:
post(request, *args, **kwargs):
Handle POST requests to refresh JWT tokens.
- On success: Calls the parent `TokenRefreshView.post` method.
- On failure: Returns appropriate error messages and status codes.
Args:
request (Request): The HTTP POST request containing the refresh token in the body.
Returns:
Response:
- 200 OK: If the refresh token is valid, returns a new access token and the original refresh token.
- 401 UNAUTHORIZED: If the refresh token is invalid or expired.
- 500 INTERNAL SERVER ERROR: For any unexpected server-side errors.
Raises:
None
Example:
POST /api/token/refresh/
Request body:
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Response (on success):
{
"access": "eyJ0eXAiOiJKV1QiLCJh..."
}
Response (on failure):
{
"detail": "Token refresh failed. Please log in again."
}
"""
def post(self, request, *args, **kwargs):
try:
# Call the original TokenRefreshView's post method to handle refresh logic
return super().post(request, *args, **kwargs)
except TokenError:
# Catch TokenError if the refresh token is invalid or expired
return Response(
{'detail': 'Token refresh failed. Please log in again.'},
status=status.HTTP_401_UNAUTHORIZED
)
except Exception:
# General exception handling for any other errors
return Response(
{'detail': 'An unexpected error occurred during token refresh.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
def logout_user(request):
"""
Logs out a user by blacklisting their refresh token.
This function handles the logout process for JWT-based authentication
by blacklisting the provided refresh token, preventing further use.
Args:
request (Request): The HTTP POST request containing the 'refresh' token in the request body.
Returns:
Response:
- 200 OK: If the refresh token is successfully blacklisted.
- 400 BAD REQUEST: If no refresh token is provided or the token is invalid.
Raises:
None
Example:
POST /api/logout_user/
Request body:
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Response (on success):
{
"detail": "Successfully logged out."
}
Response (on failure - no token):
{
"detail": "No refresh token provided."
}
Response (on failure - invalid token):
{
"detail": "Invalid token."
}
"""
try:
refresh_token = request.data.get('refresh') # Get the refresh token from the request body
if refresh_token:
token = RefreshToken(refresh_token)
token.blacklist() # Blacklist the refresh token so it can't be used again
return Response({"detail": "Successfully logged out."}, status=status.HTTP_200_OK)
else:
return Response({"detail": "No refresh token provided."}, status=status.HTTP_400_BAD_REQUEST)
except TokenError:
return Response({"detail": "Invalid token."}, status=status.HTTP_400_BAD_REQUEST)
class UserProfileView(APIView):
"""
View to retrieve the current logged-in user's profile.
This endpoint provides user-specific information, acting as a 'me' endpoint
to fetch the details of the authenticated user.
Attributes:
permission_classes (list): List of permission classes that determine access to the view.
Requires the user to be authenticated (`IsAuthenticated`).
Methods:
get(request):
Handle GET requests to return user profile details.
Args:
request (Request): The HTTP request object containing user authentication information.
Returns:
Response:
- 200 OK: If the user is authenticated, returns a dictionary containing:
- 'id': User ID (int)
- 'username': Username (str)
- 'email': Email address (str)
- 'first_name': First name (str)
- 'last_name': Last name (str)
Raises:
None
Example:
GET /api/user/profile/
Response:
{
"id": 1,
"username": "john_doe",
"email": "john_doe@example.com",
"first_name": "John",
"last_name": "Doe"
}
"""
permission_classes = [IsAuthenticated]
def get(self, request):
user = request.user
user_data = {
'id': user.id,
'username': user.username,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
}
return Response(user_data, status=status.HTTP_200_OK)
@api_view(['PUT'])
@permission_classes([IsAuthenticated])
def update_user_info(request):
"""
Update the logged-in user's information.
This endpoint allows an authenticated user to update their profile information
with partial updates supported.
Args:
request (Request): The HTTP PUT request containing the user's updated data in the request body.
Returns:
Response:
- 200 OK: If the user information is successfully updated. Returns a success message.
- 400 BAD REQUEST: If the provided data is invalid. Returns validation errors.
Raises:
None
Example:
PUT /api/user/update/
Request body:
{
"first_name": "John",
"last_name": "Doe",
"email": "new_email@example.com"
}
Response (on success):
{
"message": "User info updated successfully."
}
Response (on failure):
{
"email": ["Enter a valid email address."]
}
"""
user = request.user
serializer = CustomUserSerializer(user, data=request.data, partial=True) # Allow partial updates
if serializer.is_valid():
serializer.save()
return Response({'message': 'User info updated successfully.'}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
def register_user(request):
"""
Register a new user with basic and default information.
This endpoint allows the creation of a new user with the following required fields:
- `username`
- `email`
- `first_name`
- `last_name`
- `password`
Optional fields are initialized to default values:
- `bio`: ""
- `user_greeting`: "Welcome!"
- `color_mode`: "light"
- `dashboard_first_date`: None
- `dashboard_second_date`: None
Args:
request (Request): The HTTP POST request containing user registration data in the body.
Returns:
Response:
- 201 CREATED: If the user is successfully registered. Returns a success message.
- 400 BAD REQUEST: If required fields are missing, the username/email is already taken,
or the password fails validation. Returns an error message.
Raises:
None
Example:
POST /api/register/
Request body:
{
"username": "johndoe",
"email": "johndoe@example.com",
"first_name": "John",
"last_name": "Doe",
"password": "StrongPassword123!"
}
Response (on success):
{
"message": "User successfully registered."
}
Response (on failure):
{
"error": "Username already exists."
}
"""
# Get the data from the request
username = request.data.get('username')
email = request.data.get('email')
first_name = request.data.get('first_name')
last_name = request.data.get('last_name')
password = request.data.get('password')
# Validate required fields
if not username or not email or not first_name or not last_name or not password:
return Response({'error': 'All fields are required: username, email, first_name, last_name, and password.'},
status=status.HTTP_400_BAD_REQUEST)
# Check if the username already exists
if CustomUser.objects.filter(username=username).exists():
return Response({'error': 'Username already exists.'}, status=status.HTTP_400_BAD_REQUEST)
# Skip email check if in development environment
print(f'DJANGO_ENV value: {os.environ.get("DJANGO_ENV")}')
if DJANGO_ENV != 'development':
# Check if the email already exists, skip for development
if CustomUser.objects.filter(email=email).exists():
return Response({'error': 'Email address is already registered.'}, status=status.HTTP_400_BAD_REQUEST)
try:
# Validate password using Django's built-in password validators
validate_password(password)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
# Set optional fields to default values
bio = "" # Default value for bio
user_greeting = "Welcome!" # Default greeting message
color_mode = "light" # Default color mode
dashboard_first_date = None # Default value for dashboard_first_date
dashboard_second_date = None # Default value for dashboard_second_date
# Create the custom user (including required fields and default optional fields)
user = CustomUser.objects.create_user(
username=username,
email=email,
first_name=first_name,
last_name=last_name,
password=password,
bio=bio,
user_greeting=user_greeting,
color_mode=color_mode,
dashboard_first_date=dashboard_first_date,
dashboard_second_date=dashboard_second_date
)
return Response({'message': 'User successfully registered.'}, status=status.HTTP_201_CREATED)
RESTful API Calls
With enough functions in place, it was also time to review the RESTful API URL conventions. As you may have noticed, the URL route does not care whether a user is authenticated or not: that is enforced by the Django view code itself. You’ll notice that I wrote some of the views, while others are imported through standard libraries, making my life much easier:
#urls.py
from django.contrib import admin
from django.urls import path, re_path
from rest_framework_simplejwt.views import TokenObtainPairView
from job_search.custom_user.custom_user_views import (
authenticate_user,
TokenRefreshCustomView,
UserProfileView,
logout_user,
update_user_info,
list_all_users,
register_user
)
urlpatterns = [
path('admin/', admin.site.urls),
# Custom User Endpoints
path('api/auth/register/', register_user, name='register_user'),
path('api/auth/login/', authenticate_user, name='authenticate_user'),
path('api/auth/logout/', logout_user, name='logout_user'),
path('api/custom_user/update/', update_user_info, name='update_user_info'),
# Token obtain pair (login endpoint)
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
# Token refresh endpoint
path('api/token/refresh/', TokenRefreshCustomView.as_view(), name='token_refresh'),
# "Me" endpoint to get current authenticated user data
path('api/me/', UserProfileView.as_view(), name='user_profile'),
You’ll also notice that I have published the API documentation, with a more stable list of URLS (I’m not really anticipating adding any more substantial features).
User Data Security
With authentication finally incorporated into the back end, it was time to integrate user authentication verification into the rest of the back-end data. Each function needs to ensure that the user is authenticated and that the user should be allowed to look at that individual piece of data (job site, job posting, opportunity). Since the code is mostly redundant, I’ve only included one sample:
@api_view(["GET"])
def job_site_postings(request, job_site_id):
"""
Retrieves job postings associated with a specific job site, accessible only by the owner.
- For GET requests:
Fetches a list of job postings for the job site with the given job_site_id, ensuring the user
is authenticated and authorized to access the data.
Args:
request: The HTTP request object, containing user data, method, and any posted data.
job_site_id: The ID of the job site whose associated job postings are to be retrieved.
Returns:
Response: A Response object containing a serialized list of job postings associated with
the job site, or an error message if the user is unauthorized or the job posting
is not found.
Raises:
- Returns a 401 Unauthorized response if the user is not authenticated.
- Returns a 403 Forbidden response if the user does not have permission to access the job postings.
- Returns a 404 Not Found response if no job posting exists for the provided job_site_id.
Notes:
- This view is read-only and only allows GET requests to fetch job postings.
- The user must be the owner of the job posting to view the data.
- The response data includes a filtered list of job postings, showing `id`, `company_name`,
`posting_title`, `posting_status`, and `applied_at` fields.
Debugging:
- The function ensures user authentication and authorization before retrieving job postings.
"""
print(f"User Info: {request.user}")
print(f"Is Authenticated: {request.user.is_authenticated}")
print(f"User ID: {request.user.id}")
print(f"User Username: {request.user.username}")
if not request.user.is_authenticated:
return Response({"detail": "Authentication credentials were not provided."}, status=status.HTTP_401_UNAUTHORIZED)
# Retrieve the Job Site to ensure that the user is the owner
job_site = get_object_or_404(JobSite, pk=job_site_id)
if job_site.user != request.user:
return Response({"detail": "You do not have permission to access this resource."}, status=status.HTTP_403_FORBIDDEN)
if request.method == "GET":
data = JobPosting.objects.filter(job_site_id=job_site_id).values(
"id", "company_name", "posting_title", "posting_status", "applied_at"
)
serializer = JobSitePostingsSerializer(
data, context={"request": request}, many=True
)
return Response(serializer.data)
Environment Settings
Getting closer to deploying the application as a container to run in AWS, you start thinking about the differences between development and production environments. Having to create a few new constants, it was time to start using the correct import structure. The primary django .env file looks like:
# .env
DJANGO_ENV=development
ENABLE_AUTH=True # Disable authentication for local development
ENABLE_STRONG_PASSWORDS=False
CORS_ALLOW_CREDENTIALS=True
CORS_ORIGIN_ALLOW_ALL=True
ALLOWED_HOSTS=localhost
DEBUG=True
SECRET_KEY=your-secret-key
# Database connection (example)
# DB_HOST=localhost
# DB_PORT=5432
# DB_NAME=mydatabase
# DB_USER=myuser
# DB_PASSWORD=mypassword # Set the password for the database
# Optional settings for production
ENABLE_LOGGING=True
This was imported directly into the settings.py and each of these items was turned into a global variable:
# Initialize environment variables from .env file
import os
import environ
env = environ.Env()
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
DJANGO_ENV = env('DJANGO_ENV', default='production')
ENABLE_AUTH = os.getenv('ENABLE_AUTH', 'True') == 'True'
Final Thoughts
With all of this code in place, I was able to start building out all of the React front end. Despite using many already existing modules for both the front and back end, the Django code portion of this project is still rather minimal compared to React, which feels like well over 80% of the work!