Part 2: Database Models, Migrations and JWT Setup
Tutorial Sections
- Project Overview
- Part 1: Project Setup and Environment Configuration
- Part 2: Database Models, Migrations and JWT Setup
- Part 3: API Configuration and User Registration
- Part 4: JWT Authentication, Decorators and Blacklisting Tokens
- Part 5: RESTful Resources and Advanced Request Parsing
- Part 6: Pagination, HATEOAS and Parameterized Testing
Table of Contents
Github Links for Part 2
Project Structure
The chart below shows the folder structure that was created in Part 1. In this post, we will work on all files marked as NEW CODE
. Files that contain code from Part 1 but will not be modified in this post are marked as NO CHANGES
.
. (project root folder) |- src | |- flask_api_tutorial | |- api | | |- auth | | | |- __init__.py | | | | | |- widgets | | | |- __init__.py | | | | | |- __init__.py | | | |- models | | |- __init__.py | | |- user.py | | | |- util | | |- __init__.py | | |- datetime_util.py | | |- result.py | | | |- __init__.py | |- config.py | |- tests | |- __init__.py | |- conftest.py | |- test_config.py | |- test_user.py | |- util.py | |- .env |- .gitignore |- .pre-commit-config.yaml |- pyproject.toml |- pytest.ini |- README.md |- run.py |- setup.py |- tox.iniKEY:FOLDERNEW CODENO CHANGESEMPTY FILE
Database Models and Migrations with Flask-SQLAlchemy
If you have never used SQLAlchemy (or any ORM) before, the basic concept is simple: ORMs allow you to interact with data stored in a database as high-level abstractions such as classes, instances of classes and methods rather than writing raw SQL (the ORM translates your application code into SQL commands and queries).
A common task that I rarely see covered in tutorials like this is how to manage changes that are made to a database. Changes to the database schema will usually require changing data that is already stored in the database, or migrating the existing data. We will use the Flask-Migrate extension to handle this task and explain how to setup a migration system and how to create and apply migrations.
User
DB Model
In Part 1, we created an instance of the Flask-SQLAlchemy extension with the name db
in the src/flask_api_tutorial/__init__.py
file and initialized it in the create_app
method. The db
object contains functions and classes from sqlalchemy
and sqlalchemy.orm
.
Whenever we need to declare a new database model (i.e., create a new database table), we create a class that subclasses db.Model
. Since we are creating an API that performs user authentication, our first SQLAlchemy model will be a User
class that stores login credentials and metadata for registered users.
Create a new file user.py
in the src/flask_api_tutorial/models
folder and add the content below:
|
|
The User
class demonstrates several important concepts for creating database models in SQLAlchemy:
Line 17:
User
is defined as a subclass ofdb.Model
. Subclassingdb.Model
"registers" the model with SQLAlchemy, allowing the ORM to create a database table based on the column definitions in Lines 18-23.Line 20: Flask-SQLAlchemy will automatically set the name of the database table by converting the class name (
User
) to lowercase. However,user
is a reserved word in multiple SQL implementations (e.g., PostgreSQL, MySQL, MSSQL), and using any reserved word as a table name is a bad idea. You can override this default value by setting the__tablename__
class attribute.Lines 22-27: Use
db.Column
to define a column. Dy default, the column name will be the same as the name of the attribute you assign it to. The first argument todb.Column
is the data type (i.e.,db.Integer
,db.String(size)
,db.Boolean
). There are many different generic data types as well as vendor-specific data types available. Oursite_user
table will have the following columns:id: This is the primary key for our table, specified by
primary_key=True
. We will use thedb.Integer
data type, but you could use another data type or even specify multiple columns as a primary key. Also, note that we have setup "autoincrement" behavior by specifyingautoincrement=True
. This is possible only with integer data types.email: Like most sites, we will use an email address to identify our users. Notice that we have set a max length of 255 characters for this column with
db.String(255)
. Obviously, this is a mandatory value for all users, which we specify withnullable=False
. To ensure that an email address cannot be registered by more than one user, we specifyunique=True
.password_hash: Storing passwords in a database is poor practice. Instead, we will compute a hash of the password using Flask-Bcrypt and store the hashed value in this column. When a user attempts to authenticate, we will again use Flask-Bcrypt to compare the password provided by the user to the hashed value. This will be explained in detail later on in this post.
registered_on: This column will contain the date and time when the user account was created. SQLAlchemy provides many ways to store datetime values, but the simplist method is to use
db.DateTime
. Notice that we have specified a default value for this column,default=utc_now
. This is a function in theapp.util.datetime_util
module that returns the current UTC date and time as an "aware" datetime object. When a new User is added to the database, the current UTC time will be evaluated and stored as the value forregistered_on
.admin: This is a flag that indicates whether a user has administrator access. Use the
db.Boolean
data type to create a column containing only TRUE/FALSE values. By default, users should not have administrator access. We specifydefault=False
to ensure this behavior.public_id: This column will contain UUID (Universally Unique IDentifier) values. This column is defined in the same way as the email column since we are storing a string value that must be unique for all users. However, since this is a random value (i.e., not user-provided), we populate the column similarly to
registered_on
, with the result of a lambda function that is called when a new User is added to the database.
Lines 34-39: The
@hybrid_property
decorator is another SQLAlchemy feature that is capable of much more than what I am demonstrating here. Most often, this decorator is used to create "computed" or "virtual" columns whose value is computed from the values of one or more columns. In this instance, theregistered_on_str
column converts the datetime value stored inregistered_on
to a formatted string.Lines 41-43: This is part of the password-hashing implementation. The
@property
decorator exposes apassword
attribute on our User class. However, this is designed as a write-only value so when a client attempts to calluser.password
and retrieve the value, anAttributeError
is raised.Lines 45-49: This is the setter function for the
password
@property
which calculates the value stored in thepassword_hash
column. This design only stores the hashed value and discards the actual password. Also, hashing the same password multiple times always produces a different value, so it is impossible to comparepassword_hash
values to determine if multiple users have the same password.Lines 51-52: This
check_password
function is used when the user is attempting to login. Thepassword
argument passed into the function is the value provided by the user, and this is provided to thebcrypt.check_password_hash
function along with thepassword_hash
value that was created when the user registered their account. The function returnsTrue
if the password provided by the user matches the hash, orFalse
otherwise.Lines 54-60: These two class methods are convenience methods that provide a clean, easy-to-read way to retrieve User accounts based on the values stored in the
email
orpublic_id
columns. Since these values must be unique for all Users, we know that only one or zero Users can be returned from either method.
Did you notice that we never defined a __init__
method? That’s because SQLAlchemy adds an implicit constructor to all model classes which accepts keyword arguments for all its columns and relationships. If you decide to override the constructor for any reason, make sure to keep accepting **kwargs
and call the super constructor with those **kwargs
to preserve this behavior:
class User(db.Model):
# ...
def __init__(self, **kwargs):
super(User, self).__init__(**kwargs)
# do custom stuff
Creating The First Migration
Whenever we make a change to our database schema (e.g., add new table, change column name, change foreign key dependencies, etc.) we need to create a migration with the Flask-Migrate extension. Each migration is stored in the migration database (which is actually just a folder named migrations), which also keeps track of the order that the migrations occurred. This allows us to either upgrade or downgrade our database all the way back to the database’s initial state.
The Flask-Migrate extension adds a new set of commands to the Flask CLI grouped under flask db
. In order to create the migration database, we must run flask db init
:
(flask-api-tutorial) flask-api-tutorial $ flask db init
Creating directory /Users/aaronluna/Projects/flask-api-tutorial/migrations ... done
Creating directory /Users/aaronluna/Projects/flask-api-tutorial/migrations/versions ... done
Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/script.py.mako ... done
Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/env.py ... done
Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/README ... done
Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/alembic.ini ... done
Please edit configuration/connection/logging settings in '/Users/aaronluna/Projects/flask-api-tutorial/migrations/alembic.ini' before proceeding.
In order for Flask-Migrate to detect the User
model, we must import it in the run.py
module. Open run.py
in the project root folder and make the changes highlighted below:
|
|
Line 5: The
User
class will only be detected by the Flask-Migrate extension as a new database table if this import statement exists.Line 12: We have added the
User
object to the dictionary that is imported by theflask shell
command. This makes this class available in the shell context without needing to be explicitly imported.
Ok, after making the changes to run.py
we are ready to create our first migration. To do so, we use the flask db migrate
command. Also, I recommend adding a message describing the schema changes that will be made, as shown below:
(flask-api-tutorial) flask-api-tutorial $ flask db migrate --message "add User model"
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'site_user'
Generating /Users/aaronluna/Projects/flask-api-tutorial/migrations/versions/eb6c2faa0708_add_user_model.py ... done
The flask db migrate
command creates the migration script but does not apply the changes to the database. To upgrade the database and execute the migration script you must run the flask db upgrade
command:
(flask-api-tutorial) flask-api-tutorial $ flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> eb6c2faa0708, add User model
We can verify that the site_user
table has been created using flask shell
and the sqlite3
module:
(flask-api-tutorial) flask-api-tutorial $ flask shell
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
App: app [development]
Instance: /Users/aaronluna/Projects/flask_api_tutorial/instance
>>> import sqlite3
>>> from pathlib import Path
>>> DATABASE_URL = app.config.get("SQLALCHEMY_DATABASE_URI")
>>> db = sqlite3.connect(Path(DATABASE_URL).name)
>>> cursor = db.cursor()
>>> results = cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
>>> for row in results:
... print(row)
...
('alembic_version',)
('site_user',)
>>> exit()
The query we executed retrieves all rows from the sqlite_master
table where the type
column contains table
. This returned two rows: alembic_version
and site_user
. The latter confirms that running the upgrade successfully created the table we specified in user.py
.
The alembic_version
table contains a single column named version_num
and a single row containing the version number of the most recent migration that has been executed on this database. The version number can be traced to the migration script files in the migrations folder if you are interested in discovering the low-level details of how database migrations are created.
JSON Web Token Authentication
The user authentication process that we are implementing utilizes JSON Web Tokens (JWT), which were defined in detail in Part 1. The general workflow is given below:
- New User Registration
- Client sends a request with email address and password.
- Server verifies that email address has not already been registered. If this is true the server creates a new User object and sends a response containing a JWT in
access_token
field. - Client stores
access_token
.
- Existing User Login
- Client sends a request with email address and password.
- Server retrieves the User object with the email address provided by the client.
- Server verifies the password is valid for the User object and sends a response containing a JWT in
access_token
field if the password is valid. - Client stores
access_token
.
- After Successful Login/Registration
- Client sends a request with JWT in the Authorization field of the request header.
- If requested resource requires authorization, server decodes access token and allows client to access requested resource if token is valid.
- The decoded token contains an admin flag. If the requested resource requires administrator access, the server only allows the client to access the resource if admin = True.
Depending on the environment, the access token will expire after a set amount of time. When this happens, the client must login with their email and password to obtain a new token.
encode_access_token
Function
Let’s start implementing our workflow by creating the method that will be used to generate access tokens. Since the token will be generated using attributes from a User
instance, we will create the method as a member of the User
class.
First, update the import statements in user.py
to include datetime.timedelta
and the jwt
package (Line 2 and Line 5 below):
|
|
Then, add the encode_access_token
method to user.py
:
|
|
Let’s breakdown how this method generates the access token:
Lines 57-58: Using the
curent_app
proxy object, we retrieve the config settingsTOKEN_EXPIRE_HOURS
andTOKEN_EXPIRE_MINUTES
. Remember, we defined different values for these settings for each environment (development
,testing
,production
).Line 59: We calculate the time when the token will expire based on the config settings and the current time.
Lines 60-61: All tokens generated with the
testing
config settings will expire after five seconds, allowing us to write and execute test cases where the tokens actually expire so we can verify the expected behavior.Line 62: The payload object is where data about the token and the user is stored. The payload contains a set of key/value pairs known as "claims" (refer to Part 1 for more info on claims). Our token will contain the following registered claims:
- exp: Date/time when the token will expire
- iat: Date/time when the token was generated
- sub: The subject of the token (i.e., the user that the token was generated for)
Our token also contains one private claim:
- admin: True/False value indicating whether the User has administrator access.
Line 63: In order to calculate the token's signature, we must retrieve the
SECRET_KEY
config setting. We will use this same value to decode the token and ensure that the contents have not been modified.Line 64: The
jwt.encode()
function accepts three arguments. The first two of which we have just described: the payload and the secret key. The third argument is the signing algorithm. Most applications use theHS256
algorithm, which is short for HMAC-SHA256. The signing algorithm is what protects the payload of the JWT against tampering.
Global Test Fixtures: conftest.py
In order to test the encode_access_token
method, we will need a User
object (which requires a database connection, which requires a Flask application instance, etc). In the unittest
framework, the correct way to do this would involve creating a base test class with a setup method that creates the application instance and initializes the database. We would then create classes that inherit from the base test class, making the setup method available without duplicating the same code in each test class.
There’s nothing wrong with this approach, but it’s not (IMO) the best or the simplest solution. With pytest
, we can eliminate all of the boilerplate code required for class inheritance – we can even eliminate classes entirely. How? With fixtures, of course!
Fixtures are functions that construct any type of object needed by a test. We can utilize them by declaring our test functions with a parameter whose name is the same as a fixture. When the name of a parameter and the name of a fixture coincide, pytest
will execute the fixture function and pass the result to the test function.
First, create a file named util.py
in the tests
folder and add the content below:
"""Shared functions and constants for unit tests."""
EMAIL = "new_user@email.com"
PASSWORD = "test1234"
It may seem silly to create a file for just these two string values, but as we develop more test cases you will see why I chose to do so.
Next, create a new file conftest.py
in the tests
folder and add the content below:
|
|
conftest.py
is a special filename that pytest
automatically looks for and loads test fixtures from, making the fixtures available to all functions in the same folder (and sub-folders) where conftest.py
is located. You do not need to add an import statement to any test function in order to use the app
or db
fixtures we have defined.
Also, the app
fixture is a special case. The pytest-flask
extension looks for the app
fixture and automatically creates the client
fixture which returns an instance of the Flask test client that we will use to test API calls. You can read more about pytest-flask
in the official docs.
Here a few more things to note about the fixtures we defined in conftest.py
:
Line 5: The
db
object imported from theapp
module (which is the Flask-SQLAlchemy extension object) is renamed todatabase
since we will use the namedb
for the test fixture that injects the database object into our test functions.Line 17: The
db
fixture is using theclient
fixture frompytest-flask
. Therequest
parameter is another specialpytest
feature that can be used as a parameter in any fixture function. Therequest
object gives access to the test context where the fixture was requested.Line 18-20: This is my preferred way to teardown/create the database used for testing. You may notice that I do not remove the database after each test run as is common practice, rather I drop all tables before beginning a new test case. This allows me to inspect the database after a failing test run since the data is still present.
Line 22-25: The
fin
function is registered with theaddFinalizer
method of thepytest
request object. Thefin
function will execute after the test function concludes and in this case removes the database session. You will see this pattern repeated in all fixtures where some sort of teardown process is needed to free resources allocated by the fixture function.Line 30: As demonstrated here, fixtures can incorporate other fixtures. The
user
fixture relies on thedb
fixture which relies on theclient
(i.e.,app
) fixture. It's fixtures all the way down!This fixture creates a new, regular (non-admin)
User
instance and commits the instance to the database. TheUser
object is returned to the test function.
Unit Test: test_encode_access_token
We are finally ready to write test code that verifies the encode_access_token
method. Create a new file in the tests
folder named test_user.py
and add the following content (ensure that test_user.py
and conftest.py
are in the same folder):
"""Unit tests for User model class."""
def test_encode_access_token(user):
access_token = user.encode_access_token()
assert isinstance(access_token, bytes)
Run the tox
command and verify that all tests pass:
(flask-api-tutorial) flask-api-tutorial $ tox
GLOB sdist-make: /Users/aaronluna/Projects/flask-api-tutorial/setup.py
py37 create: /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37
py37 installdeps: black, flake8, pydocstyle, pytest, pytest-black, pytest-clarity, pytest-dotenv, pytest-flake8, pytest-flask
py37 inst: /Users/aaronluna/Projects/flask-api-tutorial/.tox/.tmp/package/1/flask-api-tutorial-0.1.zip
py37 installed: alembic==1.4.0,aniso8601==8.0.0,appdirs==1.4.3,attrs==19.3.0,bcrypt==3.1.7,black==19.10b0,certifi==2019.11.28,cffi==1.14.0,chardet==3.0.4,Click==7.0,entrypoints==0.3,flake8==3.7.9,Flask==1.1.1,flask-api-tutorial==0.1,Flask-Bcrypt==0.7.1,Flask-Cors==3.0.8,Flask-Migrate==2.5.2,flask-restx==0.1.1,Flask-SQLAlchemy==2.4.1,idna==2.9,importlib-metadata==1.5.0,itsdangerous==1.1.0,Jinja2==2.11.1,jsonschema==3.2.0,Mako==1.1.1,MarkupSafe==1.1.1,mccabe==0.6.1,more-itertools==8.2.0,packaging==20.1,pathspec==0.7.0,pluggy==0.13.1,py==1.8.1,pycodestyle==2.5.0,pycparser==2.19,pydocstyle==5.0.2,pyflakes==2.1.1,PyJWT==1.7.1,pyparsing==2.4.6,pyrsistent==0.15.7,pytest==5.3.5,pytest-black==0.3.8,pytest-clarity==0.3.0a0,pytest-dotenv==0.4.0,pytest-flake8==1.0.4,pytest-flask==0.15.1,python-dateutil==2.8.1,python-dotenv==0.11.0,python-editor==1.0.4,pytz==2019.3,regex==2020.2.20,requests==2.23.0,six==1.14.0,snowballstemmer==2.0.0,SQLAlchemy==1.3.13,termcolor==1.1.0,toml==0.10.0,typed-ast==1.4.1,urllib3==1.25.8,wcwidth==0.1.8,Werkzeug==0.16.1,zipp==3.0.0
py37 run-test-pre: PYTHONHASHSEED='1533942126'
py37 run-test: commands[0] | pytest
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/bin/python
cachedir: .tox/py37/.pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 38 items
run.py::FLAKE8 PASSED [ 2%]
run.py::BLACK PASSED [ 5%]
setup.py::FLAKE8 PASSED [ 7%]
setup.py::BLACK PASSED [ 10%]
src/flask_api_tutorial/__init__.py::FLAKE8 PASSED [ 13%]
src/flask_api_tutorial/__init__.py::BLACK PASSED [ 15%]
src/flask_api_tutorial/config.py::FLAKE8 PASSED [ 18%]
src/flask_api_tutorial/config.py::BLACK PASSED [ 21%]
src/flask_api_tutorial/api/__init__.py::FLAKE8 PASSED [ 23%]
src/flask_api_tutorial/api/__init__.py::BLACK PASSED [ 26%]
src/flask_api_tutorial/api/auth/__init__.py::FLAKE8 PASSED [ 28%]
src/flask_api_tutorial/api/auth/__init__.py::BLACK PASSED [ 31%]
src/flask_api_tutorial/api/widgets/__init__.py::FLAKE8 PASSED [ 34%]
src/flask_api_tutorial/api/widgets/__init__.py::BLACK PASSED [ 36%]
src/flask_api_tutorial/models/__init__.py::FLAKE8 PASSED [ 39%]
src/flask_api_tutorial/models/__init__.py::BLACK PASSED [ 42%]
src/flask_api_tutorial/models/user.py::FLAKE8 PASSED [ 44%]
src/flask_api_tutorial/models/user.py::BLACK PASSED [ 47%]
src/flask_api_tutorial/util/__init__.py::FLAKE8 PASSED [ 50%]
src/flask_api_tutorial/util/__init__.py::BLACK PASSED [ 52%]
src/flask_api_tutorial/util/datetime_util.py::FLAKE8 PASSED [ 55%]
src/flask_api_tutorial/util/datetime_util.py::BLACK PASSED [ 57%]
src/flask_api_tutorial/util/result.py::FLAKE8 PASSED [ 60%]
src/flask_api_tutorial/util/result.py::BLACK PASSED [ 63%]
tests/__init__.py::FLAKE8 PASSED [ 65%]
tests/__init__.py::BLACK PASSED [ 68%]
tests/conftest.py::FLAKE8 PASSED [ 71%]
tests/conftest.py::BLACK PASSED [ 73%]
tests/test_config.py::FLAKE8 PASSED [ 76%]
tests/test_config.py::BLACK PASSED [ 78%]
tests/test_config.py::test_config_development PASSED [ 81%]
tests/test_config.py::test_config_testing PASSED [ 84%]
tests/test_config.py::test_config_production PASSED [ 86%]
tests/test_user.py::FLAKE8 PASSED [ 89%]
tests/test_user.py::BLACK PASSED [ 92%]
tests/test_user.py::test_encode_access_token PASSED [ 94%]
tests/util.py::FLAKE8 PASSED [ 97%]
tests/util.py::BLACK PASSED [100%]
================================================== 38 passed in 6.33s ==================================================
_______________________________________________________ summary ________________________________________________________
py37: commands succeeded
congratulations :)
decode_access_token
Function
Let’s move on to the obvious next step in our authorization workflow: decoding tokens. We need to update the import statements in user.py
to include the Result
class (Line 16 below):
|
|
Next, add the decode_access_token
method to the user.py
file:
|
|
There are several important things to note about this method:
Line 67: The
@staticmethod
decorator indicates that this method is not bound to either aUser
instance or to theUser
class. This means that there is no practical difference between having thedecode_access_token
method defined inside theUser
class as a static method and having it defined as a regular function inside a module.The only reason to define a method as static is to group related behaviors under a shared namespace. It is simply my opinion that since the
encode_access_token
method must be bound to aUser
instance, it is both logical and more aesthetically pleasing to definedecode_access_token
as a member of theUser
class.Lines 69-70: Depending on how the
access_token
was passed to thedecode_access_token
function, it could either be a byte-array or a string. Before proceeding, we convertaccess_token
to a string if necessary.Lines 71-73: We could add this validation step later, since it will be needed when we define our API routes and incorporate Flask-RESTx. I'm adding it now and telling you: Sometimes, the Authorization field of a request header will be prefixed with "Bearer" and sometimes it won't. We need to handle both situations when decoding access tokens.
- Line 75: Since the token signature was calculated with the
SECRET_KEY
, we must use the same value to decode the token. Line 76: The
jwt.decode
function takes three arguments: the access token, the secret key, and a list of signature algorithms which the application accepts. If the access token is valid, has not been tampered with and has not expired, then the return value of thejwt.decode
function (payload
) is the dictionary containing the create time and expire time of the token, the user's public ID and a bool indicating if the user has administrator access.Line 77-79: This code will only execute if the token is expired. Note that we do not have to perform any of the work to verify whether the token is expired or not, that is handled by the
jwt.decode
function. If the token is expired, ajwt.ExpiredSignatureError
is raised. Since this is an expected failure, we catch it and create aResult
object with an error message describing the failure and return theResult
.Line 80-82: This code will only execute if the signature validation process fails. This would occur if the token was tampered or modified in any way, and we will create unit tests to verify this works as expected. Again, we do not have to perform any of the work to determine if the token has been tampered with, that process is handled by the
jwt.decode
function. If the signature is invalid, ajwt.InvalidTokenError
is raised. Since this is an expected failure, we catch it and create aResult
object with an error message describing the failure and return theResult
.Line 84-90: This code will only execute if the token passed all validation criteria: token format is valid, signature is valid (i.e., token has not been modified/tampered) and token is not expired. In that case, we construct a dict object containing the validated
access_token
, the timestamp when the token expires, the user's public_id and administrator flag from thepayload
object. We return the dict within aResult
object indicating the operation was successful.
Unit Tests: Decode Access Token
We want to verify the three possible results of the decode_access_token
method: token is valid, token is expired and token is invalid. Before we start writing any tests, place the import statements below at the top of test_user.py
:
|
|
The first test will verify the expected behavior for a valid access token. Add the test_decode_access_token_success
method to test_user.py
:
|
|
Please note the following verifications we are performing in this method:
Line 17: The
decode_access_token
method returns aResult
object, so we first check thatresult.success
isTrue
. If this is the case, we know thataccess_token
is valid and was successfully decoded.Line 18: Since
access_token
is valid, we know thatresult.value
contains theuser_dict
object.Lines 19-20: The
public_id
andadmin
values inuser_dict
should match the user that created theaccess_token
.
Next, let’s create a test that verifies what happens when we attempt to decode an expired access token. Add the test_decode_access_token_expired
method to test_user.py
:
|
|
Let’s go through this test and explain how we achieved the desired result:
Line 25: As stated multiple times, when the
TestConfig
settings are in use, authorization tokens will expire after five seconds. Immediately after generatingaccess_token
, we calltime.sleep(6)
which waits for six seconds before attempting to decode the access token.Line 27: Since we expect
decode_access_token
to return aResult
object indicatingaccess_token
could not be decoded, we expect the value ofresult.success
to beFalse
.Line 28: Since
access_token
was not decoded successfully, we verify thatresult.error
indicates the reason for the failure is because the token is expired.
The last test is by far the most interesting of the three. It is trivially easy to modify part of a JWT and send it in place of the token that was generated by the server. For example, what if a user changed the admin
claim in their token from False
to True
? Would they be able to access resources that require administrator access, even though they have not been granted the required access? Let’s try it out!
Add the test_decode_access_token_invalid
method to test_user.py
:
|
|
Rather than explain this test case line-by-line as done previously, I think it’s easier to execute the test in the flask shell
interpreter and print out the value of several important variables:
(flask-api-tutorial) flask-api-tutorial $ flask shell
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
App: flask-api-tutorial [development]
Instance: /Users/aaronluna/Projects/flask_api_tutorial
>>> import json
>>> from base64 import urlsafe_b64encode, urlsafe_b64decode
>>> from flask_api_tutorial import db
>>> from flask_api_tutorial.models.user import User
>>> USER_EMAIL = "new_user@email.com"
>>> USER_PASSWORD = "test1234"
>>> user = User(email=USER_EMAIL, password=USER_PASSWORD)
>>> db.session.add(user)
>>> db.session.commit()
>>> access_token = user.encode_access_token()
>>> split = access_token.split(b'.')
>>> print(f'access_token (original):\n header: {split[0]}\n payload: {split[1]}\n signature: {split[2]}')
access_token (original):
header: b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9'
payload: b'eyJleHAiOjE1NTc5NDA3MDAsImlhdCI6MTU1NzkzOTc5NSwic3ViIjoiMzhjMDMwYTAtNTdhNC00NmRjLWFjOWYtZTcwZDA0OWUzMDE2IiwiYWRtaW4iOmZhbHNlfQ'
signature: b'EvNgTtgbgxpEmJedAOwLuxf6YSq09N2GCRmQOxF2REs'
The highlighted value above is the base64-encoded payload from the original access token that was generated by the server. Next, we will decode the payload to reveal the values for the user claims.
>>> payload_base64 = split[1]
>>> pad_len = 4 - (len(payload_base64) % 4)
>>> payload_base64 += pad_len * b'='
>>> payload_str = urlsafe_b64decode(payload_base64)
>>> payload = json.loads(payload_str)
>>> print(f'payload (original):\n {json.dumps(payload, indent=2)}')
payload (original):
{
"exp": 1557940700,
"iat": 1557939795,
"sub": "38c030a0-57a4-46dc-ac9f-e70d049e3016",
"admin": false
}
Please note that anybody can decode the access token’s payload with the urlsafe_b64decode
function as shown above. This is why you must never store sensitive user data (especially passwords) in a JWT.
The values stored in this token are common to most applications: the time when the token was created, the time when the token expires, a value used to identify the user, and a flag indicating the role/access level of the user.
Since anybody can edit the value of the payload before sending it back to the server, we want to ensure that the token is rejected if it has been modified. It is especially important to ensure that if the “admin” value is flipped to “True”, the user is not given access to protected resources.
>>> payload['admin'] = True
>>> print(f'payload (modified):\n {json.dumps(payload, indent=2)}')
payload (modified):
{
"exp": 1557940700,
"iat": 1557939795,
"sub": "38c030a0-57a4-46dc-ac9f-e70d049e3016",
"admin": true
}
We have changed the “admin” value to True and printed the modified payload value to show that the value is now flipped.
>>> payload_mod = json.dumps(payload)
>>> payload_mod_base64 = urlsafe_b64encode(payload_mod.encode())
>>> split[1] = payload_mod_base64.strip(b'=')
>>> print(f'access_token (modified):\n header: {split[0]}\n payload: {split[1]}\n signature: {split[2]}')
access_token (modified):
header: b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9'
payload: b'eyJleHAiOiAxNTU3OTQwNzAwLCAiaWF0IjogMTU1NzkzOTc5NSwgInN1YiI6ICIzOGMwMzBhMC01N2E0LTQ2ZGMtYWM5Zi1lNzBkMDQ5ZTMwMTYiLCAiYWRtaW4iOiB0cnVlfQ'
signature: b'EvNgTtgbgxpEmJedAOwLuxf6YSq09N2GCRmQOxF2REs'
We use the urlsafe_b64encode
function to encode the modified payload and then we replace the original encoded payload with this new value. Note that the header and signature portions are the same as the original access token that was generated by the server.
>>> access_token_mod = b'.'.join(split)
>>> result = User.decode_access_token(access_token_mod)
>>> result.success
False
>>> result.error
'Invalid token. Please log in again.'
>>> exit()
As expected, the modified access token is not decoded successfully and the error message indicates that the token is invalid, which is expected if the contents of the token have been modified in any way. Therefore, our hypothetical malicious user would not be able to give himself admin access by modifying the JWT. Crisis averted!
Let’s run tox
and make sure that all test cases pass:
(flask-api-tutorial) flask-api-tutorial $ tox
GLOB sdist-make: /Users/aaronluna/Projects/flask-api-tutorial/setup.py
py37 create: /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37
py37 installdeps: black, flake8, pydocstyle, pytest, pytest-black, pytest-clarity, pytest-dotenv, pytest-flake8, pytest-flask
py37 inst: /Users/aaronluna/Projects/flask-api-tutorial/.tox/.tmp/package/1/flask-api-tutorial-0.1.zip
py37 installed: alembic==1.4.0,aniso8601==8.0.0,appdirs==1.4.3,attrs==19.3.0,bcrypt==3.1.7,black==19.10b0,certifi==2019.11.28,cffi==1.14.0,chardet==3.0.4,Click==7.0,entrypoints==0.3,flake8==3.7.9,Flask==1.1.1,flask-api-tutorial==0.1,Flask-Bcrypt==0.7.1,Flask-Cors==3.0.8,Flask-Migrate==2.5.2,flask-restx==0.1.1,Flask-SQLAlchemy==2.4.1,idna==2.9,importlib-metadata==1.5.0,itsdangerous==1.1.0,Jinja2==2.11.1,jsonschema==3.2.0,Mako==1.1.1,MarkupSafe==1.1.1,mccabe==0.6.1,more-itertools==8.2.0,packaging==20.1,pathspec==0.7.0,pluggy==0.13.1,py==1.8.1,pycodestyle==2.5.0,pycparser==2.19,pydocstyle==5.0.2,pyflakes==2.1.1,PyJWT==1.7.1,pyparsing==2.4.6,pyrsistent==0.15.7,pytest==5.3.5,pytest-black==0.3.8,pytest-clarity==0.3.0a0,pytest-dotenv==0.4.0,pytest-flake8==1.0.4,pytest-flask==0.15.1,python-dateutil==2.8.1,python-dotenv==0.11.0,python-editor==1.0.4,pytz==2019.3,regex==2020.2.20,requests==2.23.0,six==1.14.0,snowballstemmer==2.0.0,SQLAlchemy==1.3.13,termcolor==1.1.0,toml==0.10.0,typed-ast==1.4.1,urllib3==1.25.8,wcwidth==0.1.8,Werkzeug==0.16.1,zipp==3.0.0
py37 run-test-pre: PYTHONHASHSEED='2592492654'
py37 run-test: commands[0] | pytest
================================================= test session starts ==================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Projects/flask-api-tutorial/.tox/py37/bin/python
cachedir: .tox/py37/.pytest_cache
rootdir: /Users/aaronluna/Projects/flask-api-tutorial, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, dotenv-0.4.0, flask-0.15.1, flake8-1.0.4
collected 41 items
run.py::FLAKE8 PASSED [ 2%]
run.py::BLACK PASSED [ 4%]
setup.py::FLAKE8 PASSED [ 7%]
setup.py::BLACK PASSED [ 9%]
src/flask_api_tutorial/__init__.py::FLAKE8 PASSED [ 12%]
src/flask_api_tutorial/__init__.py::BLACK PASSED [ 14%]
src/flask_api_tutorial/config.py::FLAKE8 PASSED [ 17%]
src/flask_api_tutorial/config.py::BLACK PASSED [ 19%]
src/flask_api_tutorial/api/__init__.py::FLAKE8 PASSED [ 21%]
src/flask_api_tutorial/api/__init__.py::BLACK PASSED [ 24%]
src/flask_api_tutorial/api/auth/__init__.py::FLAKE8 PASSED [ 26%]
src/flask_api_tutorial/api/auth/__init__.py::BLACK PASSED [ 29%]
src/flask_api_tutorial/api/widgets/__init__.py::FLAKE8 PASSED [ 31%]
src/flask_api_tutorial/api/widgets/__init__.py::BLACK PASSED [ 34%]
src/flask_api_tutorial/models/__init__.py::FLAKE8 PASSED [ 36%]
src/flask_api_tutorial/models/__init__.py::BLACK PASSED [ 39%]
src/flask_api_tutorial/models/user.py::FLAKE8 PASSED [ 41%]
src/flask_api_tutorial/models/user.py::BLACK PASSED [ 43%]
src/flask_api_tutorial/util/__init__.py::FLAKE8 PASSED [ 46%]
src/flask_api_tutorial/util/__init__.py::BLACK PASSED [ 48%]
src/flask_api_tutorial/util/datetime_util.py::FLAKE8 PASSED [ 51%]
src/flask_api_tutorial/util/datetime_util.py::BLACK PASSED [ 53%]
src/flask_api_tutorial/util/result.py::FLAKE8 PASSED [ 56%]
src/flask_api_tutorial/util/result.py::BLACK PASSED [ 58%]
tests/__init__.py::FLAKE8 PASSED [ 60%]
tests/__init__.py::BLACK PASSED [ 63%]
tests/conftest.py::FLAKE8 PASSED [ 65%]
tests/conftest.py::BLACK PASSED [ 68%]
tests/test_config.py::FLAKE8 PASSED [ 70%]
tests/test_config.py::BLACK PASSED [ 73%]
tests/test_config.py::test_config_development PASSED [ 75%]
tests/test_config.py::test_config_testing PASSED [ 78%]
tests/test_config.py::test_config_production PASSED [ 80%]
tests/test_user.py::FLAKE8 PASSED [ 82%]
tests/test_user.py::BLACK PASSED [ 85%]
tests/test_user.py::test_encode_access_token PASSED [ 87%]
tests/test_user.py::test_decode_access_token_success PASSED [ 90%]
tests/test_user.py::test_decode_access_token_expired PASSED [ 92%]
tests/test_user.py::test_decode_access_token_invalid PASSED [ 95%]
tests/util.py::FLAKE8 PASSED [ 97%]
tests/util.py::BLACK PASSED [100%]
================================================= 41 passed in 12.27s ==================================================
_______________________________________________________ summary ________________________________________________________
py37: commands succeeded
congratulations :)
Checkpoint
I promise that the pace will pick up, since we have again made very little progress on the API requirements. I believe it’s fair to say that one item is completely implemented: JWT contains the following claims: time the token was issued, time the token expires, a value that identifies the user, and a flag that indicates if the user has administrator access. I think we can also claim partial credit on two items: Requests must be rejected if JWT has been modified and Requests must be rejected if JWT is expired.
User Management/JWT Authentication
New users can register by providing an email address and password
Existing users can obtain a JWT by providing their email address and password
JWT contains the following claims: time the token was issued, time the token expires, a value that identifies the user, and a flag that indicates if the user has administrator access
JWT is sent in access_token field of HTTP response after successful authentication with email/password
JWTs must expire after 1 hour (in production)
JWT is sent by client in Authorization field of request header
Requests must be rejected if JWT has been modified
Requests must be rejected if JWT is expired
If user logs out, their JWT is immediately invalid/expired
If JWT is expired, user must re-authenticate with email/password to obtain a new JWT
API Resource: Widget List
All users can retrieve a list of all widgets
All users can retrieve individual widgets by name
Users with administrator access can add new widgets to the database
Users with administrator access can edit existing widgets
Users with administrator access can delete widgets from the database
The widget model contains a "name" attribute which must be a string value containing only lowercase-letters, numbers and the "-" (hyphen character) or "_" (underscore character).
The widget model contains a "deadline" attribute which must be a datetime value where the date component is equal to or greater than the current date. The comparison does not consider the value of the time component when this comparison is performed.
URL and datetime values must be validated before a new widget is added to the database (and when an existing widget is updated).
The widget model contains a "name" field which must be a string value containing only lowercase-letters, numbers and the "-" (hyphen character) or "_" (underscore character).
Widget name must be validated before a new widget is added to the database (and when an existing widget is updated).
If input validation fails either when adding a new widget or editing an existing widget, the API response must include error messages indicating the name(s) of the fields that failed validation.
As always, leave your feedback and questions in the comments!