Skip to content

Resolve #453, better options for revoking both refresh and access #460

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jan 25, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ celerybeat-schedule

# virtualenv
venv/
.venv/
ENV/

# Spyder project settings
Expand All @@ -94,3 +95,6 @@ ENV/

# MacOS specific crap
.DS_Store

# Workspace
.vscode/
47 changes: 29 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,73 @@
# Flask-JWT-Extended

### Features

Flask-JWT-Extended not only adds support for using JSON Web Tokens (JWT) to Flask for protecting routes,
but also many helpful (and **optional**) features built in to make working with JSON Web Tokens
but also many helpful (and **optional**) features built in to make working with JSON Web Tokens
easier. These include:

* Adding custom claims to JSON Web Tokens
* Automatic user loading (`current_user`).
* Custom claims validation on received tokens
* [Refresh tokens](https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/)
* First class support for fresh tokens for making sensitive changes.
* Token revoking/blocklisting
* Storing tokens in cookies and CSRF protection
- Adding custom claims to JSON Web Tokens
- Automatic user loading (`current_user`).
- Custom claims validation on received tokens
- [Refresh tokens](https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/)
- First class support for fresh tokens for making sensitive changes.
- Token revoking/blocklisting
- Storing tokens in cookies and CSRF protection

### Usage

