Using authentication decorators in Flask

Software Engineer

In this tutorial, I’ll guide you step-by-step through creating API endpoints secured with authentication tokens. With these endpoints, only authenticated users will be able to make requests to your Flask API. You’ll also learn how to test your endpoint security using pytest and automate those tests in a continuous integration pipeline.
Prerequisites
Before diving in, make sure you have the following:
- A solid understanding of Python, Flask, and virtual environments
- Git installed on your system
- A CircleCI account
- A GitHub account
Using Flask API sample project
For this tutorial, you’ll work with a simple book management API. This API allows you to create, read, and delete books.
While the focus here isn’t on building the API endpoints themselves, you’ll learn how to configure and use authentication tokens in Flask. We won’t dive deeply into token structures or configurations like expiration times, but you can explore more in the PyJWT docs.
Cloning the sample project
To get started, clone the project by running the following commands in your terminal:
$ git clone https://github.com/CIRCLECI-GWP/authentication-decorators-in-flask.git
$ cd authentication-decorators-in-flask
Understanding Flask decorators
Before moving forward, let’s take a moment to understand decorators. A decorator is a function that takes another function as input and returns a new function. This is possible because Python treats functions as first-class objects, meaning they can be passed as arguments, returned from other functions, and assigned to variables. In essence, decorators extend the behavior of a function without altering its core functionality.
A common example of a Flask decorator is @app.route('/')
, which defines routes. This decorator transforms a function into a route that can be accessed via a browser without explicitly calling the function in your code.
The diagram above illustrates how a decorator function operates, enforcing specific requirements before a request proceeds or a response is returned.
Setting up authentication decorators in a Flask API
Now, let’s explore how to use decorators for authentication.
Endpoints in your application must be authenticated before processing requests. Authentication ensures that the endpoint is tied to a unique user session.
You can also assign roles to authenticated users, such as an admin role with elevated privileges. For instance, a decorator can restrict access to certain resources based on user roles. Think of it like a bank: while all employees can enter the building, only a select few can authorize transactions.
For example, the @login_required
decorator is triggered every time a route is accessed. Here’s how it works:
# Decorator usage on an endpoint
@app.route("/bookapi/books/<int:book_id>")
@login_required
def add_book():
# Adding a book requires that the endpoint
# is authenticated
return book[book_id]
The decorator @login_required
is used to secure the bookapi/books/:bookid
route in a the Flask application. It enforces the rule that the client has to either authenticate with a valid logged-in user or have an existing token. For the add_book()
method to be executed, the request will first have to go through the defined decorator for verification that it has the required access permissions.
Note: Decorators can assist in the implementation of DRY code principles, such as accepting input from a query, JSON, or form request with type checking. In this case, an error is provided if the input to the decorator is incorrect, or if it is missing required inputs.
Writing an authentication decorator
Using the API you cloned earlier, you can generate your own custom decorator. This decorator will enforce authorization by requiring users to provide an authentication token, provided by PyJWT. The token generated will allow access for the creation and viewing of books in your endpoint only by registered and logged-in users.
This might seem complicated, but you can take it step-by-step. Start by creating your authentication decorator using this code snippet:
# Authentication decorator
def token_required(f):
@wraps(f)
def decorator(*args, **kwargs):
token = request.headers.get('x-access-token')
if not token: # Throw error if no token provided
return make_response(jsonify({"message": "A valid token is missing!"}), 401)
try:
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
current_user = User.query.filter_by(public_id=data.get('public_id')).first()
if not current_user: # Ensure the user exists
return make_response(jsonify({"message": "User not found!"}), 404)
except jwt.ExpiredSignatureError:
return make_response(jsonify({"message": "Token has expired!"}), 401)
except jwt.InvalidTokenError:
return make_response(jsonify({"message": "Invalid token!"}), 401)
return f(current_user, *args, **kwargs)
return decorator
Note: This code snippet is located in library/models.py
in the cloned repository.
This code snippet first checks that you have a token with the name x-access-token
in your request header. If not, it sends a response that a valid token is missing from the request.
When a valid token header is present in the request, it is decoded using your application SECRET_KEY
. Then, the public_id
of the user associated with the decoded data is fetched. This is what is returned by the decorator.
Creating authentication tokens
Now you have a decorator to ensure that your restricted endpoints have a valid access token before making the request. Next you need to work on using the decorators in your endpoints. There are two questions you need to answer to complete this task:
- How is a token generated?
- How is a decorator called on a request method?
How to create access tokens
The log-in process is one of the most important considerations when determining whether a token is valid. The token generated on the login process is the same one that will be received when your decorator method decodes it.
Here is what you need to create the token:
# Login method responsible for generating authentication tokens
@app.route('/login', methods=['POST'])
def login():
auth = request.get_json()
if not auth or not auth.get('username') or not auth.get('password'):
return make_response('Could not verify!', 401, {'WWW-Authenticate': 'Basic-realm= "Login required!"'})
user = User.query.filter_by(username=auth['username']).first()
if not user:
return make_response('Could not verify user, Please signup!', 401, {'WWW-Authenticate': 'Basic-realm= "No user found!"'})
if check_password_hash(user.password, auth.get('password')):
token = jwt.encode({'public_id': user.public_id}, app.config['SECRET_KEY'], 'HS256')
return make_response(jsonify({'token': token}), 201)
return make_response('Could not verify password!', 403, {'WWW-Authenticate': 'Basic-realm= "Wrong Password!"'})
What this code snippet does:
- Verifies that both the username and the password have been passed on with the request. If they have not, an error is returned to the user.
- Verifies that the username exists; if it does not, the user gets a prompt to sign up for an account.
- Checks that the hashed password stored in the database is the same as the hash of the password that has been provided. If this is the case, a token is created by encoding the
public_id
of the user and theSECRET_KEY
. These are the same ingredients used when decoding the providedx-access-token
from the request.
If the username and password match values already stored in the database, the code generates an access token you can use in the next steps to access the API resources.
Note: You can compare password hashes only because, with the same hashing algorithms, two hashed texts will always have the same hash result. On the other hand, it would require too much compute power to decode the hashed password text to revert it back to a plain password text and compare it with an actual password (which with good algorithms is close to impossible).
This flowchart shows that once the authentication process is successful, a token is created and sent back to the user. This is the token that is used to access the restricted endpoints.
You can verify this flow by making CURL requests to both the signup
and login
endpoints. Then make sure you can create a new user and use the details to log the user in and get a token.
You have a token! Now you can put it into use.
Using the authentication decorator in API endpoints
In the previous steps, you have created a token and also decoded the token to get user information for the authentication decorator. Now you can start the process of integrating the authentication decorator into your API endpoints.
# Endpoint to delete a book
@app.route('/bookapi/books/<book_id>', methods=['DELETE'])
@token_required
def delete_book(book_id):
book = BookModel.query.filter_by(id=book_id).first()
if not book:
return jsonify({'message': 'book does not exist'})
db.session.delete(book)
db.session.commit()
return jsonify({'message': 'Book deleted'})
This code snippet makes the API request to the bookapi/books/:bookid
endpoint and calls the @token_required
decorator. Once the decorator is called, it will verify that the request has a valid x-access-token
token in the request header. If the passed token in the request is valid, a response of a successful request for a deleted book is sent.
Just like that, you have an authenticated endpoint that can only be accessed by a user with a valid token. You can verify this by making a CURL request to the API endpoint to create and obtain created books.
Add token
Create new book
Note: Most tokens are created in the JWT (JSON Web Tokens) format. JWT is an open standard used to share security information between two parties, like between client and server.
Testing endpoints that require authentication tokens
Development is never complete without testing the code. Your next step is to create tests for the endpoints that are using the authentication decorator. You will be using pytest to create the tests.
Note: The tests can be found in the root directory of the cloned project in the tests/test_book_api.py
file.
Start by defining a method that logs in a user every time you execute your tests to ensure there is a valid token. This code snippet shows how:
# Method in test file to generate a user token
def getLoginToken(self):
""""Method to get a login token
"""
user_register = self.client().post('/signup', data=self.user_details, content_type="application/json")
self.assertEqual(user_register.status_code, 201)
user_login = self.client().post('/login', data=self.user_details, content_type="application/json")
self.assertEqual(user_login.status_code, 201)
token = ast.literal_eval(user_login.data.decode())
return token['token']
In this code snippet, you are using pytest’s app.client()
method to make a request to your API to create a user and log in with the same user to obtain the authentication token. Once you have the token, you can now use it to make requests in your tests.
Note: To ensure that data is not corrupted, always drop the test DB and add a new user with every test run. This is handled by the test_db
. It would be an anti-practice to use the test database as the staging/development database.
Once you have the token, making API requests in the tests is simple. Just make the request via your test client and pass the token in the header request. This code snippet shows how:
# Test to ensure that a user can get all books from the app
def test_user_logged_in_user_can_get_books(self) -> None:
"""Test fetching books with a logged-in user."""
login_token = get_login_token(self.client, self.user_details)
headers = {
'Content-Type': "application/json",
'x-access-token': login_token
}
fetch_books = self.client.get('/bookapi/books', headers=headers)
response = json.loads(fetch_books.data.decode())
self.assertEqual(fetch_books.status_code, 200)
self.assertEqual(response, {"Books": []})
This code first gets a login token and then makes a request to the /bookapi/books
endpoint. It then asserts that the response code is 200
and that the response
is an empty list of the books. The list is empty because you have not created any books yet.
At the same time, you can also test that a user without a valid token cannot access the books endpoint. Use this code snippet:
# Test to ensure that a user without a valid token cannot access the books endpoint
def test_user_without_valid_token_cannot_get_books(self) -> None:
"""Test fetching books with an invalid token."""
headers = {
'Content-Type': "application/json",
'x-access-token': 'invalid-token'
}
fetch_books = self.client.get('/bookapi/books', headers=headers)
response = json.loads(fetch_books.data.decode())
self.assertEqual(fetch_books.status_code, 401)
self.assertEqual(response['message'], 'Invalid token!')
Rather than pass in a valid token to x-access-token
in the headers to be passed on to /bookapi/books
endpoint, the code passes in an invalid token. This results in a 401
error.
To verify that the tests are working, you can execute them by running this command in the terminal:
pipenv run pytest
To learn and get more examples of your tests for the book management API, you can check out the full implementation on the tests/test_book_api.py
file in your cloned repository.
Now that you know how to create authentication tokens, decode them with decorators, and also use them in your API endpoints, you need a proper way to run your tests in the cloud. An easier way is to run them locally using the pytest
command, but you want to show them to the world. To do that, you will need to integrate your tests with a CI/CD tool, which in this case will be CircleCI.
CircleCI gives you the confidence that your tests work and raises the level of trust, especially when you are have a continuous integration and continous deployment practice in place.
Writing the CI pipeline
To set up CircleCI, create a .circleci
directory in your root directory and then add a config.yml
file. The config file holds the CircleCI configuration for every project. Use this code:
version: 2.1
orbs:
python: circleci/python@2.1.1
workflows:
build-app-with-test:
jobs:
- build-and-test
jobs:
build-and-test:
docker:
- image: cimg/python:3.9
steps:
- checkout
- python/install-packages:
pkg-manager: pipenv
- run:
name: Run tests
command: pipenv run pytest
In this configuration, CircleCI uses a Python version of 3.9
and the pipenv
package manager to install the dependencies. You are using the pytest
package to run your tests. Once all the dependencies have been installed, and the environment is set up, execute using the pipenv run pytest
command.
After adding this configuration, commit your work and push your changes to GitHub.
Log in to CircleCI and go to the Projects dashboard. You can choose the repository that you want to set up from a list of all the GitHub repositories associated with your GitHub username or your organization. The project for this tutorial is named authentication-decorators-in-flask
.
On the Projects dashboard, select the option to set up the project you want. Choose the option to use the configuration file in your repository. Then click Set Up Project to start the building process.
CircleCI will start executing the tests after building the Python image. Because you already have tests in the tests
directory, your pipeline will pass.
Voila! You have successfully set up your CircleCI pipeline to run tests in the cloud. This brings your authentication decorators journey with Flask almost to an end, at least for now.
Conclusion
Congratulations to you on getting this far. In this tutorial, you have learned how to create custom authentication decorators and use them in your API to receive and decode JWT tokens from registered users in the login process. You also learned how to pass authentication tokens to the API using headers
, expecting that the request received by the application has the same headers. Finally, you learned how you can enforce authentication by ensuring that endpoints are protected with an authentication decorator.
As a bonus, you can show your team how to set up CircleCI and execute automated tests. If you have yet to adopt continuous integration and continuous delivery (CI/CD) for your applications, this is a great time to get started.
I hope you enjoyed this tutorial as much as I did writing it. Until next time!