├── .gitignore ├── LICENSE ├── README.md ├── api ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── serializers.py ├── tests │ ├── __init__.py │ └── test_customer_api.py ├── urls.py └── views.py ├── business ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── tests.py └── views.py ├── customerreq.png ├── getrequestz.png ├── jwtreq.png ├── manage.py ├── refresh.png ├── secure_tested_django_api ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── terminalcurl.png ├── tokengen.png └── userpass.png /.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite3 2 | .vscode/ 3 | .cache/ 4 | .cache.dat 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (http://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # Distribution directories 44 | dist/ 45 | 46 | # Typescript v1 declaration files 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | 67 | .coverage 68 | 69 | htmlcov/ 70 | 71 | # Byte-compiled / optimized / DLL files 72 | __pycache__/ 73 | *.py[cod] 74 | *$py.class 75 | 76 | # C extensions 77 | *.so 78 | 79 | # Distribution / packaging 80 | .Python 81 | env/ 82 | build/ 83 | develop-eggs/ 84 | dist/ 85 | downloads/ 86 | eggs/ 87 | .eggs/ 88 | lib/ 89 | lib64/ 90 | parts/ 91 | sdist/ 92 | var/ 93 | wheels/ 94 | *.egg-info/ 95 | .installed.cfg 96 | *.egg 97 | 98 | # PyInstaller 99 | # Usually these files are written by a python script from a template 100 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 101 | *.manifest 102 | *.spec 103 | 104 | # Installer logs 105 | pip-log.txt 106 | pip-delete-this-directory.txt 107 | 108 | # Unit test / coverage reports 109 | htmlcov/ 110 | .tox/ 111 | .coverage 112 | .coverage.* 113 | .cache 114 | nosetests.xml 115 | coverage.xml 116 | *.cover 117 | .hypothesis/ 118 | 119 | # Translations 120 | *.mo 121 | *.pot 122 | 123 | # Django stuff: 124 | *.log 125 | local_settings.py 126 | 127 | # Flask stuff: 128 | instance/ 129 | .webassets-cache 130 | 131 | # Scrapy stuff: 132 | .scrapy 133 | 134 | # Sphinx documentation 135 | docs/_build/ 136 | 137 | # PyBuilder 138 | target/ 139 | 140 | # Jupyter Notebook 141 | .ipynb_checkpoints 142 | 143 | # pyenv 144 | .python-version 145 | 146 | # celery beat schedule file 147 | celerybeat-schedule 148 | 149 | # SageMath parsed files 150 | *.sage.py 151 | 152 | # dotenv 153 | .env 154 | 155 | # virtualenv 156 | .venv 157 | venv/ 158 | ENV/ 159 | .vscode 160 | # Spyder project settings 161 | .spyderproject 162 | .spyproject 163 | 164 | # Rope project settings 165 | .ropeproject 166 | 167 | # mkdocs documentation 168 | /site 169 | 170 | # mypy 171 | .mypy_cache/ 172 | 173 | .DS_Store 174 | *.sqlite3 175 | media/ 176 | *.pyc 177 | *.db 178 | *.pid 179 | 180 | # Ignore Django Migrations in Development if you are working on team 181 | 182 | # Only for Development only 183 | **/migrations/** 184 | !**/migrations 185 | !**/migrations/__init__.py 186 | 187 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tafadzwa Lameck Nyamukapa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Creating a Secure and well Tested Django RestFUL API 2 | 3 | In this tutorial you are going to learn how you can create a secure Django API using [djangorestframework](https://www.django-rest-framework.org/) and [djangorestframework-simplejwt](https://django-rest-framework-simplejwt.readthedocs.io/en/latest/). 4 | You are also going to learn how to write unit tests for your Django API using [APITestCase](). 5 | 6 | 7 | ### Show some :heart: and :star: the repo to support the project 8 | 9 | # Get Started 10 | * This tutorial is divided it into three sections: 11 | 12 | * 1. CREATING THE REST API 13 | * 2. SECURING THE API 14 | * 3. REST API UNIT TESTING 15 | 16 | ## 1. CREATING THE REST API 17 | 18 | In this section i'm assuming you have your virtual environment setup and ready to go. If not; you can install [virtualenv](https://virtualenv.pypa.io/en/latest/installation.html) or alternatively [pipenv](https://pypi.org/project/pipenv/) unto you machine. 19 | 20 | * **NB** First make sure your virtual environmant is activated 21 | * Install Django Framework 22 | ```cmd 23 | pip install django 24 | ``` 25 | * Create Django Project 26 | ```cmd 27 | django-admin startproject secure_tesed_django_api 28 | ``` 29 | * After the Django project has been created , you will need to install a couple of dependancies that we are going to use in this project 30 | We need to install 31 | ```cmd 32 | pip install djangorestframework 33 | pip install djangorestframework-simplejwt 34 | ``` 35 | 36 | Now that we have our setup out of the way lets jump into the application. Navigate into your project root folder where there is the *manage.py* file. 37 | 38 | #### We are going to create a simple CRUD businees API, that will be using to manage a Customer Model. 39 | 40 | * Create New app called business 41 | 42 | ```cmd 43 | python manage.py startapp business 44 | ``` 45 | After creating the business app make sure to add the module in the *settings.py* file under installed app. 46 | 47 | ```python 48 | INSTALLED_APPS = [ 49 | 'django.contrib.admin', 50 | 'django.contrib.auth', 51 | 'django.contrib.contenttypes', 52 | 'django.contrib.sessions', 53 | 'django.contrib.messages', 54 | 'django.contrib.staticfiles', 55 | 'business', #new here 56 | ] 57 | ``` 58 | 59 | ### 1a. Customer Model 60 | Now its probably time to create our model. Navigate to the business app and in the *models.py* file create a Customer Class follows: 61 | ```python 62 | from django.db import models 63 | from django.utils import timezone 64 | from django.contrib.auth.models import User 65 | class PublishedManager(models.Manager): 66 | def get_queryset(self): 67 | return super(PublishedManager, self).get_queryset().filter(status='published') 68 | 69 | class Customer(models.Model): 70 | STATUS_CHOICES = ( 71 | ('draft', 'Draft'), 72 | ('published', 'Published') 73 | ) 74 | GENDER_CHOICES = ( 75 | ('M', 'Male'), 76 | ('F','Female'), 77 | ('I', 'Intersex') 78 | ) 79 | title = models.CharField(max_length=250, null=False) 80 | name = models.CharField( max_length=250) 81 | last_name = models.CharField(max_length=250) 82 | gender = models.CharField(max_length=10, choices=GENDER_CHOICES) 83 | created_by = models.ForeignKey( 84 | User, related_name='created_by', editable=False, on_delete=models.PROTECT, default=1) 85 | created = models.DateTimeField(default=timezone.now) 86 | status = models.CharField( 87 | max_length=10, choices=STATUS_CHOICES, default='draft') 88 | 89 | objects = models.Manager() 90 | published = PublishedManager() 91 | 92 | class Meta: 93 | verbose_name = "Customer" 94 | verbose_name_plural = "Customers" 95 | 96 | def __str__(self): 97 | return "{} {}".format(self.name,self.last_name) 98 | 99 | ``` 100 | You will probalby notice that we have an additional *PublishedManager class*, nothnig to worry about :). Now Django by default uses a Manager with the name **objects** to every Django model class. 101 | 102 | So if you want to create your own custom manager,you can archiexve this by extending the base Manager class and add the field in your model in this case the **pubished** field. 103 | ```python 104 | class PublishedManager(models.Manager): 105 | def get_queryset(self): 106 | return super(PublishedManager, self).get_queryset().filter(status='published') 107 | ``` 108 | So what this manager does is to simply run a query that returns the Model data(in our case Customers) where the status=published. 109 | Therefore when we query this model in the future we will be doing something like this: 110 | ```python 111 | customers = Customer.published.all() 112 | ``` 113 | Istead of 114 | ```python 115 | customers = Customer.objects.filter(status='published') 116 | ``` 117 | 118 | ### 1b Registering the Model in Django Admin 119 | To register the Customer model in admin 120 | ```python 121 | from django.contrib import admin 122 | from .models import Customer 123 | 124 | class CustomerAdmin(admin.ModelAdmin): 125 | list_display = ('title', 'full_name','gender', 'created_by', 'created',) 126 | readonly_fields = ('created', ) 127 | 128 | def full_name(self, obj): 129 | return obj.name + " " +obj.last_name 130 | 131 | admin.site.register(Customer,CustomerAdmin) 132 | ``` 133 | Also note another trick that you can do to concat two fields in Django admin. You simply create a function and give an *obj* as a param. The obj will then be used to get the desired fields in this case **name** and **last_name**. Also note that in the list_display tuple we then use the name of the function in this case *full_name*. 134 | 135 | ### 1c CRUD API 136 | Now that we have our Model Ready Lets create the CRUD API. 137 | * First create an api app 138 | ```cmd 139 | python manage.py startapp api 140 | ``` 141 | * Add the api module in the *settings.py* file and also add the **rest_framework** that we installed earlier on. 142 | ```python 143 | ... 144 | ... 145 | 'business', 146 | 'rest_framework', #new here 147 | 'api', #new here 148 | ] 149 | ``` 150 | #### 1c1 API URLS 151 | * Now include the **api** app in the base *urls.py* file where there is the settings.py file. 152 | ```python 153 | from django.contrib import admin 154 | from django.urls import path, include #new here 155 | 156 | urlpatterns = [ 157 | path('admin/', admin.site.urls), 158 | path('api/', include('api.urls')), #new here 159 | ] 160 | ``` 161 | 162 | * Finally create a *urls.py* file the **api** app and add the following routes. 163 | ```python 164 | from django.urls import path 165 | from api import views as api_views 166 | 167 | urlpatterns = [ 168 | path('customers/', api_views.CustomerView.as_view(), name="customer"), 169 | path('customers/', api_views.CustomerDetailView.as_view(), name="customer-detail") 170 | ] 171 | 172 | ``` 173 | In the above snippet we have created the customer routes with views **CustomerView** and **CustomerDetailView** which we are going to create shortly. 174 | The **CustomerView** is essentially going to handle our **get all** *get* request and **save** *post* request then; 175 | The **CustomerDetailView** is going to handle our *get*, *put and delete* requests. 176 | 177 | #### 1c2 API Views 178 | Navigate to the *views.py* inside the **app** folder and add the following code. 179 | ```python 180 | from django.shortcuts import render 181 | from rest_framework.views import APIView 182 | from rest_framework.response import Response 183 | from business.models import Customer 184 | from api.serializers import CustomerSerializer 185 | from rest_framework import status 186 | from django.http import Http404 187 | from functools import wraps 188 | class CustomerView(APIView): 189 | def get(self, request, format=None): 190 | customers = Customer.published.all() 191 | serializer = CustomerSerializer(customers, many=True) 192 | return Response(serializer.data) 193 | 194 | def post(self,request,format=None): 195 | serializer = CustomerSerializer(data=request.data) 196 | if serializer.is_valid(): 197 | serializer.save() 198 | return Response(serializer.data,status=status.HTTP_201_CREATED) 199 | return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) 200 | 201 | 202 | def resource_checker(model): 203 | def check_entity(fun): 204 | @wraps(fun) 205 | def inner_fun(*args, **kwargs): 206 | try: 207 | x = fun(*args, **kwargs) 208 | return x 209 | except model.DoesNotExist: 210 | return Response({'message': 'Not Found'}, status=status.HTTP_204_NO_CONTENT) 211 | return inner_fun 212 | return check_entity 213 | 214 | class CustomerDetailView(APIView): 215 | 216 | @resource_checker(Customer) 217 | def get(self,request,pk, format=None): 218 | customer = Customer.published.get(pk=pk) 219 | serializer = CustomerSerializer(customer) 220 | return Response(serializer.data) 221 | 222 | @resource_checker(Customer) 223 | def put(self,request,pk, format=None): 224 | customer = Customer.published.get(pk=pk) 225 | serializer = CustomerSerializer(customer,data=request.data) 226 | if serializer.is_valid(): 227 | serializer.save() 228 | return Response(serializer.data) 229 | return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) 230 | 231 | @resource_checker(Customer) 232 | def delete(self,request, pk, format=None): 233 | customer = Customer.published.get(pk=pk) 234 | customer.delete() 235 | return Response(status=status.HTTP_204_NO_CONTENT) 236 | 237 | ``` 238 | Now Lets break this code down 239 | * **CustomerView** 240 | As mentioned above the CustomerView is going to handle our get all request and post request by extending the rest_framework APIView. 241 | Note how we are now making use of the Manager that we creating in the Customer Model. 242 | ```python 243 | ... 244 | customers = Customer.published.all() 245 | ... 246 | ``` 247 | Here we are simply getting all Customers that has the status= published. 248 | Also note that we have a **CustomerSerializer** which we will create shortly with **many=True** meaning Serializer has to searialize the List of Objects. 249 | 250 | 251 | * **CustomerDetailView** 252 | Is going to handle the rest of our CRUD request operations, ie *get, put and delete* requests. 253 | Since we are going to perform a queryset that is going to get a specific Customer by the pk, for the rest of the requests; we need a way of handling the model **DoesNotExist** exception which is thrown if you query against a Customer that is not in the database. 254 | 255 | * There are multiple ways of handling such a scenario , like for instance one way would be to *try catch* every request in this view, but the donwfall with this approach is the bottleneck of uncessary repetition of code. 256 | 257 | * To solve this we can make use of a python [decorator](https://www.python.org/dev/peps/pep-0318/) pattern that lets you annoatate every function request which will handle the model DoesNotExist exception. 258 | See code below 259 | ```python 260 | def resource_checker(model): 261 | def check_entity(fun): 262 | @wraps(fun) 263 | def inner_fun(*args, **kwargs): 264 | try: 265 | x = fun(*args, **kwargs) 266 | return x 267 | except model.DoesNotExist: 268 | return Response({'message': 'Not Found'}, status=status.HTTP_204_NO_CONTENT) 269 | return inner_fun 270 | return check_entity 271 | ``` 272 | The above nippet checks of a Model with a given pk exists, if it does it runs the request via 273 | ```python 274 | x = fun(*args, **kwargs) 275 | ``` 276 | Else if the resource does not exist it then returns a Not Found exception. 277 | ```python 278 | return Response({'message': 'Not Found'}, status=status.HTTP_204_NO_CONTENT) 279 | ``` 280 | A **decorator** is a python concept that lets you run a function within another function thus providing some abstrction in code that lets you use same code base in different scenarios or **alter the behavior of a function or a class**. 281 | Since we are going to pass a parameter in our decorate ie a Class Model we need some form of Factory Function that will take the param and later send it down the chain with other func params 282 | 283 | Im not going to in detail about python decorators as it is a wide concept. 284 | 285 | #### 1c3 API CustomerSerializer 286 | Lets create CustomerSerializer class 287 | * Create a file called ***serializers.py*** in the **api** folder with the following code 288 | 289 | ```python 290 | from rest_framework import serializers 291 | from business.models import Customer 292 | 293 | class CustomerSerializer(serializers.ModelSerializer): 294 | class Meta: 295 | model = Customer 296 | fields = '__all__' 297 | 298 | ``` 299 | * To create a rest serializer class you need to extend the ModelSerializer base. 300 | 301 | 302 | #### 1c4 Running app 303 | From this stage everything should be fine now you can go a ahead and run your migrations. 304 | * Also remember to create a **superuser** 305 | 306 | ```cmd 307 | python manage.py makemigrations 308 | python manage.py migrate 309 | python manage.py createsuperuser 310 | ``` 311 | 312 | You can even go ahead and test with postman to see if the application is behaving as expected. 313 | * **Note** we will write some unit tests in the last section of this tutorial so stay tuned :) 314 | 315 | ##### Examples 316 | POST request 317 | 318 | 319 | ## 2 SECURING THE API 320 | In this section we are going to use djangorestframework and djangorestframework_simplejwt that we installed earlier to secure our end points. 321 | 322 | Go to the **api/views.py** file and add the permission classes as below: 323 | ```python 324 | ... 325 | from rest_framework.permissions import IsAuthenticated #new here 326 | class CustomerView(APIView): 327 | permission_classes = (IsAuthenticated,) #new here 328 | def get(self, request, format=None): 329 | customers = Customer.published.all() 330 | serializer = CustomerSerializer(customers, many=True) 331 | return Response(serializer.data) 332 | ... 333 | ... 334 | ... 335 | ... 336 | class CustomerDetailView(APIView): 337 | permission_classes = (IsAuthenticated,)#new here 338 | @resource_checker(Customer) 339 | ... 340 | ... 341 | ``` 342 | At this stage both of your views should be protected if you try to hit one of the end points eg get /customers 343 | 344 | You probably notice you are now getting a HTTP 403 Forbidden error, lets now implement the token authentication so that we will be able to hit our end points. 345 | 346 | ### 2a REST TokenAuthentication 347 | The [djangorestframework](https://www.django-rest-framework.org/) comes with a token based mechanism for authenticating authorising access to secured end point. 348 | 349 | Lets start by adding a couple of configurations to the **settings.py** file. 350 | * Add **rest_framework.authtoken** to **INSTALLED_APPS** and **TokenAuthentication** to **REST_FRAMEWORK**. 351 | ```python 352 | ... 353 | INSTALLED_APPS = [ 354 | 'django.contrib.admin', 355 | 'django.contrib.auth', 356 | 'django.contrib.contenttypes', 357 | 'django.contrib.sessions', 358 | 'django.contrib.messages', 359 | 'django.contrib.staticfiles', 360 | 'business', 361 | 'rest_framework', 362 | 'api', 363 | 'rest_framework.authtoken', #new here 364 | ] 365 | 366 | REST_FRAMEWORK = { #new here 367 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 368 | 'rest_framework.authentication.TokenAuthentication', 369 | ], 370 | } 371 | ... 372 | ``` 373 | After adding the configurations make sure you migrate to apply the **authtoken** tables. 374 | 375 | ```cmd 376 | python manage.py migrate 377 | ``` 378 | 379 | #### Generate Token 380 | Now to make successful requests we need to pass an **Authorization Header** to our request with a **Token**. 381 | * To generate a token we need an account that we created earlier if not just quickly create one: 382 | 383 | ```cmd 384 | python manage.py createsuper --username admin --email tafadzwalnyamukapa@gmail.com 385 | ``` 386 | * You can now generate your token by running the django **drf_create_token** command: 387 | 388 | You should now be able to see a string like this 389 | 390 | ```cmd 391 | Generated token 73d29cb34e8a972741462fa3022935e43c18a247 for user admin 392 | ``` 393 | * Now lets run the get /customers request that we tried earlier on with curl: 394 | 395 | ```cmd 396 | curl http://localhost:8000/api/customers/ -H 'Authorization: Token 73d29cb34e8a972741462fa3022935e43c18a247' | json_pp 397 | ``` 398 | 399 | Now we have successfully retrieved our list of customers. 400 | 401 | * **NB** Note that you need to pass the token with a Token value in the Authorization Header 402 | 403 | Now this works just fine but if a client want to be able to get the token and run the exposed secured end points ,there should be a way of doing that. Not to worry *djangorestframework* comes with a helper end point that should let the client provide their credentials ie **username and password** and make a **POST** request in order to retrieve the token. 404 | 405 | We are going to use **obtain_auth_token** view to archieve the above scenario. 406 | 407 | 408 | #### Client Requesting Token 409 | Navigate to **api/urls.py** file and add the following route: 410 | ```python 411 | from django.urls import path 412 | from api import views as api_views 413 | from rest_framework.authtoken import views # new here 414 | urlpatterns = [ 415 | path('customers/', api_views.CustomerView.as_view(), name="customer"), 416 | path('customers//', api_views.CustomerDetailView.as_view(), name="customer-detail"), 417 | path('api-token-auth/', views.obtain_auth_token), #new here 418 | ] 419 | ``` 420 | Now the client should be a be able to make a post request to ***/api/api-token-auth*** to obtain the Authorization token: 421 | Request: 422 | 423 | ```cmd 424 | curl -d "username=admin&password=admin12#" -X POST http://localhost:8000/api/api-token-auth/ 425 | ``` 426 | 427 | Response: 428 | 429 | ```cmd 430 | {"token":"73d29cb34e8a972741462fa3022935e43c18a247"} 431 | ``` 432 | 433 | * Now at this point the client has succssfully obtained the token,its now up to them to save it in **localStorage**,or **sessionCookies** or any other state manager, in order to make the rest of the requests. 434 | 435 | 436 | ### 2b JWT Authorization and Iformation Exchange 437 | * Up to this point we have been using the REST Token for,authorization, while this works fine, there is another exellent way that we can use to archive this that is more secure when transimitting information between two parties ie **JWT (Json Web Token)**. 438 | * This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the **HMAC** algorithm) or a ***public/private key pair*** using **RSA** or **ECDSA**. 439 | 440 | ### JSON Web Token structure? 441 | * The JWT consits of a ***Header.Payload.Signature*** in that order. 442 | ```cmd 443 | xxxx.yyyy.zzzz 444 | ``` 445 | Simple JWT: 446 | ```cmd 447 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNDU2LCJqdGkiOiJmZDJmOWQ1ZTFhN2M0MmU4OTQ5MzVlMzYyYmNhOGJjYSJ9.NHlztMGER7UADHZJlxNG0WSi22a2KaYSfd1S-AuT7lU 448 | ``` 449 | 450 | #### Header 451 | * The header typically consists of two parts: the type of the token, which is JWT, and the signing algorithm being used, such as HMAC SHA256 or RSA. 452 | ```json 453 | { 454 | "alg": "HS256", 455 | "typ": "JWT" 456 | } 457 | ``` 458 | #### Payload 459 | * The second part of the token is the payload, which contains the claims. Claims are statements about an entity (typically, the user) and additional data. There are three types of claims: registered, public, and private claims. 460 | ```json 461 | "token_type": "access", 462 | "exp": 1543828431, 463 | "jti": "7f5997b7150d46579dc2b49167097e7b", 464 | "user_id": 5cc 465 | ``` 466 | #### Signature 467 | 468 | * To create the signature part you have to take the encoded header, the encoded payload, a secret, the algorithm specified in the header, and sign that. 469 | 470 | ```json 471 | HMACSHA256( 472 | base64UrlEncode(header) + "." + 473 | base64UrlEncode(payload), 474 | secret) 475 | ``` 476 | 477 | * To get started with djangorestframework-simplejwt 478 | 479 | Navigate to ***settings.py***, add **rest_framework_simplejwt.authentication.JWTAuthentication** to the list of authentication classes: 480 | 481 | ```python 482 | REST_FRAMEWORK = { 483 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 484 | 'rest_framework.authentication.TokenAuthentication', 485 | 'rest_framework_simplejwt.authentication.JWTAuthentication', #new here 486 | ], 487 | } 488 | ``` 489 | 490 | * Also, in **api/urls.py** file include routes for Simple JWT’s ***TokenObtainPairView*** and ***TokenRefreshView*** views: 491 | 492 | ```python 493 | from django.urls import path 494 | from api import views as api_views 495 | from rest_framework.authtoken import views 496 | from rest_framework_simplejwt.views import ( #new her 497 | TokenObtainPairView, 498 | TokenRefreshView, 499 | ) 500 | urlpatterns = [ 501 | path('customers/', api_views.CustomerView.as_view(), name="customer"), 502 | path('customers//', api_views.CustomerDetailView.as_view(), name="customer-detail"), 503 | path('api-token-auth/', views.obtain_auth_token), 504 | path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), #new here 505 | path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), #new here 506 | ] 507 | 508 | ``` 509 | #### Obtain JWT Token 510 | 511 | * To obtain the token you need to make a *POST* request to the ***/api/token/*** end point on the **TokenObtainPairView**: 512 | Request: 513 | 514 | ```cmd 515 | curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "admin123#"}' http://localhost:8000/api/token/ 516 | ``` 517 | Response: 518 | 519 | ```json 520 | { 521 | "access" : "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjI0NzAxMzY3LCJqdGkiOiIwNmMyNjU0NjQyOWU0MThkODUzYzljZDViOTUyYmYyZSIsInVzZXJfaWQiOjF9.nW_bq87ob0PT5vm8uQ4ZsczO5jIZxtD6XTb1vQdz7_w", 522 | "refresh" : "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYyNDc4NzI2MywianRpIjoiM2Q0NzdhZmZiOGFhNDRhZjkzMmJhZDI0NjlhNmYwZWYiLCJ1c2VyX2lkIjoxfQ.s4rOL75ddLGCFnLt38Kwa3Du1O-j5Z7YC0cx0aetW4Q" 523 | } 524 | ``` 525 | 526 | * You can notice that in our response we got the **access** and **referesh** tokens. 527 | * We are going to access the secured endpoint eg ***/api/customers/*** by using rhe aceess token. 528 | 529 | ```cmd 530 | curl http://localhost:8000/api/customers/ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjI0NzAxMzY3LCJqdGkiOiIwNmMyNjU0NjQyOWU0MThkODUzYzljZDViOTUyYmYyZSIsInVzZXJfaWQiOjF9.nW_bq87ob0PT5vm8uQ4ZsczO5jIZxtD6XTb1vQdz7_w' | json_pp 531 | ``` 532 | * **NB** Notice that we have used **Bearer** keyword instead of *Token* in our request Authorization Header. 533 | 534 | 535 | 536 | * The access token by default is valid for **5 minutes** . After the expiry time has elapsed you cant use the same token else you will get an "Token is invalid or expired". 537 | ```json 538 | { 539 | "code" : "token_not_valid", 540 | "messages" : [ 541 | { 542 | "token_class" : "AccessToken", 543 | "token_type" : "access", 544 | "message" : "Token is invalid or expired" 545 | } 546 | ], 547 | "detail" : "Given token not valid for any token type" 548 | } 549 | ``` 550 | * To get a new access token you need to make a POST request with refresh token as data **/api/token/refresh/**, ***TokenRefreshView*** 551 | * The refresh token is valid for 24HRS. 552 | ```cmd 553 | curl \ 554 | -X POST \ 555 | -H "Content-Type: application/json" \ 556 | -d '{"refresh":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTYyNDc4NzI2MywianRpIjoiM2Q0NzdhZmZiOGFhNDRhZjkzMmJhZDI0NjlhNmYwZWYiLCJ1c2VyX2lkIjoxfQ.s4rOL75ddLGCFnLt38Kwa3Du1O-j5Z7YC0cx0aetW4Q"}' \ 557 | http://localhost:8000/api/token/refresh/ 558 | ``` 559 | 560 | 561 | * To configure token behavior eg lifetime, add the SIMPLE_JWT configurations in **settings.py**: 562 | ```python 563 | from datetime import timedelta 564 | 565 | ... 566 | 567 | SIMPLE_JWT = { 568 | 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), 569 | 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 570 | 'ROTATE_REFRESH_TOKENS': False, 571 | 'BLACKLIST_AFTER_ROTATION': True, 572 | 'UPDATE_LAST_LOGIN': False, 573 | 574 | 'ALGORITHM': 'HS256', 575 | 'SIGNING_KEY': settings.SECRET_KEY, 576 | 'VERIFYING_KEY': None, 577 | 'AUDIENCE': None, 578 | 'ISSUER': None, 579 | 580 | 'AUTH_HEADER_TYPES': ('Bearer',), 581 | 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', 582 | 'USER_ID_FIELD': 'id', 583 | 'USER_ID_CLAIM': 'user_id', 584 | 'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule', 585 | 586 | 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), 587 | 'TOKEN_TYPE_CLAIM': 'token_type', 588 | 589 | 'JTI_CLAIM': 'jti', 590 | 591 | 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', 592 | 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5), #update here for acess token 593 | 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), # update here for refresh token 594 | } 595 | ``` 596 | 597 | ## 3. REST API UNIT TESTING 598 | 599 | * In this section we are going to look at how to run unit test on django restful api. 600 | * We are going to use the restframework **APITestCase** and the **APIClient** for our **CRUD** requests. 601 | 602 | * To begin delete the ***/api/tests.py*** file in the **api** folder. 603 | ```cmd 604 | rm /api/tests.py 605 | ``` 606 | * Created a new folder inside the **/api** folder called tests 607 | ```cmd 608 | mkdir /api/tests 609 | ``` 610 | * Navigate to the newly created tests folder. 611 | ```cmd 612 | cd /api/tests/ 613 | ``` 614 | * Create a test file ***test_customer_api.py*** 615 | ```cmd 616 | cat test_customer_api.py 617 | ``` 618 | * Create an __init__.py inside the same */tests* folder so that the django test runner will be able to pick our test file. 619 | ```cmd 620 | cat __init__.py 621 | ``` 622 | 623 | * Now add the following snippet inside the newly created ***test_customer_api.py*** file. 624 | ```python 625 | from django.urls import reverse, resolve 626 | from django.test import SimpleTestCase 627 | from api.views import CustomerView 628 | class ApiUrlsTests(SimpleTestCase): 629 | 630 | def test_get_customers_is_resolved(self): 631 | url = reverse('customer') 632 | self.assertEquals(resolve(url).func.view_class, CustomerView) 633 | ``` 634 | * Before we jump to the restframework **APITestCase** lets start with this django urls unit testing. 635 | * The above code simply tests the get ***/api/customers/*** url to see if it is firing the correct ViewClass. 636 | * The default behavior of the test utility is to find all the test cases (that is, subclasses of unittest.TestCase) in any file whose name begins with **test** 637 | 638 | Here we are using the django reverse function to get the absolute url of the path. 639 | We then asset the resolved url function view_class name against the name of the Class Viwew that we want it to trigger. 640 | 641 | * To run the test run the test command (*./manage.py test *) with the name of app. If you dont add the name of the app the test runner will look for all apps in the project and run the test cases if there are any. 642 | ```cmd 643 | python manage.py test api 644 | 645 | System check identified no issues (0 silenced). 646 | . 647 | ---------------------------------------------------------------------- 648 | Ran 1 test in 0.004s 649 | 650 | OK 651 | ``` 652 | The test successfully runs 1 test and it passed. 653 | * Now lets continue with djangorestframework tests. 654 | 655 | #### APITestCase 656 | The REST framework uses incles test case classes , that mirror the existing Django test cases classes. They include 657 | * APISimpleTestCase 658 | * APITransactionTestCase 659 | * APITestCase 660 | * APILiveServerTestCase 661 | 662 | 663 | ```python 664 | from django.urls import path, reverse, include, resolve 665 | from django.test import SimpleTestCase 666 | from api.views import CustomerView 667 | from rest_framework.test import APITestCase, APIClient 668 | from rest_framework.authtoken.models import Token 669 | from rest_framework import status 670 | from django.contrib.auth.models import User 671 | from rest_framework.views import APIView 672 | 673 | 674 | class ApiUrlsTests(SimpleTestCase): 675 | 676 | def test_get_customers_is_resolved(self): 677 | url = reverse('customer') 678 | self.assertEquals(resolve(url).func.view_class, CustomerView) 679 | 680 | 681 | class CustomerAPIViewTests(APITestCase): #new here 682 | customers_url = reverse("customer") 683 | 684 | def setUp(self): 685 | self.user = User.objects.create_user( 686 | username='admin', password='admin') 687 | self.token = Token.objects.create(user=self.user) 688 | #self.client = APIClient() 689 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 690 | 691 | def test_get_customers_authenticated(self): 692 | response = self.client.get(self.customers_url) 693 | self.assertEqual(response.status_code, status.HTTP_200_OK) 694 | 695 | def test_get_customers_un_authenticated(self): 696 | self.client.force_authenticate(user=None, token=None) 697 | response = self.client.get(self.customers_url) 698 | self.assertEquals(response.status_code, 401) 699 | 700 | def test_post_customer_authenticated(self): 701 | data = { 702 | "title": "Mr", 703 | "name": "Peter", 704 | "last_name": "Parkerz", 705 | "gender": "M", 706 | "status": "published" 707 | } 708 | response = self.client.post(self.customers_url, data, format='json') 709 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 710 | self.assertEqual(len(response.data), 8) 711 | 712 | class CustomerDetailAPIViewTests(APITestCase): 713 | customer_url = reverse('customer-detail', args=[1]) 714 | customers_url = reverse("customer") 715 | 716 | def setUp(self): 717 | self.user = User.objects.create_user( 718 | username='admin', password='admin') 719 | self.token = Token.objects.create(user=self.user) 720 | #self.client = APIClient() 721 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 722 | 723 | # Saving customer 724 | data = { 725 | "title": "Mrs", 726 | "name": "Johnson", 727 | "last_name": "MOrisee", 728 | "gender": "F", 729 | "status": "published" 730 | } 731 | self.client.post( 732 | self.customers_url, data, format='json') 733 | 734 | def test_get_customer_autheticated(self): 735 | response = self.client.get(self.customer_url) 736 | self.assertEqual(response.status_code, 200) 737 | self.assertEqual(response.data['name'], 'Johnson') 738 | 739 | def test_get_customer_un_authenticated(self): 740 | self.client.force_authenticate(user=None, token=None) 741 | response = self.client.get(self.customer_url) 742 | self.assertEqual(response.status_code, 401) 743 | 744 | def test_delete_customer_authenticated(self): 745 | response = self.client.delete(self.customer_url) 746 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 747 | 748 | ``` 749 | 750 | Alright now the code has extended a bit, but nothing to worry about :), lets break it down. 751 | 752 | #### CustomerAPIViewTests 753 | Now in this case we want to test the CustomerView that does our **get** all and **post** request. 754 | 755 | * Since we are now working with the database we need to extend an appropriate test case in our case an ***APITestCase***. 756 | ```python 757 | class CustomerAPIViewTests(APITestCase): 758 | ``` 759 | * We then use the django reverse function to get the absolute url by name "customer". 760 | 761 | ```python 762 | customers_url = reverse("customer") 763 | ``` 764 | 765 | * The setUp method will only be run once on each test case. 766 | 767 | ```python 768 | def setUp(self): 769 | self.user = User.objects.create_user( 770 | username='admin', password='admin') 771 | self.token = Token.objects.create(user=self.user) 772 | #self.client = APIClient() 773 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 774 | ``` 775 | * Inside the function we are creating the user, and the token. The django test runner sets up a volatile DB on every run and tears it down after each test case, and with that in mind we dont necessarily need a ***tearDown*** function. 776 | 777 | * The **APITestCase** comes with a **client** from the ***APIClient*** class which we then use to configure our **request** Headers using the **credentials method**. 778 | 779 | * Setup the request Authorization Header with a token to the client: 780 | ```python 781 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 782 | ``` 783 | * Here we are making a get customers request to the /api/customers/ endpoint. 784 | 785 | ```python 786 | response = self.client.get(self.customers_url) 787 | ``` 788 | 789 | * We are using assetEqual to make our declative statements, for our test. 790 | ```python 791 | self.assertEqual(response.status_code, status.HTTP_200_OK) 792 | ``` 793 | 794 | * Here we are declaring /asserting that the response's status_code == 200. 795 | 796 | * Now the above test case was for an **authenticated** client , now lets test an un-authenticated client and see if we attain the desired results. 797 | 798 | ```python 799 | self.client.force_authenticate(user=None, token=None) 800 | ``` 801 | * To bypass authentication entirely for this test case we use the force_authenticate function on our **client** and assign the user or token to None. 802 | 803 | * We then assert a **401** response on the status_code. 804 | 805 | * THe post request , we are using the same client to make a post request, and assert a **201** status Created. 806 | 807 | 808 | #### CustomerDetailAPIViewTests 809 | In this test class we are doing almost everything that we have covered above expect for a few things. Lets take a look 810 | 811 | ```python 812 | customer_url = reverse('customer-detail', args=[1]) 813 | ``` 814 | You will probably notice that our reverse function is now a bit different , this is because the **customer-detail** path take an argument pk See urls.py code below: 815 | ```python 816 | ... 817 | path('customers//', api_views.CustomerDetailView.as_view(), name="customer-detail"), 818 | ... 819 | ``` 820 | So we then pass an argument in the args list, in this case we are passing a 1. 821 | 822 | * Since we want to perfom **get**, **delete**,**put** requests we need to pre-insert a customer in the DB during the setUp , since the customer is going to be used by the rest of the test cases. 823 | * Also note that this is now a different TestCalss so we need to crete a user , token and pass the Authorization token to the client request Header. 824 | Pre-Inserting Customer 825 | ```python 826 | self.client.post(self.customers_url, data, format='json') 827 | ``` 828 | 829 | **GET** and **DELETE** requests 830 | 831 | ```python 832 | ... 833 | def test_get_customer_autheticated(self): 834 | response = self.client.get(self.customer_url) 835 | self.assertEqual(response.status_code, 200) 836 | self.assertEqual(response.data['name'], 'Johnson') 837 | 838 | def test_get_customer_un_authenticated(self): 839 | self.client.force_authenticate(user=None, token=None) 840 | response = self.client.get(self.customer_url) 841 | self.assertEqual(response.status_code, 401) 842 | 843 | def test_delete_customer_authenticated(self): 844 | response = self.client.delete(self.customer_url) 845 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 846 | ... 847 | ``` 848 | In the get by id /api/customer/1 request , we are asserting that the response has a name == 'Johnson': 849 | ```python 850 | self.assertEqual(response.data['name'], 'Johnson') 851 | ``` 852 | 853 | In the delete request /api/customers/1 ,we are asserting that the response will return a 204 No content Found status_code. 854 | 855 | * Now Finally Run your tests: 856 | ```cmd 857 | python manage.py test api 858 | 859 | Creating test database for alias 'default'... 860 | System check identified no issues (0 silenced). 861 | ....... 862 | ---------------------------------------------------------------------- 863 | Ran 7 tests in 1.447s 864 | 865 | OK 866 | Destroying test database for alias 'default'... 867 | ``` 868 | 869 | * If you have 7 passed tests then CONGRATULATIONS you have created your REST API UNIT TESTING. 870 | 871 | * **NB** As a rule of thumb make sure you mess around with the assertions, just to make sure your tests are working as Expected. 872 | 873 | END !! 874 | 875 | * If there is anything you feel i should have covered or improve ,Please let me know in the comments section below. 876 | 877 | Thank you for taking your time in reading this article. 878 | 879 | KINDLY FORK AND STAR THE [REPO](https://github.com/nyakaz73/secure_tested_django_api) TO SUPPORT THIS PROJECT :) 880 | 881 | ### Source Code Git repo 882 | The source code of this [repo](https://github.com/nyakaz73/secure_tested_django_api) 883 | ### Pull Requests 884 | I Welcome and i encourage all Pull Requests.... 885 | ## Created and Maintained by 886 | * Author: [Tafadzwa Lameck Nyamukapa](https://github.com/nyakaz73) 887 | * Email: [tafadzwalnyamukapa@gmail.com] 888 | * Youtube Channel: [Stack{Dev}](https://www.youtube.com/channel/UCacNBWW7T2j_St593VHvulg) 889 | * Open for any collaborations and Remote Work!! 890 | * Happy Coding!! 891 | 892 | ### License 893 | 894 | ``` 895 | MIT License 896 | 897 | Copyright (c) 2021 Tafadzwa Lameck Nyamukapa 898 | 899 | ``` -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/api/__init__.py -------------------------------------------------------------------------------- /api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'api' 7 | -------------------------------------------------------------------------------- /api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/api/migrations/__init__.py -------------------------------------------------------------------------------- /api/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from business.models import Customer 3 | 4 | class CustomerSerializer(serializers.ModelSerializer): 5 | class Meta: 6 | model = Customer 7 | fields = '__all__' 8 | -------------------------------------------------------------------------------- /api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/api/tests/__init__.py -------------------------------------------------------------------------------- /api/tests/test_customer_api.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, reverse, include, resolve 2 | from django.test import SimpleTestCase 3 | from api.views import CustomerView 4 | from rest_framework.test import APITestCase, APIClient 5 | from rest_framework.authtoken.models import Token 6 | from rest_framework import status 7 | from django.contrib.auth.models import User 8 | from rest_framework.views import APIView 9 | 10 | 11 | class ApiUrlsTests(SimpleTestCase): 12 | 13 | def test_get_customers_is_resolved(self): 14 | url = reverse('customer') 15 | self.assertEquals(resolve(url).func.view_class, CustomerView) 16 | 17 | 18 | class CustomerAPIViewTests(APITestCase): 19 | customers_url = reverse("customer") 20 | 21 | def setUp(self): 22 | self.user = User.objects.create_user( 23 | username='admin', password='admin') 24 | self.token = Token.objects.create(user=self.user) 25 | #self.client = APIClient() 26 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 27 | 28 | def test_get_customers_authenticated(self): 29 | response = self.client.get(self.customers_url) 30 | self.assertEqual(response.status_code, status.HTTP_200_OK) 31 | 32 | def test_get_customers_un_authenticated(self): 33 | self.client.force_authenticate(user=None, token=None) 34 | response = self.client.get(self.customers_url) 35 | self.assertEquals(response.status_code, 401) 36 | 37 | def test_post_customer_authenticated(self): 38 | data = { 39 | "title": "Mr", 40 | "name": "Peter", 41 | "last_name": "Parkerz", 42 | "gender": "M", 43 | "status": "published" 44 | } 45 | response = self.client.post(self.customers_url, data, format='json') 46 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 47 | self.assertEqual(len(response.data), 8) 48 | 49 | 50 | class CustomerDetailAPIViewTests(APITestCase): 51 | customer_url = reverse('customer-detail', args=[1]) 52 | customers_url = reverse("customer") 53 | 54 | def setUp(self): 55 | self.user = User.objects.create_user( 56 | username='admin', password='admin') 57 | self.token = Token.objects.create(user=self.user) 58 | #self.client = APIClient() 59 | self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) 60 | 61 | # Saving customer 62 | data = { 63 | "title": "Mrs", 64 | "name": "Johnson", 65 | "last_name": "MOrisee", 66 | "gender": "F", 67 | "status": "published" 68 | } 69 | self.client.post( 70 | self.customers_url, data, format='json') 71 | 72 | def test_get_customer_autheticated(self): 73 | response = self.client.get(self.customer_url) 74 | self.assertEqual(response.status_code, 200) 75 | self.assertEqual(response.data['name'], 'Johnson') 76 | 77 | def test_get_customer_un_authenticated(self): 78 | self.client.force_authenticate(user=None, token=None) 79 | response = self.client.get(self.customer_url) 80 | self.assertEqual(response.status_code, 401) 81 | 82 | def test_delete_customer_authenticated(self): 83 | response = self.client.delete(self.customer_url) 84 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 85 | -------------------------------------------------------------------------------- /api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from api import views as api_views 3 | from rest_framework.authtoken import views 4 | from rest_framework_simplejwt.views import ( 5 | TokenObtainPairView, 6 | TokenRefreshView, 7 | ) 8 | urlpatterns = [ 9 | path('customers/', api_views.CustomerView.as_view(), name="customer"), 10 | path('customers//', api_views.CustomerDetailView.as_view(), name="customer-detail"), 11 | path('api-token-auth/', views.obtain_auth_token), 12 | path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), 13 | path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), 14 | ] 15 | -------------------------------------------------------------------------------- /api/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from rest_framework.views import APIView 3 | from rest_framework.response import Response 4 | from business.models import Customer 5 | from api.serializers import CustomerSerializer 6 | from rest_framework import status 7 | from django.http import Http404 8 | from functools import wraps 9 | from rest_framework.permissions import IsAuthenticated 10 | 11 | 12 | class CustomerView(APIView): 13 | permission_classes = (IsAuthenticated,) 14 | 15 | def get(self, request, format=None): 16 | customers = Customer.published.all() 17 | serializer = CustomerSerializer(customers, many=True) 18 | return Response(serializer.data) 19 | 20 | def post(self, request, format=None): 21 | serializer = CustomerSerializer(data=request.data) 22 | if serializer.is_valid(): 23 | serializer.save() 24 | return Response(serializer.data, status=status.HTTP_201_CREATED) 25 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 26 | 27 | 28 | def resource_checker(model): 29 | def check_entity(fun): 30 | @wraps(fun) 31 | def inner_fun(*args, **kwargs): 32 | try: 33 | x = fun(*args, **kwargs) 34 | return x 35 | except model.DoesNotExist: 36 | return Response({'message': 'Not Found'}, status=status.HTTP_204_NO_CONTENT) 37 | return inner_fun 38 | return check_entity 39 | 40 | 41 | class CustomerDetailView(APIView): 42 | permission_classes = (IsAuthenticated,) 43 | 44 | @resource_checker(Customer) 45 | def get(self, request, pk, format=None): 46 | customer = Customer.published.get(pk=pk) 47 | serializer = CustomerSerializer(customer) 48 | return Response(serializer.data) 49 | 50 | @resource_checker(Customer) 51 | def put(self, request, pk, format=None): 52 | customer = Customer.published.get(pk=pk) 53 | serializer = CustomerSerializer(customer, data=request.data) 54 | if serializer.is_valid(): 55 | serializer.save() 56 | return Response(serializer.data) 57 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 58 | 59 | @resource_checker(Customer) 60 | def delete(self, request, pk, format=None): 61 | customer = Customer.published.get(pk=pk) 62 | customer.delete() 63 | return Response(status=status.HTTP_204_NO_CONTENT) 64 | -------------------------------------------------------------------------------- /business/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/business/__init__.py -------------------------------------------------------------------------------- /business/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Customer 3 | 4 | class CustomerAdmin(admin.ModelAdmin): 5 | list_display = ('title', 'full_name','gender', 'status', 'created_by', 'created',) 6 | readonly_fields = ('created', ) 7 | 8 | def full_name(self, obj): 9 | return obj.name + " " +obj.last_name 10 | 11 | admin.site.register(Customer,CustomerAdmin) -------------------------------------------------------------------------------- /business/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BusinessConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'business' 7 | -------------------------------------------------------------------------------- /business/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/business/migrations/__init__.py -------------------------------------------------------------------------------- /business/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | from django.contrib.auth.models import User 4 | class PublishedManager(models.Manager): 5 | def get_queryset(self): 6 | return super(PublishedManager, self).get_queryset().filter(status='published') 7 | 8 | class Customer(models.Model): 9 | STATUS_CHOICES = ( 10 | ('draft', 'Draft'), 11 | ('published', 'Published') 12 | ) 13 | GENDER_CHOICES = ( 14 | ('M', 'Male'), 15 | ('F','Female'), 16 | ('I', 'Intersex') 17 | ) 18 | title = models.CharField(max_length=250, null=False) 19 | name = models.CharField( max_length=250) 20 | last_name = models.CharField(max_length=250) 21 | gender = models.CharField(max_length=10, choices=GENDER_CHOICES) 22 | created_by = models.ForeignKey( 23 | User, related_name='created_by', editable=False, on_delete=models.PROTECT, default=1) 24 | created = models.DateTimeField(default=timezone.now) 25 | status = models.CharField( 26 | max_length=10, choices=STATUS_CHOICES, default='draft') 27 | objects = models.Manager() 28 | published = PublishedManager() 29 | 30 | class Meta: 31 | verbose_name = "Customer" 32 | verbose_name_plural = "Customers" 33 | 34 | def __str__(self): 35 | return "{} {}".format(self.name,self.last_name) 36 | -------------------------------------------------------------------------------- /business/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /business/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /customerreq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/customerreq.png -------------------------------------------------------------------------------- /getrequestz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/getrequestz.png -------------------------------------------------------------------------------- /jwtreq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/jwtreq.png -------------------------------------------------------------------------------- /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', 'secure_tested_django_api.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 | -------------------------------------------------------------------------------- /refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/refresh.png -------------------------------------------------------------------------------- /secure_tested_django_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/secure_tested_django_api/__init__.py -------------------------------------------------------------------------------- /secure_tested_django_api/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for secure_tested_django_api 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/3.2/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', 'secure_tested_django_api.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /secure_tested_django_api/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for secure_tested_django_api project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-6$9brmx7j$qn=i4j@to@i4pok2%or5vwx6u0-2=m73%hvf+!cl' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'business', 41 | 'rest_framework', 42 | 'api', 43 | 'rest_framework.authtoken', 44 | ] 45 | 46 | REST_FRAMEWORK = { 47 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 48 | 'rest_framework.authentication.TokenAuthentication', 49 | 'rest_framework_simplejwt.authentication.JWTAuthentication', 50 | ], 51 | } 52 | 53 | MIDDLEWARE = [ 54 | 'django.middleware.security.SecurityMiddleware', 55 | 'django.contrib.sessions.middleware.SessionMiddleware', 56 | 'django.middleware.common.CommonMiddleware', 57 | 'django.middleware.csrf.CsrfViewMiddleware', 58 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 59 | 'django.contrib.messages.middleware.MessageMiddleware', 60 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 61 | ] 62 | 63 | ROOT_URLCONF = 'secure_tested_django_api.urls' 64 | 65 | TEMPLATES = [ 66 | { 67 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 68 | 'DIRS': [], 69 | 'APP_DIRS': True, 70 | 'OPTIONS': { 71 | 'context_processors': [ 72 | 'django.template.context_processors.debug', 73 | 'django.template.context_processors.request', 74 | 'django.contrib.auth.context_processors.auth', 75 | 'django.contrib.messages.context_processors.messages', 76 | ], 77 | }, 78 | }, 79 | ] 80 | 81 | WSGI_APPLICATION = 'secure_tested_django_api.wsgi.application' 82 | 83 | 84 | # Database 85 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 86 | 87 | DATABASES = { 88 | 'default': { 89 | 'ENGINE': 'django.db.backends.sqlite3', 90 | 'NAME': BASE_DIR / 'db.sqlite3', 91 | } 92 | } 93 | 94 | 95 | # Password validation 96 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 97 | 98 | AUTH_PASSWORD_VALIDATORS = [ 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 104 | }, 105 | { 106 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 107 | }, 108 | { 109 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 110 | }, 111 | ] 112 | 113 | 114 | # Internationalization 115 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 116 | 117 | LANGUAGE_CODE = 'en-us' 118 | 119 | TIME_ZONE = 'UTC' 120 | 121 | USE_I18N = True 122 | 123 | USE_L10N = True 124 | 125 | USE_TZ = True 126 | 127 | 128 | # Static files (CSS, JavaScript, Images) 129 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 130 | 131 | STATIC_URL = '/static/' 132 | 133 | # Default primary key field type 134 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 135 | 136 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 137 | -------------------------------------------------------------------------------- /secure_tested_django_api/urls.py: -------------------------------------------------------------------------------- 1 | """secure_tested_django_api URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/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 | from django.contrib import admin 17 | from django.urls import path, include 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | path('api/', include('api.urls')), 22 | ] 23 | -------------------------------------------------------------------------------- /secure_tested_django_api/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for secure_tested_django_api 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/3.2/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', 'secure_tested_django_api.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /terminalcurl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/terminalcurl.png -------------------------------------------------------------------------------- /tokengen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/tokengen.png -------------------------------------------------------------------------------- /userpass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyakaz73/secure_tested_django_api/adb90ef7d5f41456c2647ba5f63694c2040b06ed/userpass.png --------------------------------------------------------------------------------