├── .gitignore ├── README.md ├── backend ├── Pipfile ├── README.md ├── backend │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── base │ ├── __init__.py │ ├── admin.py │ ├── api │ │ ├── __init__.py │ │ ├── urls.py │ │ └── views.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_profile_email_profile_last_name_and_more.py │ │ └── __init__.py │ ├── models.py │ ├── serializer.py │ ├── signals.py │ ├── tests.py │ └── views.py └── manage.py └── frontend ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── App.js ├── components └── Header.js ├── context └── AuthContext.js ├── index.css ├── index.js ├── pages ├── HomePage.js └── LoginPage.js └── utils └── PrivateRoute.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /frontend/node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Table of contents 3 | - [Table of contents](#table-of-contents) 4 | - [Introduction](#introduction) 5 | - [What is JWT?](#what-is-jwt) 6 | - [Backend](#backend) 7 | - [Boilerplate Setup](#boilerplate-setup) 8 | - [Creating a view and routing it](#creating-a-view-and-routing-it) 9 | - [Adding Django Rest Framework](#adding-django-rest-framework) 10 | - [Adding JWT - creating login and refresh views](#adding-jwt---creating-login-and-refresh-views) 11 | - [Customizing JWT behavior](#customizing-jwt-behavior) 12 | - [Customizing JWT token - include the username](#customizing-jwt-token---include-the-username) 13 | - [Allowing Frontend Access with CORS](#allowing-frontend-access-with-cors) 14 | - [Frontend](#frontend) 15 | - [Setting up webpages](#setting-up-webpages) 16 | - [Protected routes](#protected-routes) 17 | - [AuthContext - state management](#authcontext---state-management) 18 | - [```createContext()```](#createcontext) 19 | - [```useContext()```](#usecontext) 20 | - [Login method](#login-method) 21 | - [Logout method](#logout-method) 22 | - [Keeping a user logged in after refresh](#keeping-a-user-logged-in-after-refresh) 23 | - [UpdateToken method - Refreshing the access token](#updatetoken-method---refreshing-the-access-token) 24 | - [Refreshing the Token on an Interval](#refreshing-the-token-on-an-interval) 25 | - [Edge cases:](#edge-cases) 26 | - [User Permissions - control access to user-specific data](#user-permissions---control-access-to-user-specific-data) 27 | - [Setting up user-specific data in django](#setting-up-user-specific-data-in-django) 28 | - [Testing user permissions - displaying private profile info](#testing-user-permissions---displaying-private-profile-info) 29 | 30 | 31 | 32 | 33 | # Introduction 34 | 35 | This tutorial will walk through the process of implementing user authentication between a Django backend and a React frontend using JSON Web Tokens (JWT) with the help of [jwt.io](https://jwt.io). We'll start by setting up a basic Django backend with a user authentication system, then create a React frontend and integrate it with our backend. Finally, we'll implement JWT-based authentication to secure our web application, and access protected data. By the end of this tutorial, you'll have a solid understanding of how to use JWT to implement user authentication in a full-stack web application. For more discussion on why or why not to use JWT visit [here](https://blog.logrocket.com/jwt-authentication-best-practices/). 36 | 37 | 38 | ## What is JWT? 39 | 40 | JWT stands for JSON Web Token. It is an open standard for securely transmitting information between parties as a JSON object. In the context of web applications, JWTs are commonly used for authentication and authorization purposes. 41 | 42 | JWTs are useful because they allow a user to authenticate once and then securely transmit that authentication information between different parts of an application. This can eliminate the need to constantly re-authenticate a user, which can improve the overall user experience. JWTs are also stateless, which means that no server-side state needs to be stored, making them a good fit for distributed applications. 43 | 44 | Django Rest Framework's built-in JWT functionality provides an easy way to use JWTs for authentication and authorization. When a user logs in, a JSON web token is generated by the server and sent to the client. The client then includes the token in subsequent requests to the server to prove that it has already been authenticated. 45 | 46 | When a request with a JWT is received by the server, the server validates the token by checking its signature and decoding the payload. If the token is valid, the server uses the information in the payload to authorize the request. If the token is not valid, the request is denied. 47 | 48 | Security is a critical aspect of using JWT for authentication and authorization. JWT tokens can potentially be intercepted and used by an attacker to gain unauthorized access to sensitive data or actions. It's important to properly secure tokens to prevent this. The tokens should be sent over HTTPS, and they should be properly validated to ensure they haven't been tampered with. It's also important to set a short expiration time for the access token to minimize the risk of an attacker using it. Django Rest Framework's JWT implementation includes measures to mitigate these risks, but it's still important to follow best practices to ensure the security of your application. 49 | 50 | For more information on securing JWT in, see this post on [JWT Best Practice](https://curity.io/resources/learn/jwt-best-practices/). 51 | 52 | --- 53 | 54 | 55 | # Backend 56 | 57 | 58 | ## Boilerplate Setup 59 | To start, we need a new Django project. In a shell, navigate to the directory you want to contain your project, and run
```django-admin startproject backend``` 60 | 61 | Enter the new project folder:
```cd backend``` 62 | 63 | Before installing Django, you need to make sure that pipenv is installed. If you haven't installed it already, you can run:
```pip install pipenv``` 64 | 65 | Then, launch a virtual environment by calling
```pipenv shell``` 66 |
This creates a new virtual environment tied to this directory. 67 | 68 | First we need to install django in the new virtual env by running:
```pip install django``` 69 | 70 | Now we can create our app:
```python manage.py startapp base``` 71 | 72 | Make sure to run this command in the backend directory. 73 | 74 | If you are using VSCode as your IDE, from here you can open the directory with ```code .``` 75 | 76 | Now that there is a template in place, we are ready to start making changes. We want all the authentication api functionality to reside together, and to provide more separation for this functionality, we will create a new folder inside of ```/base``` called ```/api```. 77 | 78 | Now if everything has been setup correctly, when you run ```python manage.py runserver```, you should be able to see the server running on ```http://127.0.0.1:8000``` 79 | 80 |
81 | 82 | --- 83 | 84 | 85 | ## Creating a view and routing it 86 | 87 | Our goal here is to create a view that returns two API routes that will be used for sending user login details and receiving authentication tokens. 88 | 89 | The first thing we want to do is create a new view and link it in the urls. In the api folder create two new files: ```urls.py``` and ```views.py```. ```This urls.py``` folder will contain all of our user auth api routes; we will include it in the main url config file ```/base/urls.py``` later. 90 | 91 | This is what the directory structure should look like: 92 | ``` 93 | backend 94 | ├── Pipfile 95 | ├── Pipfile.lock 96 | ├── README.md 97 | ├── backend 98 | │ ├── README.md 99 | │ ├── __init__.py 100 | │ ├── asgi.py 101 | │ ├── settings.py 102 | │ ├── urls.py 103 | │ └── wsgi.py 104 | ├── base 105 | │ ├── README.md 106 | │ ├── __init__.py 107 | │ ├── admin.py 108 | │ ├── api 109 | │ │ ├── README.md 110 | │ │ ├── urls.py 111 | │ │ └── views.py 112 | │ ├── apps.py 113 | │ ├── migrations 114 | │ │ ├── README.md 115 | │ │ └── __init__.py 116 | │ ├── models.py 117 | │ ├── tests.py 118 | │ └── views.py 119 | ├── db.sqlite3 120 | └── manage.py 121 | ``` 122 | 123 | In views.py create a new view that returns all the possible routes, here, we are going to have two routes: one for sending user login details and receiving authentication tokens ```/api/token```, and one for sending a refresh token and receiving new authentication tokens ```/api/token/refresh```. 124 | 125 | ```python 126 | from django.http import JsonResponse 127 | def get_routes(request): 128 | routes = [ 129 | '/api/token', 130 | '/api/token/refresh' 131 | ] 132 | return JsonResponse(routes, safe=False) 133 | ``` 134 | Note: The ```safe=False``` allows us to receive and display non-Json data 135 | 136 | To link this view to an accessible url, we need to complete the ```urls.py``` file in our ```/api``` directory.
```/api/urls.py```: 137 | ```python 138 | from django.urls import path 139 | from . import views 140 | 141 | urlpatterns = [ 142 | path('', views.get_routes), 143 | ] 144 | ``` 145 | 146 | Now to include the new url configuration in the app’s main url config file ```/backend/urls.py```, we need to import include and add a new path pointing to the ```/base/api/urls.py``` file
```/backend/urls.py```: 147 | ```python 148 | from django.contrib import admin 149 | from django.urls import path, include 150 | 151 | urlpatterns = [ 152 | path('admin/', admin.site.urls), 153 | path('api/', include('base.api.urls')) 154 | ] 155 | ``` 156 | 157 | Now if you navigate to ```http://127.0.0.1:8000/api``` you should see these two routes displayed. 158 | 159 |
160 | 161 | --- 162 | 163 | 164 | ## Adding Django Rest Framework 165 | 166 | Now we want to use the Django Rest Framework for our API, the documentation for usage can be found [here](https://www.django-rest-framework.org/). To install make sure the virtual env is active and run 167 | 168 | ```pip install djangorestframework``` 169 | 170 | and modify the ```/backend/settings.py``` file 171 | ```python 172 | INSTALLED_APPS = [ 173 | ... 174 | 'rest_framework', 175 | ] 176 | ``` 177 | 178 | We can change our view to use the django rest framwork by changing the response to use a DjangoRestFramework ```Response``` class instead of the default javascript ```JsonResponse```. Because this is a function based view, we also need to instruct it what kind of view we want to render with a decorator. 179 | 180 | ```python 181 | from rest_framework.response import Response 182 | from rest_framework.decorators import api_view 183 | 184 | @api_view(['GET']) 185 | def get_routes(request): 186 | """returns a view containing all the possible routes""" 187 | routes = [ 188 | '/api/token', 189 | '/api/token/refresh' 190 | ] 191 | 192 | return Response(routes) 193 | ``` 194 | 195 | If everything is configured correctly, you should see a new view at ```http://127.0.0.1:8000/api``` with an output that looks like this: 196 | ```HTTP 197 | HTTP 200 OK 198 | Allow: OPTIONS, GET 199 | Content-Type: application/json 200 | Vary: Accept 201 | 202 | [ 203 | "/api/token", 204 | "/api/token/refresh" 205 | ] 206 | ``` 207 | 208 |
209 | 210 | --- 211 | 212 | ## Adding JWT - creating login and refresh views 213 | 214 | Luckily, django rest framework has JWT built in. Following the documentation, to add it, we need to install it in the virtual env:
```pip install djangorestframework-simplejwt``` 215 | 216 | and configure it to be the default authentication behavior for django rest framework in the ```settings.py``` file by adding this setting: 217 | ``` python 218 | REST_FRAMEWORK = { 219 | ... 220 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 221 | ... 222 | 'rest_framework_simplejwt.authentication.JWTAuthentication', 223 | ) 224 | ... 225 | } 226 | ``` 227 | 228 | and add two urls for the login and refresh routes in ```/base/api/urls.py``` 229 | 230 | the new urls.py file should look like this: 231 | ```python 232 | from django.urls import path 233 | from . import views 234 | 235 | from rest_framework_simplejwt.views import ( 236 | TokenObtainPairView, 237 | TokenRefreshView, 238 | ) 239 | 240 | urlpatterns = [ 241 | path('', views.get_routes), 242 | path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), 243 | path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), 244 | ] 245 | ``` 246 | 247 | Verify jwt is working by first migrating the changes to the data model with
```python manage.py migrate```
then creating a superuser with
```python manage.py createsuperuser```. 248 | 249 | Now when visiting ```http://127.0.0.1:8000/api/token/``` you should see input fields for a username and password. Login using the superuser login you just created. 250 | 251 | After POSTing your login credentials, you should receive a refresh and access token that looks like this: 252 | 253 | ```HTTP 254 | HTTP 200 OK 255 | Allow: POST, OPTIONS 256 | Content-Type: application/json 257 | Vary: Accept 258 | 259 | { 260 | "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY3NjU5MTcyMywiaWF0IjoxNjc2NTA1MzIzLCJqdGkiOiI2MTBlM2I4NTk3ZGQ0NGQ2YTk3MWViZTEwYzQzOTg3YiIsInVzZXJfaWQiOjF9.P5ps5AOBp25_HoeiatbC7_LZjoBBb0SxukvcpyvuaqI", 261 | "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjc2NTA1NjIzLCJpYXQiOjE2NzY1MDUzMjMsImp0aSI6IjUxMTUzYTRiNmJkNjQyNTY4NDMzN2UyZjEyN2M2YTkwIiwidXNlcl9pZCI6MX0.O1n1TppJFk0KO8rUco1UWPaOcCyxaRPFOmIZv0Pte18" 262 | } 263 | ``` 264 | 265 | Copy the refresh token you were just provided and then navigate to ```http://127.0.0.1:8000/api/token/refresh```, where you should see an input field for the refresh token. Paste and submit the refresh token. You should receive a new access token from the server if everything has worked. 266 | 267 |
268 | 269 | --- 270 | 271 | ## Customizing JWT behavior 272 | 273 | There is a lot of potential customization to the behavior of JWT that can be found [here](https://django-rest-framework-simplejwt.readthedocs.io/en/latest/index.html), but I want to highlight a few that are of interest to us: 274 | ```python 275 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), # Specifies how long access tokens are valid. Typically use a lower value for higher security but more network overhead. Changing this will be useful for testing. 276 | 277 | "REFRESH_TOKEN_LIFETIME": timedelta(days=1), # Specifies how long refresh tokens are valid, this corresponds to how longer a user can remain logged in while not actively refreshing their tokens. Ex: if a user closes the tab for 22 hours, on reopening, the old refresh token would still be able to fetch a valid access token, continuing their authentication. Changing this will be useful for testing. 278 | 279 | "ROTATE_REFRESH_TOKENS": False, # When set to True, if a refresh token is submitted to the TokenRefreshView, a new refresh token will be returned along with the new access token. This provides a way to keep a rolling authentication while a client is open. 280 | 281 | "BLACKLIST_AFTER_ROTATION": False, # Causes refresh tokens submitted to the TokenRefreshView to be added to the blacklist. This prevents the scenario where a bad actor can use old refresh tokens to request their own new authentication tokens. 282 | ``` 283 | 284 | While ```ACCESS_TOKEN_LIFETIME``` and ```REFRESH_TOKEN_LIFETIME``` can remain as default for now, we want to change both ```ROTATE_REFRESH_TOKENS``` and ```BLACKLIST_AFTER_ROTATION``` to ```True```. Using the default settings from the documentation, we can add this section to the ```settings.py``` file with the new values. 285 | ```python 286 | from datetime import timedelta 287 | ... 288 | 289 | SIMPLE_JWT = { 290 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), 291 | "REFRESH_TOKEN_LIFETIME": timedelta(days=1), 292 | "ROTATE_REFRESH_TOKENS": True, 293 | "BLACKLIST_AFTER_ROTATION": True, 294 | "UPDATE_LAST_LOGIN": False, 295 | 296 | "ALGORITHM": "HS256", 297 | "SIGNING_KEY": SECRET_KEY, 298 | "VERIFYING_KEY": "", 299 | "AUDIENCE": None, 300 | "ISSUER": None, 301 | "JSON_ENCODER": None, 302 | "JWK_URL": None, 303 | "LEEWAY": 0, 304 | 305 | "AUTH_HEADER_TYPES": ("Bearer",), 306 | "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", 307 | "USER_ID_FIELD": "id", 308 | "USER_ID_CLAIM": "user_id", 309 | "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", 310 | 311 | "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), 312 | "TOKEN_TYPE_CLAIM": "token_type", 313 | "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", 314 | 315 | "JTI_CLAIM": "jti", 316 | 317 | "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", 318 | "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), 319 | "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), 320 | } 321 | ``` 322 | To enable the blacklist, we need to add the blacklist app to our list of installed apps and migrate the assocaited data model changes: 323 | ```python 324 | INSTALLED_APPS = [ 325 | ... 326 | 'rest_framework_simplejwt.token_blacklist', 327 | ... 328 | ] 329 | ``` 330 | ```python manage.py migrate``` 331 | 332 | 333 | Now when you visit ```http://127.0.0.1:8000/api/token/``` and login, and use the refresh token at ```http://127.0.0.1:8000/api/token/refresh/```, you should receive both a new access token and a new refresh token. You can also test the blacklist is functioning by trying to submit the same refresh token a second time. You should receive a response like this, indicating that token has already been used. 334 | 335 | ```HTTP 336 | HTTP 401 Unauthorized 337 | Allow: POST, OPTIONS 338 | Content-Type: application/json 339 | Vary: Accept 340 | WWW-Authenticate: Bearer realm="api" 341 | 342 | { 343 | "detail": "Token is blacklisted", 344 | "code": "token_not_valid" 345 | } 346 | ``` 347 | 348 | --- 349 | 350 | 351 | ## Customizing JWT token - include the username 352 | 353 | JWT tokens can be customized to include specific data. If you paste an access token into the debugger at [jwt.io](https://jwt.io/), you can see the payload data that it contains. This data usually includes the user_id, but what if we wanted to include the username as well without having to make a separate request to the server? 354 | 355 | To do this, we can create a custom serializer that extends the ```TokenObtainPairSerializer``` class and overrides the ```get_token()``` method. In this method, we can add a new claim to the token, such as the username. The modified serializer looks like this: 356 | 357 | ```python 358 | from rest_framework_simplejwt.serializers import TokenObtainPairSerializer 359 | from rest_framework_simplejwt.views import TokenObtainPairView 360 | 361 | class MyTokenObtainPairSerializer(TokenObtainPairSerializer): 362 | @classmethod 363 | def get_token(cls, user): 364 | token = super().get_token(user) 365 | token['username'] = user.username 366 | return token 367 | ``` 368 | 369 | Next, we need to create a custom view that uses our custom serializer instead of the default one. We can do this by creating a new view that extends the ```TokenObtainPairView``` class and sets its ```serializer_class``` attribute to our custom serializer. Here's what the new view looks like: 370 | ```python 371 | from .serializers import MyTokenObtainPairSerializer 372 | from rest_framework_simplejwt.views import TokenObtainPairView 373 | 374 | class MyTokenObtainPairView(TokenObtainPairView): 375 | serializer_class = MyTokenObtainPairSerializer 376 | ``` 377 | 378 | Finally, we need to modify the URL to point to our new custom view. In our ```urls.py``` file, we replace ```TokenObtainPairView``` with ```MyTokenObtainPairView```: 379 | 380 | ```python 381 | from django.urls import path 382 | from .views import MyTokenObtainPairView 383 | from rest_framework_simplejwt.views import TokenRefreshView 384 | 385 | urlpatterns = [ 386 | path('token/', MyTokenObtainPairView.as_view(), name='token_obtain_pair'), 387 | path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), 388 | ] 389 | ``` 390 | 391 | 392 | ## Allowing Frontend Access with CORS 393 | 394 | To allow requests from our frontend application, we need to set up Cross-Origin Resource Sharing (CORS) configuration for our Django project. The [django-cors-headers](https://pypi.org/project/django-cors-headers/) library provides a simple way to enable CORS in our application. 395 | 396 | First, we need to install the ```django-cors-headers``` package by running the following command:
```pip install django-cors-headers``` 397 | 398 | Next, add ```corsheaders``` to the ```INSTALLED_APPS``` list in the ```settings.py``` file: 399 | ```python 400 | INSTALLED_APPS = [ 401 | ..., 402 | "corsheaders", 403 | ..., 404 | ] 405 | ``` 406 | After that, add the ```CorsMiddleware``` to the ```MIDDLEWARE``` list: 407 | ```python 408 | MIDDLEWARE = [ 409 | ..., 410 | "corsheaders.middleware.CorsMiddleware", 411 | "django.middleware.common.CommonMiddleware", 412 | ..., 413 | ] 414 | ``` 415 | 416 | Now we can configure the allowed origins in the ```settings.py``` file. For simplicity, we will allow all origins using the following setting: 417 | 418 | ```python 419 | CORS_ALLOW_ALL_ORIGINS = True 420 | ``` 421 | 422 | Note that this setting should be modified to specify the allowed origins during deployment for security reasons. 423 | 424 | With these settings, our Django backend is ready to receive requests from a frontend application. 425 | 426 |
427 | 428 | --- 429 | 430 | 431 | # Frontend 432 | 433 | To create the frontend for our app, we will use ```npx create-react-app``` frontend to set up a new React application. This command generates a starter project with some boilerplate code that we can customize to fit our needs. 434 | 435 | We are going to use ```npx create-react-app frontend``` for a boilerplate of our react application. 436 | 437 | To get started, navigate to the new directory with cd frontend. Next, we'll clean up some of the extra files that we won't be using, such as webVitals and the logo. In the ```/src``` folder, delete ```App.css```, ```App.test.js```, ```logo.svg```, ```reportWebVitals.js```, and ```setupTests.js```. Then modify ```App.js``` and ```index.js``` to remove all references to these deleted files: 438 | 439 | ```App.js```: 440 | ```javascript 441 | function App() { 442 | return ( 443 |
444 |
445 | ); 446 | } 447 | 448 | export default App; 449 | ``` 450 | 451 | ```index.js```: 452 | ```javascript 453 | import React from 'react'; 454 | import ReactDOM from 'react-dom/client'; 455 | import './index.css'; 456 | import App from './App'; 457 | 458 | const root = ReactDOM.createRoot(document.getElementById('root')); 459 | root.render( 460 | 461 | 462 | 463 | ); 464 | ``` 465 | 466 | At this point, the directory should have the following structure: 467 | ``` 468 | frontend 469 | ├── node_modules 470 | ├── package-lock.json 471 | ├── package.json 472 | ├── public 473 | │ ├── favicon.ico 474 | │ ├── index.html 475 | │ ├── logo192.png 476 | │ ├── logo512.png 477 | │ ├── manifest.json 478 | │ └── robots.txt 479 | └── src 480 | ├── App.js 481 | ├── index.css 482 | └── index.js 483 | ``` 484 | 485 | Now we're ready to start building our application. We'll begin by adding some folders for organization. To start, let's create a ```/pages``` folder to contain our homepage (```HomePage.js```) and login page (```LoginPage.js```). We'll also need a header shared in common on both pages, so we'll add a ```/components``` folder to contain it and other shared components. To manage state, we'll create a ```/context/AuthContext.js``` file, which will use React's built-in Context API. Finally, we'll create a ```/utils``` folder for shared logic. 486 | 487 | After all these changes, the directory should look like this: 488 | 489 | ``` 490 | frontend 491 | ├── node_modules 492 | ├── package-lock.json 493 | ├── package.json 494 | ├── public 495 | │ ├── favicon.ico 496 | │ ├── index.html 497 | │ ├── logo192.png 498 | │ ├── logo512.png 499 | │ ├── manifest.json 500 | │ └── robots.txt 501 | └── src 502 | ├── App.js 503 | ├── components 504 | │ └── Header.js 505 | ├── context 506 | │ └── AuthContext.js 507 | ├── index.css 508 | ├── index.js 509 | ├── pages 510 | │ ├── HomePage.js 511 | │ └── LoginPage.js 512 | └── utils 513 | ``` 514 | 515 | With this basic structure in place, we're ready to start building the frontend of our app. 516 | 517 | --- 518 | 519 | 520 | ## Setting up webpages 521 | 522 | Lets start with a simple homepage. This page should only be visible to users who are logged in, but for now, we'll hardcode an ```isAuthenticated``` value for demonstration purposes only. 523 | 524 | ```HomePage.js```: 525 | ```javascript 526 | import React from 'react' 527 | 528 | const HomePage = () => { 529 | const isAuthenticated = false; 530 | return ( 531 | isAuthenticated ? ( 532 |
533 |

You are logged in to the homepage!

534 |
535 | ):( 536 |
537 |

You are not logged in, redirecting...

538 |
539 | ) 540 | ) 541 | } 542 | 543 | export default HomePage 544 | ``` 545 | 546 | next we can create a simple login page, but it wont work yet without a proper ```loginUser``` function, we'll define that later: 547 | 548 | ```LoginPage.js```: 549 | ```javascript 550 | import React from 'react' 551 | 552 | const LoginPage = () => { 553 | 554 | let loginUser = (e) => { 555 | e.preventDefault() 556 | } 557 | 558 | return ( 559 |
560 |
561 | 562 | 563 | 564 |
565 |
566 | ) 567 | } 568 | 569 | export default LoginPage 570 | ``` 571 | 572 | the Header component will responsible for displaying the navigation links and user information, and it is included in the App component so that it appears on every page. Again we are using a filler function for handling logging out a user for now: 573 | 574 | ```Header.js```: 575 | ```javascript 576 | import React, { useState } from 'react' 577 | import { Link } from 'react-router-dom' 578 | 579 | const Header = () => { 580 | let [user, setUser] = useState(null) 581 | let logoutUser = (e) => { 582 | e.preventDefault() 583 | } 584 | return ( 585 |
586 | Home 587 | | 588 | {user ? ( 589 |

Logout

590 | ) : ( 591 | Login 592 | )} 593 | {user &&

Hello {user.username}!

} 594 | 595 |
596 | ) 597 | } 598 | 599 | export default Header 600 | ``` 601 | 602 | We need to setup all the url routing for these pages in ```App.js```. To do this we need to install the ```react-router-dom``` package with ```npm install react-router-dom```. It is used to handle routing, its documentation can be found [here](https://reactrouter.com/en/main). 603 | 604 | ```App.js```: 605 | ```javascript 606 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 607 | 608 | import HomePage from './pages/HomePage' 609 | import LoginPage from './pages/LoginPage' 610 | import Header from './components/Header' 611 | 612 | function App() { 613 | return ( 614 |
615 | 616 |
617 | 618 | } /> 619 | }/> 620 | 621 | 622 |
623 | ); 624 | } 625 | 626 | export default App; 627 | ``` 628 | We are finally ready to launch the frontend. Make sure youre in the ```/frontend``` directory and run ```npm start``` in the console. A development server should launch on ```localhost:3000```. 629 | 630 | you should be able to see the homepage, and if you click the Login link in the header, you should be directed to the login page. 631 | 632 |
633 | 634 | --- 635 | 636 | 637 | ## Protected routes 638 | When a user visits the homepage without being authenticated, they should be redirected to the login page. This type of page is called a private route, one that requires authentication to view. To add private routes, we first need to define a component in ```utils/PrivateRoute.js```. 639 | 640 | ```javascript 641 | import { Navigate } from 'react-router-dom' 642 | import { useState } from 'react' 643 | 644 | const PrivateRoute = ({children, ...rest}) => { 645 | let [user, setUser] = useState(null) 646 | 647 | return !user ? : children; 648 | } 649 | 650 | export default PrivateRoute; 651 | ``` 652 | 653 | This component checks if a client is authenticated. If so, the rendering continues uninterrupted. Otherwise, the client is redirected to the login page. We've used a separate state to store the user here, but we want this user state to match the user state in the header. This is where context state management comes in, and we'll cover that later. 654 | 655 | To protect a route, we just need to wrap the ```Route``` component in a `````` component like so: 656 | 657 | ```javascript 658 | 659 | ... 660 | } /> 661 | ... 662 | 663 | ``` 664 | 665 | This protects the homepage route, meaning a user cannot access the page until they are authenticated, and will instead be redirected to the login page. 666 | 667 | Here's the updated ```App.js``` file with a protected homepage: 668 | 669 | ```javascript 670 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 671 | 672 | import HomePage from './pages/HomePage' 673 | import LoginPage from './pages/LoginPage' 674 | import Header from './components/Header' 675 | 676 | import PrivateRoute from './utils/PrivateRoute' 677 | 678 | 679 | function App() { 680 | return ( 681 |
682 | 683 |
684 | 685 | } /> 686 | }/> 687 | 688 | 689 |
690 | ); 691 | } 692 | 693 | export default App; 694 | ``` 695 | 696 | Now you should be unable to load the homepage until a user is authenticated, and will instead be redirected to the login page. 697 | 698 | --- 699 | 700 | 701 | ## AuthContext - state management 702 | 703 | We want to save the authentication tokens and user state and use it throughout the application, so to avoid prop drilling or other more complicated options, we'll use the useContext hook built into React. 704 | 705 | 706 | #### ```createContext()``` 707 | ```createContext()``` is a function provided by the React library that allows you to create a context object. This object provides a way to pass data between components without having to pass props down through the component tree. It consists of a provider component that wraps the other components and passes data down to them, and a consumer component that accesses the data passed down from the provider. 708 | 709 | In this case, we use the createContext() function to create an AuthContext object, which we then export and use as a shared state across our application. We define the initial state and any methods that we want to share in the AuthProvider component, and then wrap our components with this provider so that they have access to this shared state. 710 | 711 | To start we will define the state we know we want shared across our application in an ```AuthProvider``` component, including a ```user```, ```authTokens```, ```loginUser``` method and ```logoutUser``` method. 712 | 713 | ```javascript 714 | import { createContext, useState } from 'react' 715 | 716 | const AuthContext = createContext() 717 | 718 | export default AuthContext; 719 | 720 | export const AuthProvider = ({children}) => { 721 | 722 | let [user, setUser] = useState(null) 723 | let [authTokens, setAuthTokens] = useState(null) 724 | 725 | let loginUser = async (e) => { 726 | e.preventDefault() 727 | } 728 | 729 | let logoutUser = (e) => { 730 | e.preventDefault() 731 | } 732 | 733 | let contextData = { 734 | user: user, 735 | authTokens: authTokens, 736 | loginUser: loginUser, 737 | logoutUser: logoutUser, 738 | } 739 | 740 | return( 741 | 742 | {children} 743 | 744 | ) 745 | } 746 | ``` 747 | 748 | Then we can provide this state to the other components by wrapping them in an `````` component: 749 | 750 | ```App.js``` 751 | ```javascript 752 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 753 | 754 | import { AuthProvider } from './context/AuthContext' 755 | 756 | import HomePage from './pages/HomePage' 757 | import LoginPage from './pages/LoginPage' 758 | import Header from './components/Header' 759 | 760 | import PrivateRoute from './utils/PrivateRoute' 761 | 762 | 763 | function App() { 764 | return ( 765 |
766 | 767 | 768 |
769 | 770 | 772 | 773 | }/> 774 | }/> 775 | 776 | 777 | 778 |
779 | ); 780 | } 781 | 782 | export default App; 783 | ``` 784 | 785 | #### ```useContext()``` 786 | useContext() is a hook provided by the React library that allows you to consume the data and methods passed down by a context provider. It takes in a context object created by createContext() and returns the current value of the context. 787 | 788 | In our application, we use useContext() to access the shared state and methods defined in our AuthContext object. We call useContext(AuthContext) inside our components to access the current user state, authentication tokens, login function, and logout function. This allows us to avoid prop drilling and pass data and methods down from the top-level component to the components that need them. 789 | 790 | E.g. 791 | ```javascript 792 | let { user, loginUser } = useContext(AuthContext) 793 | ``` 794 | 795 | We need to make this change in 4 places: 796 | 797 | 1. ```Header.js```, after adjusting to use the shared context and ```logoutUser``` method, looks like: 798 | 799 | ```javascript 800 | import React, { useContext } from 'react' 801 | import { Link } from 'react-router-dom' 802 | import AuthContext from '../context/AuthContext' 803 | 804 | const Header = () => { 805 | let { user, logoutUser } = useContext(AuthContext) 806 | 807 | return ( 808 |
809 | Home 810 | | 811 | {user ? ( 812 |

Logout

813 | ) : ( 814 | Login 815 | )} 816 | {user &&

Hello {user.username}!

} 817 | 818 |
819 | ) 820 | } 821 | 822 | export default Header 823 | ``` 824 | 825 | 2. ```LoginPage.js```, after adjusting to use the shared ```loginUser``` method, looks like: 826 | ```javascript 827 | import React, {useContext} from 'react' 828 | import AuthContext from '../context/AuthContext' 829 | 830 | const LoginPage = () => { 831 | 832 | let {loginUser} = useContext(AuthContext) 833 | 834 | return ( 835 |
836 |
837 | 838 | 839 | 840 |
841 |
842 | ) 843 | } 844 | 845 | export default LoginPage 846 | ``` 847 | 848 | 3. ```Homepage.js``` is also adjusted to use the AuthContext for user state: 849 | ```javascript 850 | import React, { useContext } from 'react' 851 | import AuthContext from '../context/AuthContext'; 852 | 853 | const HomePage = () => { 854 | const { user } = useContext(AuthContext); 855 | 856 | return (user ? ( 857 |
858 |

You are logged in to the homepage!

859 |
860 | ):( 861 |
862 |

You are not logged in, redirecting...

863 |
864 | ) 865 | ) 866 | } 867 | 868 | export default HomePage 869 | ``` 870 | 871 | 4. The last place to make this change is in ```PrivateRoute.js``` 872 | ```javascript 873 | import { Navigate } from 'react-router-dom' 874 | import { useContext } from 'react' 875 | import AuthContext from '../context/AuthContext'; 876 | 877 | const PrivateRoute = ({children, ...rest}) => { 878 | let { user } = useContext(AuthContext) 879 | 880 | return !user ? : children; 881 | } 882 | 883 | export default PrivateRoute; 884 | ``` 885 | 886 | we can test this is working by changing the state of user in AuthContext.js and verifying that our header now shows a greeting to the user and offers a logout option instead of a login option. 887 | 888 | ```javascript 889 | let [user, setUser] = useState({username:'Sean'}) 890 | ``` 891 | 892 | return the state to null after testing. 893 | 894 | 895 | ## Login method 896 | 897 | The loginUser method is responsible for handling the user's login attempt by submitting a POST request to the backend server with the user's login credentials. The response should contain auth tokens, which need to be decoded so that the payload data can be read. The [jwt-decode](https://www.npmjs.com/package/jwt-decode) package can be installed to help with this. ```npm install jwt-decode``` 898 | 899 | If the POST request is successful, the newly received tokens and the successfully logged-in user should be stored in state, and the tokens saved in local storage. The user should then be redirected to their homepage. If there is an error, an alert should be shown. 900 | 901 | Here's the code for the entire AuthProvider component, with the new method: 902 | ```javascript 903 | import { createContext, useState } from 'react' 904 | import jwtDecode from 'jwt-decode'; 905 | import { useNavigate } from 'react-router-dom' 906 | 907 | const AuthContext = createContext() 908 | 909 | export default AuthContext; 910 | 911 | export const AuthProvider = ({children}) => { 912 | 913 | let [user, setUser] = useState(null) 914 | let [authTokens, setAuthTokens] = useState(null) 915 | 916 | const navigate = useNavigate() 917 | 918 | let loginUser = async (e) => { 919 | e.preventDefault() 920 | const response = await fetch('http://127.0.0.1:8000/api/token/', { 921 | method: 'POST', 922 | headers: { 923 | 'Content-Type': 'application/json' 924 | }, 925 | body: JSON.stringify({username: e.target.username.value, password: e.target.password.value }) 926 | }); 927 | 928 | let data = await response.json(); 929 | 930 | if(data){ 931 | localStorage.setItem('authTokens', JSON.stringify(data)); 932 | setAuthTokens(data) 933 | setUser(jwtDecode(data.access)) 934 | navigate('/') 935 | } else { 936 | alert('Something went wrong while loggin in the user!') 937 | } 938 | } 939 | 940 | let logoutUser = (e) => { 941 | e.preventDefault() 942 | } 943 | 944 | let contextData = { 945 | user: user, 946 | authTokens: authTokens, 947 | loginUser: loginUser, 948 | logoutUser: logoutUser, 949 | } 950 | 951 | return( 952 | 953 | {children} 954 | 955 | ) 956 | } 957 | ``` 958 | 959 | After submitting the superuser credentials on the login page, if the request is successful, the user should be logged in and redirected to the home page. 960 | 961 | 962 | ## Logout method 963 | 964 | The logout link isn't working yet, so let's fix that. To logout a user, we need to 965 | - Clear the ```localStorage``` by removing the stored authentication tokens 966 | - Update the state of the ```authTokens``` and ```user``` to null, effectively logging the user out 967 | - Redirect the user is redirected to the login page using the ```navigate``` method from ```react-router-dom```: 968 | 969 | ```javascript 970 | let logoutUser = (e) => { 971 | e.preventDefault() 972 | localStorage.removeItem('authTokens') 973 | setAuthTokens(null) 974 | setUser(null) 975 | navigate('/login') 976 | } 977 | ``` 978 | 979 | Now when you click on the logout link, you should be logged out and redirected to the login page. Confirm the ```localStorage``` is cleared in the storage tab of the developer tools. 980 | 981 | --- 982 | 983 | 984 | ## Keeping a user logged in after refresh 985 | 986 | After submitting login details and being redirected to the homepage, refreshing the page logs the user out. To prevent this, we can use JSON Web Tokens (JWT) stored in localStorage to automatically log the user back in without requiring login credentials. 987 | 988 | To achieve this, we need to update the initial state of the user and authTokens variables in AuthContext.js to check the localStorage for authTokens before setting them to null if none are found. We can use a callback function in the useState hook to ensure that this logic is only executed once on the initial load of AuthProvider, and not on every rerender. 989 | 990 | Here are the updated lines of code: 991 | 992 | ```AuthContext.js``` 993 | ```javascript 994 | let [user, setUser] = useState(() => (localStorage.getItem('authTokens') ? jwtDecode(localStorage.getItem('authTokens')) : null)) 995 | let [authTokens, setAuthTokens] = useState(() => (localStorage.getItem('authTokens') ? JSON.parse(localStorage.getItem('authTokens')) : null)) 996 | ``` 997 | 998 | After submitting login credentials, redirecting to the homepage, and refreshing the page, the user should remain logged in. 999 | 1000 | --- 1001 | 1002 | ## UpdateToken method - Refreshing the access token 1003 | 1004 | The access token, as currently configured, has a limited lifetime of 5 minutes, after which a new one must be generated using the refresh token. To handle this, we need to create an ```updateToken``` method. This is the setting of interest: 1005 | 1006 | ```python 1007 | ... 1008 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), 1009 | ... 1010 | ``` 1011 | 1012 | The updateToken method sends a POST request to ```http://127.0.0.1:8000/api/token/refresh/``` containing the refresh token, and receives a new access token and refresh token to save in ```localStorage``` and update the context state. If an invalid refresh token is used, the user is logged out. Here is the code for the ```updateToken``` method: 1013 | 1014 | ```javascript 1015 | const updateToken = async () => { 1016 | const response = await fetch('http://127.0.0.1:8000/api/token/refresh/', { 1017 | method: 'POST', 1018 | headers: { 1019 | 'Content-Type':'application/json' 1020 | }, 1021 | body:JSON.stringify({refresh:authTokens?.refresh}) 1022 | }) 1023 | 1024 | const data = await response.json() 1025 | if (response.status === 200) { 1026 | setAuthTokens(data) 1027 | setUser(jwtDecode(data.access)) 1028 | localStorage.setItem('authTokens',JSON.stringify(data)) 1029 | } else { 1030 | logoutUser() 1031 | } 1032 | 1033 | if(loading){ 1034 | setLoading(false) 1035 | } 1036 | } 1037 | ``` 1038 | 1039 | ## Refreshing the Token on an Interval 1040 | 1041 | To keep the user authenticated, we need to refresh their access token before it expires. In our case, we will refresh the token every 4 minutes to avoid the possibility of a slow server response causing the user to be logged out. This approach has obvious drawbacks, and surely a better and more popular approach would be to refresh these tokens on every call to the server with Axios interceptors. I plan to explore this in the future, but for now we will update the tokens on an interval using the ```useEffect``` hook. Here is the code for that ```useEffect``` hook: 1042 | 1043 | ```javascript 1044 | let [loading, setLoading] = useState(true) 1045 | 1046 | useEffect(()=>{ 1047 | 1048 | const REFRESH_INTERVAL = 1000 * 60 * 4 // 4 minutes 1049 | let interval = setInterval(()=>{ 1050 | if(authTokens){ 1051 | updateToken() 1052 | } 1053 | }, REFRESH_INTERVAL) 1054 | return () => clearInterval(interval) 1055 | 1056 | },[authTokens]) 1057 | ``` 1058 | 1059 | The useEffect hook uses JavaScript's built-in ```setInterval``` function to execute a callback function at a set interval in milliseconds. We need to clear the existing interval when the hook is triggered again to avoid multiple intervals being created. We also need to track when the page is loading using the ```loading``` state, which is initially set to ```true```. 1060 | 1061 | --- 1062 | 1063 | Our new ```AuthContext.js```: 1064 | ```javascript 1065 | import { createContext, useState, useEffect } from 'react' 1066 | import jwtDecode from 'jwt-decode'; 1067 | import { useNavigate } from 'react-router-dom' 1068 | 1069 | const AuthContext = createContext() 1070 | 1071 | export default AuthContext; 1072 | 1073 | export const AuthProvider = ({children}) => { 1074 | 1075 | let [user, setUser] = useState(() => (localStorage.getItem('authTokens') ? jwtDecode(localStorage.getItem('authTokens')) : null)) 1076 | let [authTokens, setAuthTokens] = useState(() => (localStorage.getItem('authTokens') ? JSON.parse(localStorage.getItem('authTokens')) : null)) 1077 | let [loading, setLoading] = useState(true) 1078 | 1079 | const navigate = useNavigate() 1080 | 1081 | let loginUser = async (e) => { 1082 | e.preventDefault() 1083 | const response = await fetch('http://127.0.0.1:8000/api/token/', { 1084 | method: 'POST', 1085 | headers: { 1086 | 'Content-Type': 'application/json' 1087 | }, 1088 | body: JSON.stringify({username: e.target.username.value, password: e.target.password.value }) 1089 | }); 1090 | 1091 | let data = await response.json(); 1092 | 1093 | if(data){ 1094 | localStorage.setItem('authTokens', JSON.stringify(data)); 1095 | setAuthTokens(data) 1096 | setUser(jwtDecode(data.access)) 1097 | navigate('/') 1098 | } else { 1099 | alert('Something went wrong while logging in the user!') 1100 | } 1101 | } 1102 | 1103 | let logoutUser = (e) => { 1104 | e.preventDefault() 1105 | localStorage.removeItem('authTokens') 1106 | setAuthTokens(null) 1107 | setUser(null) 1108 | navigate('/login') 1109 | } 1110 | 1111 | const updateToken = async () => { 1112 | const response = await fetch('http://127.0.0.1:8000/api/token/refresh/', { 1113 | method: 'POST', 1114 | headers: { 1115 | 'Content-Type':'application/json' 1116 | }, 1117 | body:JSON.stringify({refresh:authTokens?.refresh}) 1118 | }) 1119 | 1120 | const data = await response.json() 1121 | if (response.status === 200) { 1122 | setAuthTokens(data) 1123 | setUser(jwtDecode(data.access)) 1124 | localStorage.setItem('authTokens',JSON.stringify(data)) 1125 | } else { 1126 | logoutUser() 1127 | } 1128 | 1129 | if(loading){ 1130 | setLoading(false) 1131 | } 1132 | } 1133 | 1134 | let contextData = { 1135 | user:user, 1136 | authTokens:authTokens, 1137 | loginUser:loginUser, 1138 | logoutUser:logoutUser, 1139 | } 1140 | 1141 | useEffect(()=>{ 1142 | const REFRESH_INTERVAL = 1000 * 60 * 4 // 4 minutes 1143 | let interval = setInterval(()=>{ 1144 | if(authTokens){ 1145 | updateToken() 1146 | } 1147 | }, REFRESH_INTERVAL) 1148 | return () => clearInterval(interval) 1149 | 1150 | },[authTokens]) 1151 | 1152 | return( 1153 | 1154 | {children} 1155 | 1156 | ) 1157 | } 1158 | ``` 1159 | 1160 | --- 1161 | 1162 | ## Edge cases: 1163 | 1164 | Let's consider an edge case where the ```REFRESH_TOKEN_LIFETIME``` setting on the backend is set to a short duration, say 5 seconds. After logging in, if a token refresh is triggered, you'll receive a ```401 Unauthorized access``` response when a call is made to update the tokens. This is because the refresh token has expired, and login credentials are required to authenticate the user again. To simulate this edge case, you can set the token refresh interval to 10000 ms (10 seconds). 1165 | 1166 | To ensure that a user is logged out and redirected to the login page when accessing a protected route with an expired access token, and is not logged out and redirected while waiting for a response to an ```updateToken``` request, we need to keep track of when the ```AuthProvider``` is first loaded. We can achieve this by initializing a new state variable, ```loading```, to ```true```: 1167 | 1168 | ```javascript 1169 | let [loading, setLoading] = useState(true) 1170 | ``` 1171 | 1172 | If the state is ```loading```, we want to attempt to update the tokens at the beginning of the ```useEffect``` hook. This will fetch new refresh tokens where possible, and redirect users with invalid tokens back to the login screen: 1173 | 1174 | ```javascript 1175 | useEffect(()=>{ 1176 | if(loading){ 1177 | updateToken() 1178 | } 1179 | ... 1180 | },[authTokens, loading]) 1181 | ``` 1182 | 1183 | Finally, at the end of the ```updateToken()``` function, set the ```loading``` state to ```false```: 1184 | 1185 | ```javascript 1186 | const updateToken = async () => { 1187 | ... 1188 | if(loading){ 1189 | setLoading(false) 1190 | } 1191 | } 1192 | ``` 1193 | 1194 | With this approach, we ensure that the user is logged out and redirected to the login page only when the access token has expired, and not while waiting for a response to an updateToken request. 1195 | 1196 | # User Permissions - control access to user-specific data 1197 | To control access to user-specific data, we need to extend the default Django ```User``` model by adding a ```Profile``` model with a one-to-one relationship. The ```Profile``` model will contain private information such as first name, last name, and email. We will display each user their own profile information when on the home page. 1198 | 1199 | ## Setting up user-specific data in django 1200 | To start we need to return to the backend and add the ```Profile``` model to the ```models.py``` file: 1201 | 1202 | ```models.py``` 1203 | ```python 1204 | from django.db import models 1205 | from django.contrib.auth.models import User 1206 | 1207 | class Profile(models.Model): 1208 | user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') 1209 | first_name = models.CharField(max_length=100) 1210 | last_name = models.CharField(max_length=100) 1211 | email = models.EmailField() 1212 | 1213 | def __str__(self): 1214 | return self.user.username 1215 | ``` 1216 | We also need a serializer for the new ```Profile``` model. Create a ```serializers.py``` file inside the ```/base``` directory. We define a simple serializer for ```User``` so we can nest it inside the ```ProfileSerializer```: 1217 | 1218 | ```serializers.py``` 1219 | ```python 1220 | from rest_framework import serializers 1221 | from base.models import * 1222 | 1223 | class UserSerializer(serializers.ModelSerializer): 1224 | class Meta: 1225 | model = User 1226 | fields = '__all__' 1227 | 1228 | class ProfileSerializer(serializers.ModelSerializer): 1229 | user = UserSerializer(many=False, read_only=True) 1230 | class Meta: 1231 | model = Profile 1232 | fields = ('user', 'first_name', 'last_name', 'email') 1233 | ``` 1234 | 1235 | We make this data available via the``` /api``` route with a new view. We use a new decorator from the Django REST framework, ```@permission_classes``` to verify that the user is authenticated with ```request.user``` before any of the other code in the view is executed: (Documentation on permissions can be found [here](https://www.django-rest-framework.org/api-guide/permissions/)) 1236 | 1237 | ```python 1238 | @api_view(['GET']) 1239 | @permission_classes([IsAuthenticated]) 1240 | def get_profile(request): 1241 | user = request.user 1242 | profile = user.profile 1243 | serializer = ProfileSerializer(profile, many=False) 1244 | return Response(serializer.data) 1245 | ``` 1246 | 1247 | then we can link this view in the ```urls.py``` file: 1248 | 1249 | ```python 1250 | urlpatterns = [ 1251 | path('profile/', views.get_profile), 1252 | ... 1253 | ] 1254 | ``` 1255 | 1256 | --- 1257 | 1258 | ## Testing user permissions - displaying private profile info 1259 | 1260 | To test user permissions, we need to migrate the data model changes with: 1261 | 1262 | ```python manage.py makemigrations``` 1263 | 1264 | ```python manage.py migrate``` 1265 | 1266 | You may need to delete all previous users or add ```null=True``` to the model fields to migrate the changes. 1267 | 1268 | Create two users, each with associated profiles: 1269 | 1270 | e.g. 1271 | ``` 1272 | username: "user1", 1273 | password: "password1", 1274 | profile: { 1275 | first_name: "Sam", 1276 | last_name: "Smith", 1277 | email: "sam@smith.com" 1278 | } 1279 | 1280 | username: "user2", 1281 | password: "password2", 1282 | profile: { 1283 | first_name: "Tim", 1284 | last_name: "Allen", 1285 | email: "tim@allen.com" 1286 | } 1287 | 1288 | ``` 1289 | 1290 | If you try to access ```http://127.0.0.1:8000/api/profile``` now, you will get an ```"Unauthorized"``` response. By default, the GET request does not include any authentication details. To authenticate, we need to go back to the frontend and change our homepage to render these details specific to the authenticated user. We have defined a ```getProfile()``` function to fetch the profile data from the server, including our auth access token with the GET request. We have also added a state to store our profile data. (If this data were used in other places throughout the application, you may consider moving this to a context for shared state management.) Lastly, the ```useEffect``` hook is used to fetch the profile data once on the first load of the component, provided the blank dependency array. 1291 | 1292 | ```HomePage.js``` 1293 | ```javascript 1294 | import React, { useState, useEffect, useContext } from 'react' 1295 | import AuthContext from '../context/AuthContext'; 1296 | 1297 | const HomePage = () => { 1298 | const { authTokens, logoutUser } = useContext(AuthContext); 1299 | let [profile, setProfile] = useState([]) 1300 | 1301 | useEffect(() => { 1302 | getProfile() 1303 | },[]) 1304 | 1305 | const getProfile = async() => { 1306 | let response = await fetch('http://127.0.0.1:8000/api/profile', { 1307 | method: 'GET', 1308 | headers:{ 1309 | 'Content-Type': 'application/json', 1310 | 'Authorization':'Bearer ' + String(authTokens.access) 1311 | } 1312 | }) 1313 | let data = await response.json() 1314 | console.log(data) 1315 | if(response.status === 200){ 1316 | setProfile(data) 1317 | } else if(response.statusText === 'Unauthorized'){ 1318 | logoutUser() 1319 | } 1320 | } 1321 | 1322 | return ( 1323 |
1324 |