[View the documentation online](https://flask-jwt-extended.readthedocs.io/en/stable/)

### Upgrading from 3.x.x to 4.0.0

[View the changes](https://flask-jwt-extended.readthedocs.io/en/stable/v4_upgrade_guide/)

### Changelog

You can view the changelog [here](https://github.com/vimalloc/flask-jwt-extended/releases).
This project follows [semantic versioning](https://semver.org/).

### Chatting

Come chat with the community or ask questions at https://discord.gg/EJBsbFd

### Contributing

Before making any changes, make sure to install the development requirements
and setup the git hooks which will automatically lint and format your changes.

```bash
pip install -r requirements.txt
pre-commit install
```

We require 100% code coverage in our unit tests. You can run the tests locally
with `tox` which insures that all tests pass, tests provide complete code coverage,
with `tox` which ensures that all tests pass, tests provide complete code coverage,
documentation builds, and style guide are adhered to

```bash
tox
```

A subset of checks can also be ran by adding an argument to tox. The available
arguments are:
* py36, py37, py38, py39, pypy3
* Run unit tests on the given python version
* coverage
* Run a code coverage check
* docs
* Insure documentation builds and there are no broken links
* style
* Insure style guide is adhered to

- py36, py37, py38, py39, pypy3
- Run unit tests on the given python version
- coverage
- Run a code coverage check
- docs
- Ensure documentation builds and there are no broken links
- style
- Ensure style guide is adhered to

```bash
tox -e py38
```

We also require features to be well documented. You can generate a local copy
We also require features to be well documented. You can generate a local copy
of the documentation by going to the `docs` directory and running:

```bash
make clean && make html && open _build/html/index.html
```
87 changes: 87 additions & 0 deletions docs/blocklist_and_token_revoking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ Live (TTL) functionality when storing a JWT. Here is an example using redis:

.. literalinclude:: ../examples/blocklist_redis.py


.. warning::
Note that configuring redis to be disk-persistent is an absolutely necessity for
production use. Otherwise, events like power outages or server crashes/reboots
would cause all invalidated tokens to become valid again (assuming the
secret key does not change). This is especially concering for long-lived
refresh tokens, discussed below.

Database
~~~~~~~~
If you need to keep track of information about revoked JWTs our recommendation is
Expand All @@ -33,3 +41,82 @@ revoked tokens, such as when it was revoked, who revoked it, can it be un-revoke
etc. Here is an example using SQLAlchemy:

.. literalinclude:: ../examples/blocklist_database.py

Revoking Refresh Tokens
~~~~~~~~~~~~~~~~~~~~~~~
It is critical to note that a user's refresh token must also be revoked
when logging out; otherwise, this refresh token could just be used to generate
a new access token. Usually this falls to the responsibility of the frontend
application, which must send two separate requests to the backend in order to
revoke these tokens.

This can be implemented via two separate routes marked with ``@jwt_required()``
and ``@jwt_required(refresh=True)`` to revoke access and refresh tokens, respectively.
However, it is more convenient to provide a single endpoint where the frontend
can send a DELETE for each token. The following is an example:

.. code-block:: python

@app.route("/logout", methods=["DELETE"])
@jwt_required(verify_type=False)
def logout():
token = get_jwt()
jti = token["jti"]
ttype = token["type"]
jwt_redis_blocklist.set(jti, "", ex=ACCESS_EXPIRES)

# Returns "Access token revoked" or "Refresh token revoked"
return jsonify(msg=f"{ttype.capitalize()} token successfully revoked")

or, for the database format:

.. code-block:: python

class TokenBlocklist(db.Model):
id = db.Column(db.Integer, primary_key=True)
jti = db.Column(db.String(36), nullable=False, index=True)
type = db.Column(db.String(16), nullable=False)
user_id = db.Column(
db.ForeignKey('person.id'),
default=lambda: get_current_user().id,
nullable=False,
)
created_at = db.Column(
db.DateTime,
server_default=func.now(),
nullable=False,
)

@app.route("/logout", methods=["DELETE"])
@jwt_required(verify_type=False)
def modify_token():
token = get_jwt()
jti = token["jti"]
ttype = token["type"]
now = datetime.now(timezone.utc)
db.session.add(TokenBlocklist(jti=jti, type=ttype, created_at=now))
db.session.commit()
return jsonify(msg=f"{ttype.capitalize()} token successfully revoked")


Token type and user columns are not required and can be omitted. That being said, including
these can help to audit that the frontend is performing its revoking job correctly and revoking both tokens.

Alternatively, there are a few ways to revoke both tokens at once:

#. Send the access token in the header (per usual), and send the refresh token in
the DELETE request body. This saves a request but still needs frontend changes, so may not
be worth implementing
#. Embed the refresh token's jti in the access token. The revoke route should be authenticated
with the access token. Upon revoking the access token, extract the refresh jti from it
and invalidate both. This has the advantage of requiring no extra work from the frontend.
#. Store every generated tokens jti in a database upon creation. Have a boolean column to represent
whether it is valid or not, which the ``token_in_blocklist_loader`` should respond based upon.
Upon revoking a token, mark that token row as invalid, as well as all other tokens from the same
user generated at the same time. This would also allow for a "log out everywhere" option where
all tokens for a user are invalidated at once, which is otherwise not easily possibile


The best option of course depends and needs to be chosen based upon the circumstances. If there
if ever a time where an unknown, untracked token needs to be immediately invalidated, this can
be accomplished by changing the secret key.
9 changes: 9 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,12 @@
# If true, do not generate a @detailmenu in the "Top" node's menu.
#
# texinfo_no_detailmenu = False

# Fix warnings about refernce targets. See link:
# https://stackoverflow.com/questions/11417221/
# sphinx-autodoc-gives-warning-pyclass-reference-target-not-found-type-warning
nitpick_ignore = [
("py:class", "flask.app.Flask"),
("py:class", "datetime.timedelta"),
("py:class", "flask.wrappers.Response"),
]
5 changes: 5 additions & 0 deletions docs/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ General Options:

**Do not reveal the secret key when posting questions or committing code.**

Note: there is ever a need to invalidate all issued tokens (e.g. a security flaw was found,
or the revoked token database was lost), this can be easily done by changing the JWT_SECRET_KEY
(or Flask's SECRET_KEY, if JWT_SECRET_KEY is unset).


Default: ``None``


Expand Down
9 changes: 9 additions & 0 deletions docs/refreshing_tokens.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,19 @@ website (mobile, api only, etc).
Making a request with a refresh token looks just like making a request with
an access token. Here is an example using `HTTPie <https://httpie.io/>`_.




.. code-block :: bash

$ http POST :5000/refresh Authorization:"Bearer $REFRESH_TOKEN"

.. warning::

Note that when an access token is invalidated (e.g. logging a user out), any
corresponding refresh token(s) must be revoked too. See
:ref:`Revoking Refresh Tokens` for details on how to handle this.


Token Freshness Pattern
~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
10 changes: 8 additions & 2 deletions examples/blocklist_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,23 @@
# This could be expanded to fit the needs of your application. For example,
# it could track who revoked a JWT, when a token expires, notes for why a
# JWT was revoked, an endpoint to un-revoked a JWT, etc.
# Making jti an index can significantly speed up the search when there are
# tens of thousands of records. Remember this query will happen for every
# (protected) request,
# If your database supports a UUID type, this can be used for the jti column
# as well
class TokenBlocklist(db.Model):
id = db.Column(db.Integer, primary_key=True)
jti = db.Column(db.String(36), nullable=False)
jti = db.Column(db.String(36), nullable=False, index=True)
created_at = db.Column(db.DateTime, nullable=False)


# Callback function to check if a JWT exists in the database blocklist
@jwt.token_in_blocklist_loader
def check_if_token_revoked(jwt_header, jwt_payload):
def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool:
jti = jwt_payload["jti"]
token = db.session.query(TokenBlocklist.id).filter_by(jti=jti).scalar()

return token is not None


Expand Down
2 changes: 1 addition & 1 deletion examples/blocklist_redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

# Callback function to check if a JWT exists in the redis blocklist
@jwt.token_in_blocklist_loader
def check_if_token_is_revoked(jwt_header, jwt_payload):
def check_if_token_is_revoked(jwt_header, jwt_payload: dict):
jti = jwt_payload["jti"]
token_in_redis = jwt_redis_blocklist.get(jti)
return token_in_redis is not None
Expand Down
Loading