You are logged in to the homepage!

1325 |

Name: {profile.first_name} {profile.last_name}

1326 |

Email: {profile.email}

1327 |
1328 | ) 1329 | } 1330 | 1331 | export default HomePage 1332 | ``` 1333 | 1334 | Now when you navigate to ```http://localhost:3000/login``` and login with (```username: "user1", password: "password1"```) you should see the profile details for this user on the home page: 1335 | ``` 1336 | Name: Sam Smith 1337 | Email: sam@smith.com 1338 | ``` 1339 | 1340 | and when you login to a different user (```username: "user2", password: "password2"```) you should see their profile details: 1341 | ``` 1342 | Name: Tim Allen 1343 | Email: tim@allen.com 1344 | ``` 1345 | 1346 | --- 1347 | -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | 8 | [dev-packages] 9 | 10 | [requires] 11 | python_version = "3.10" 12 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | ### ```python manage.py runserver``` 2 | launches the development server at ```127.0.0.1:8000``` 3 | 4 | ### ```python manage.py runserver ``` 5 | launches the development server at a custom port 6 | 7 | ### ```python manage.py startapp ``` 8 | creates a new app in the current directory 9 | 10 | ### ```python manage.py createsuperuser``` 11 | starts admin user creation process in terminal 12 | 13 | -------------------------------------------------------------------------------- /backend/backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seankwarren/Django-React-jwt-authentication/fba8b09a3191ec05b7542cc6d8ad5f568a481c47/backend/backend/__init__.py -------------------------------------------------------------------------------- /backend/backend/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for backend project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /backend/backend/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for backend project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | from datetime import timedelta 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "django-insecure-o&qo$m$13v0*=0t+#l&$j^vnvpz@vitmabf6h#b@!@4zy1w!qx" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "rest_framework", 42 | 'rest_framework_simplejwt.token_blacklist', 43 | 'corsheaders', 44 | 'base', 45 | ] 46 | 47 | CORS_ALLOW_ALL_ORIGINS = True 48 | 49 | REST_FRAMEWORK = { 50 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 51 | 'rest_framework_simplejwt.authentication.JWTAuthentication', 52 | ) 53 | } 54 | 55 | SIMPLE_JWT = { 56 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), 57 | "REFRESH_TOKEN_LIFETIME": timedelta(days=15), 58 | "ROTATE_REFRESH_TOKENS": True, 59 | "BLACKLIST_AFTER_ROTATION": True, 60 | "UPDATE_LAST_LOGIN": False, 61 | 62 | "ALGORITHM": "HS256", 63 | "SIGNING_KEY": SECRET_KEY, 64 | "VERIFYING_KEY": "", 65 | "AUDIENCE": None, 66 | "ISSUER": None, 67 | "JSON_ENCODER": None, 68 | "JWK_URL": None, 69 | "LEEWAY": 0, 70 | 71 | "AUTH_HEADER_TYPES": ("Bearer",), 72 | "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", 73 | "USER_ID_FIELD": "id", 74 | "USER_ID_CLAIM": "user_id", 75 | "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", 76 | 77 | "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), 78 | "TOKEN_TYPE_CLAIM": "token_type", 79 | "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", 80 | 81 | "JTI_CLAIM": "jti", 82 | 83 | "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", 84 | "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), 85 | "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), 86 | 87 | "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.MyTokenObtainPairSerializer", 88 | "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", 89 | "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", 90 | "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", 91 | "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", 92 | "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", 93 | } 94 | 95 | MIDDLEWARE = [ 96 | "corsheaders.middleware.CorsMiddleware", 97 | "django.middleware.common.CommonMiddleware", 98 | "django.middleware.security.SecurityMiddleware", 99 | "django.contrib.sessions.middleware.SessionMiddleware", 100 | "django.middleware.common.CommonMiddleware", 101 | "django.middleware.csrf.CsrfViewMiddleware", 102 | "django.contrib.auth.middleware.AuthenticationMiddleware", 103 | "django.contrib.messages.middleware.MessageMiddleware", 104 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 105 | ] 106 | 107 | ROOT_URLCONF = "backend.urls" 108 | 109 | TEMPLATES = [ 110 | { 111 | "BACKEND": "django.template.backends.django.DjangoTemplates", 112 | "DIRS": [], 113 | "APP_DIRS": True, 114 | "OPTIONS": { 115 | "context_processors": [ 116 | "django.template.context_processors.debug", 117 | "django.template.context_processors.request", 118 | "django.contrib.auth.context_processors.auth", 119 | "django.contrib.messages.context_processors.messages", 120 | ], 121 | }, 122 | }, 123 | ] 124 | 125 | WSGI_APPLICATION = "backend.wsgi.application" 126 | 127 | # Database 128 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 129 | 130 | DATABASES = { 131 | "default": { 132 | "ENGINE": "django.db.backends.sqlite3", 133 | "NAME": BASE_DIR / "db.sqlite3", 134 | } 135 | } 136 | 137 | 138 | # Password validation 139 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 140 | 141 | AUTH_PASSWORD_VALIDATORS = [ 142 | { 143 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 144 | }, 145 | { 146 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 147 | }, 148 | { 149 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 150 | }, 151 | { 152 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 153 | }, 154 | ] 155 | 156 | 157 | # Internationalization 158 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 159 | 160 | LANGUAGE_CODE = "en-us" 161 | 162 | TIME_ZONE = "UTC" 163 | 164 | USE_I18N = True 165 | 166 | USE_TZ = True 167 | 168 | 169 | # Static files (CSS, JavaScript, Images) 170 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 171 | 172 | STATIC_URL = "static/" 173 | 174 | # Default primary key field type 175 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 176 | 177 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 178 | -------------------------------------------------------------------------------- /backend/backend/urls.py: -------------------------------------------------------------------------------- 1 | """backend URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.contrib import admin 18 | from django.urls import path, include 19 | 20 | urlpatterns = [ 21 | path('admin/', admin.site.urls), 22 | path('api/', include('base.api.urls')) 23 | ] 24 | -------------------------------------------------------------------------------- /backend/backend/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for backend project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seankwarren/Django-React-jwt-authentication/fba8b09a3191ec05b7542cc6d8ad5f568a481c47/backend/base/__init__.py -------------------------------------------------------------------------------- /backend/base/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import * 3 | 4 | # Register your models here. 5 | 6 | admin.site.register(Profile) -------------------------------------------------------------------------------- /backend/base/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seankwarren/Django-React-jwt-authentication/fba8b09a3191ec05b7542cc6d8ad5f568a481c47/backend/base/api/__init__.py -------------------------------------------------------------------------------- /backend/base/api/urls.py: -------------------------------------------------------------------------------- 1 | 2 | from django.urls import path 3 | from . import views 4 | from .views import MyTokenObtainPairView 5 | 6 | from rest_framework_simplejwt.views import ( 7 | TokenRefreshView, 8 | ) 9 | 10 | urlpatterns = [ 11 | path('profile/', views.get_profile), 12 | path('token/', MyTokenObtainPairView.as_view(), name='token_obtain_pair'), 13 | path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), 14 | ] 15 | -------------------------------------------------------------------------------- /backend/base/api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.response import Response 2 | from rest_framework.decorators import api_view, permission_classes 3 | from rest_framework.permissions import IsAuthenticated 4 | 5 | from rest_framework_simplejwt.serializers import TokenObtainPairSerializer 6 | from rest_framework_simplejwt.views import TokenObtainPairView 7 | 8 | from base.serializer import ProfileSerializer 9 | 10 | class MyTokenObtainPairSerializer(TokenObtainPairSerializer): 11 | @classmethod 12 | def get_token(cls, user): 13 | token = super().get_token(user) 14 | 15 | token['username'] = user.username 16 | 17 | return token 18 | 19 | class MyTokenObtainPairView(TokenObtainPairView): 20 | serializer_class = MyTokenObtainPairSerializer 21 | 22 | @api_view(['GET']) 23 | @permission_classes([IsAuthenticated]) 24 | def get_profile(request): 25 | user = request.user 26 | profile = user.profile 27 | serializer = ProfileSerializer(profile, many=False) 28 | return Response(serializer.data) 29 | -------------------------------------------------------------------------------- /backend/base/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | class BaseConfig(AppConfig): 4 | default_auto_field = "django.db.models.BigAutoField" 5 | name = "base" 6 | 7 | def ready(self): 8 | from .signals import create_profile, save_profile 9 | -------------------------------------------------------------------------------- /backend/base/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-02-16 18:11 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Profile", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("first_name", models.CharField(max_length=100)), 30 | ( 31 | "user", 32 | models.OneToOneField( 33 | on_delete=django.db.models.deletion.CASCADE, 34 | to=settings.AUTH_USER_MODEL, 35 | ), 36 | ), 37 | ], 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /backend/base/migrations/0002_profile_email_profile_last_name_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-02-16 18:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("base", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="profile", 15 | name="email", 16 | field=models.EmailField(max_length=254, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name="profile", 20 | name="last_name", 21 | field=models.CharField(max_length=100, null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name="profile", 25 | name="first_name", 26 | field=models.CharField(max_length=100, null=True), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /backend/base/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seankwarren/Django-React-jwt-authentication/fba8b09a3191ec05b7542cc6d8ad5f568a481c47/backend/base/migrations/__init__.py -------------------------------------------------------------------------------- /backend/base/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | 4 | class Profile(models.Model): 5 | user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') 6 | first_name = models.CharField(max_length=100) 7 | last_name = models.CharField(max_length=100) 8 | email = models.EmailField() 9 | 10 | def __str__(self): 11 | return self.user.username 12 | -------------------------------------------------------------------------------- /backend/base/serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from base.models import * 3 | 4 | class UserSerializer(serializers.ModelSerializer): 5 | class Meta: 6 | model = User 7 | fields = '__all__' 8 | 9 | class ProfileSerializer(serializers.ModelSerializer): 10 | user = UserSerializer(many=False, read_only=True) 11 | class Meta: 12 | model = Profile 13 | fields = ('user', 'first_name', 'last_name', 'email') -------------------------------------------------------------------------------- /backend/base/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save #Import a post_save signal when a user is created 2 | from django.contrib.auth.models import User # Import the built-in User model, which is a sender 3 | from django.dispatch import receiver # Import the receiver 4 | from .models import Profile 5 | 6 | 7 | @receiver(post_save, sender=User) 8 | def create_profile(sender, instance, created, **kwargs): 9 | if created: 10 | Profile.objects.create(user=instance) 11 | 12 | 13 | @receiver(post_save, sender=User) 14 | def save_profile(sender, instance, **kwargs): 15 | instance.profile.save() -------------------------------------------------------------------------------- /backend/base/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/base/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React-Django JWT Authentication - Frontend 2 | 3 | ### `npm start` 4 | 5 | Runs the app in the development mode.\ 6 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 7 | 8 | The page will reload when you make changes.\ 9 | You may also see any lint errors in the console. -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "jwt-decode": "^3.1.2", 10 | "react": "^18.2.0", 11 | "react-dom": "^18.2.0", 12 | "react-router-dom": "^6.8.1", 13 | "react-scripts": "5.0.1", 14 | "web-vitals": "^2.1.4" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": [ 24 | "react-app", 25 | "react-app/jest" 26 | ] 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seankwarren/Django-React-jwt-authentication/fba8b09a3191ec05b7542cc6d8ad5f568a481c47/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seankwarren/Django-React-jwt-authentication/fba8b09a3191ec05b7542cc6d8ad5f568a481c47/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seankwarren/Django-React-jwt-authentication/fba8b09a3191ec05b7542cc6d8ad5f568a481c47/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' 2 | 3 | import { AuthProvider } from './context/AuthContext' 4 | 5 | import HomePage from './pages/HomePage' 6 | import LoginPage from './pages/LoginPage' 7 | import Header from './components/Header' 8 | 9 | import PrivateRoute from './utils/PrivateRoute' 10 | 11 | 12 | function App() { 13 | return ( 14 |
15 | 16 | 17 |
18 | 19 | } /> 20 | }/> 21 | 22 | 23 | 24 |
25 | ); 26 | } 27 | 28 | export default App; 29 | -------------------------------------------------------------------------------- /frontend/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import AuthContext from '../context/AuthContext' 4 | 5 | const Header = () => { 6 | let { user, logoutUser } = useContext(AuthContext) 7 | 8 | return ( 9 |
10 | Home 11 | | 12 | {user ? ( 13 | Logout 14 | ) : ( 15 | Login 16 | )} 17 | {user &&

Hello {user.username}!

} 18 | 19 |
20 | ) 21 | } 22 | 23 | export default Header -------------------------------------------------------------------------------- /frontend/src/context/AuthContext.js: -------------------------------------------------------------------------------- 1 | import { createContext, useState, useEffect } from 'react' 2 | import {jwtDecode} from 'jwt-decode'; 3 | import { useNavigate } from 'react-router-dom' 4 | 5 | const AuthContext = createContext() 6 | 7 | export default AuthContext; 8 | 9 | export const AuthProvider = ({children}) => { 10 | 11 | let [user, setUser] = useState(() => (localStorage.getItem('authTokens') ? jwtDecode(localStorage.getItem('authTokens')) : null)) 12 | let [authTokens, setAuthTokens] = useState(() => (localStorage.getItem('authTokens') ? JSON.parse(localStorage.getItem('authTokens')) : null)) 13 | let [loading, setLoading] = useState(true) 14 | 15 | const navigate = useNavigate() 16 | 17 | let loginUser = async (e) => { 18 | e.preventDefault() 19 | const response = await fetch('http://127.0.0.1:8000/api/token/', { 20 | method: 'POST', 21 | headers: { 22 | 'Content-Type': 'application/json' 23 | }, 24 | body: JSON.stringify({username: e.target.username.value, password: e.target.password.value }) 25 | }); 26 | 27 | let data = await response.json(); 28 | // With wrong credentials the code get stuck with parsing error message 29 | // because the response has text message not the access and request tocken in required format 30 | // performing a check on success of login will prevent this error. 31 | // a robust error handling can be implemented but below modification a work around to carry on with the tutorial 32 | 33 | if(data && response.ok){ 34 | localStorage.setItem('authTokens', JSON.stringify(data)); 35 | setAuthTokens(data) 36 | setUser(jwtDecode(data.access)) 37 | navigate('/') 38 | } else { 39 | alert('Check login credentials :Something went wrong while logging in the user!') 40 | } 41 | } 42 | 43 | let logoutUser = () => { 44 | // e.preventDefault() 45 | localStorage.removeItem('authTokens') 46 | setAuthTokens(null) 47 | setUser(null) 48 | navigate('/login') 49 | } 50 | 51 | const updateToken = async () => { 52 | const response = await fetch('http://127.0.0.1:8000/api/token/refresh/', { 53 | method: 'POST', 54 | headers: { 55 | 'Content-Type':'application/json' 56 | }, 57 | body:JSON.stringify({refresh:authTokens?.refresh}) 58 | }) 59 | 60 | const data = await response.json() 61 | if (response.status === 200) { 62 | setAuthTokens(data) 63 | setUser(jwtDecode(data.access)) 64 | localStorage.setItem('authTokens',JSON.stringify(data)) 65 | } else { 66 | logoutUser() 67 | } 68 | 69 | if(loading){ 70 | setLoading(false) 71 | } 72 | } 73 | 74 | let contextData = { 75 | user:user, 76 | authTokens:authTokens, 77 | loginUser:loginUser, 78 | logoutUser:logoutUser, 79 | } 80 | 81 | useEffect(()=>{ 82 | if(loading){ 83 | updateToken() 84 | } 85 | 86 | const REFRESH_INTERVAL = 1000 * 60 * 4 // 4 minutes 87 | let interval = setInterval(()=>{ 88 | if(authTokens){ 89 | updateToken() 90 | } 91 | }, REFRESH_INTERVAL) 92 | return () => clearInterval(interval) 93 | 94 | },[authTokens, loading]) 95 | 96 | return( 97 | 98 | {children} 99 | 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /frontend/src/pages/HomePage.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react' 2 | import AuthContext from '../context/AuthContext'; 3 | 4 | const HomePage = () => { 5 | const { authTokens, logoutUser } = useContext(AuthContext); 6 | let [profile, setProfile] = useState([]) 7 | 8 | useEffect(() => { 9 | getProfile() 10 | },[]) 11 | 12 | const getProfile = async() => { 13 | let response = await fetch('http://127.0.0.1:8000/api/profile', { 14 | method: 'GET', 15 | headers:{ 16 | 'Content-Type': 'application/json', 17 | 'Authorization':'Bearer ' + String(authTokens.access) 18 | } 19 | }) 20 | let data = await response.json() 21 | console.log(data) 22 | if(response.status === 200){ 23 | setProfile(data) 24 | } else if(response.statusText === 'Unauthorized'){ 25 | logoutUser() 26 | } 27 | } 28 | 29 | return ( 30 |
31 |

You are logged in to the homepage!

32 |

Name: {profile.first_name} {profile.last_name}

33 |

Email: {profile.email}

34 |
35 | ) 36 | } 37 | 38 | export default HomePage -------------------------------------------------------------------------------- /frontend/src/pages/LoginPage.js: -------------------------------------------------------------------------------- 1 | import React, {useContext} from 'react' 2 | import AuthContext from '../context/AuthContext' 3 | 4 | const LoginPage = () => { 5 | 6 | let {loginUser} = useContext(AuthContext) 7 | 8 | return ( 9 |
10 |
11 | 12 | 13 | 14 |
15 |
16 | ) 17 | } 18 | 19 | export default LoginPage -------------------------------------------------------------------------------- /frontend/src/utils/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import { Navigate } from 'react-router-dom' 2 | import { useContext } from 'react' 3 | import AuthContext from '../context/AuthContext'; 4 | 5 | const PrivateRoute = ({children, ...rest}) => { 6 | let { user } = useContext(AuthContext) 7 | 8 | return !user ? : children; 9 | } 10 | 11 | export default PrivateRoute; --------------------------------------------------------------------------------