Skip to content

Commit d7ef40e

Browse files
authored
Resolve #453, better options for revoking both refresh and access (#460)
* Added basics for verify_type, addes some type hints, added missing flake8 dependency * More type hinting * Finished type hinting, tests passed * Working on docs * Work on docs * Work on docs * More documentation work * Finished documentation * Fixed docs * Removed unused get_token_type * Added test for no typecheck * Updated docs, tests passed * Fixed a minor grammatical error, nobody is getting insurance money from this:) * Final formatting, ready for PR. Closes #453. * Removed typehint from example * Final type hint updates * Remove flake8 from requirements Co-authored-by: Trevor Gross <[email protected]>
1 parent 2c7ae9e commit d7ef40e

23 files changed

+456
-221
lines changed

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ celerybeat-schedule
8181

8282
# virtualenv
8383
venv/
84+
.venv/
8485
ENV/
8586

8687
# Spyder project settings
@@ -94,3 +95,6 @@ ENV/
9495

9596
# MacOS specific crap
9697
.DS_Store
98+
99+
# Workspace
100+
.vscode/

README.md

+29-18
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,73 @@
11
# Flask-JWT-Extended
22

33
### Features
4+
45
Flask-JWT-Extended not only adds support for using JSON Web Tokens (JWT) to Flask for protecting routes,
5-
but also many helpful (and **optional**) features built in to make working with JSON Web Tokens
6+
but also many helpful (and **optional**) features built in to make working with JSON Web Tokens
67
easier. These include:
78

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

1617
### Usage
18+
1719
[View the documentation online](https://flask-jwt-extended.readthedocs.io/en/stable/)
1820

1921
### Upgrading from 3.x.x to 4.0.0
22+
2023
[View the changes](https://flask-jwt-extended.readthedocs.io/en/stable/v4_upgrade_guide/)
2124

2225
### Changelog
26+
2327
You can view the changelog [here](https://github.com/vimalloc/flask-jwt-extended/releases).
2428
This project follows [semantic versioning](https://semver.org/).
2529

2630
### Chatting
31+
2732
Come chat with the community or ask questions at https://discord.gg/EJBsbFd
2833

2934
### Contributing
35+
3036
Before making any changes, make sure to install the development requirements
3137
and setup the git hooks which will automatically lint and format your changes.
38+
3239
```bash
3340
pip install -r requirements.txt
3441
pre-commit install
3542
```
3643

3744
We require 100% code coverage in our unit tests. You can run the tests locally
38-
with `tox` which insures that all tests pass, tests provide complete code coverage,
45+
with `tox` which ensures that all tests pass, tests provide complete code coverage,
3946
documentation builds, and style guide are adhered to
47+
4048
```bash
4149
tox
4250
```
4351

4452
A subset of checks can also be ran by adding an argument to tox. The available
4553
arguments are:
46-
* py36, py37, py38, py39, pypy3
47-
* Run unit tests on the given python version
48-
* coverage
49-
* Run a code coverage check
50-
* docs
51-
* Insure documentation builds and there are no broken links
52-
* style
53-
* Insure style guide is adhered to
54+
55+
- py36, py37, py38, py39, pypy3
56+
- Run unit tests on the given python version
57+
- coverage
58+
- Run a code coverage check
59+
- docs
60+
- Ensure documentation builds and there are no broken links
61+
- style
62+
- Ensure style guide is adhered to
63+
5464
```bash
5565
tox -e py38
5666
```
5767

58-
We also require features to be well documented. You can generate a local copy
68+
We also require features to be well documented. You can generate a local copy
5969
of the documentation by going to the `docs` directory and running:
70+
6071
```bash
6172
make clean && make html && open _build/html/index.html
6273
```

docs/blocklist_and_token_revoking.rst

+87
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ Live (TTL) functionality when storing a JWT. Here is an example using redis:
2525

2626
.. literalinclude:: ../examples/blocklist_redis.py
2727

28+
29+
.. warning::
30+
Note that configuring redis to be disk-persistent is an absolutely necessity for
31+
production use. Otherwise, events like power outages or server crashes/reboots
32+
would cause all invalidated tokens to become valid again (assuming the
33+
secret key does not change). This is especially concering for long-lived
34+
refresh tokens, discussed below.
35+
2836
Database
2937
~~~~~~~~
3038
If you need to keep track of information about revoked JWTs our recommendation is
@@ -33,3 +41,82 @@ revoked tokens, such as when it was revoked, who revoked it, can it be un-revoke
3341
etc. Here is an example using SQLAlchemy:
3442

3543
.. literalinclude:: ../examples/blocklist_database.py
44+
45+
Revoking Refresh Tokens
46+
~~~~~~~~~~~~~~~~~~~~~~~
47+
It is critical to note that a user's refresh token must also be revoked
48+
when logging out; otherwise, this refresh token could just be used to generate
49+
a new access token. Usually this falls to the responsibility of the frontend
50+
application, which must send two separate requests to the backend in order to
51+
revoke these tokens.
52+
53+
This can be implemented via two separate routes marked with ``@jwt_required()``
54+
and ``@jwt_required(refresh=True)`` to revoke access and refresh tokens, respectively.
55+
However, it is more convenient to provide a single endpoint where the frontend
56+
can send a DELETE for each token. The following is an example:
57+
58+
.. code-block:: python
59+
60+
@app.route("/logout", methods=["DELETE"])
61+
@jwt_required(verify_type=False)
62+
def logout():
63+
token = get_jwt()
64+
jti = token["jti"]
65+
ttype = token["type"]
66+
jwt_redis_blocklist.set(jti, "", ex=ACCESS_EXPIRES)
67+
68+
# Returns "Access token revoked" or "Refresh token revoked"
69+
return jsonify(msg=f"{ttype.capitalize()} token successfully revoked")
70+
71+
or, for the database format:
72+
73+
.. code-block:: python
74+
75+
class TokenBlocklist(db.Model):
76+
id = db.Column(db.Integer, primary_key=True)
77+
jti = db.Column(db.String(36), nullable=False, index=True)
78+
type = db.Column(db.String(16), nullable=False)
79+
user_id = db.Column(
80+
db.ForeignKey('person.id'),
81+
default=lambda: get_current_user().id,
82+
nullable=False,
83+
)
84+
created_at = db.Column(
85+
db.DateTime,
86+
server_default=func.now(),
87+
nullable=False,
88+
)
89+
90+
@app.route("/logout", methods=["DELETE"])
91+
@jwt_required(verify_type=False)
92+
def modify_token():
93+
token = get_jwt()
94+
jti = token["jti"]
95+
ttype = token["type"]
96+
now = datetime.now(timezone.utc)
97+
db.session.add(TokenBlocklist(jti=jti, type=ttype, created_at=now))
98+
db.session.commit()
99+
return jsonify(msg=f"{ttype.capitalize()} token successfully revoked")
100+
101+
102+
Token type and user columns are not required and can be omitted. That being said, including
103+
these can help to audit that the frontend is performing its revoking job correctly and revoking both tokens.
104+
105+
Alternatively, there are a few ways to revoke both tokens at once:
106+
107+
#. Send the access token in the header (per usual), and send the refresh token in
108+
the DELETE request body. This saves a request but still needs frontend changes, so may not
109+
be worth implementing
110+
#. Embed the refresh token's jti in the access token. The revoke route should be authenticated
111+
with the access token. Upon revoking the access token, extract the refresh jti from it
112+
and invalidate both. This has the advantage of requiring no extra work from the frontend.
113+
#. Store every generated tokens jti in a database upon creation. Have a boolean column to represent
114+
whether it is valid or not, which the ``token_in_blocklist_loader`` should respond based upon.
115+
Upon revoking a token, mark that token row as invalid, as well as all other tokens from the same
116+
user generated at the same time. This would also allow for a "log out everywhere" option where
117+
all tokens for a user are invalidated at once, which is otherwise not easily possibile
118+
119+
120+
The best option of course depends and needs to be chosen based upon the circumstances. If there
121+
if ever a time where an unknown, untracked token needs to be immediately invalidated, this can
122+
be accomplished by changing the secret key.

docs/conf.py

+9
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,12 @@
358358
# If true, do not generate a @detailmenu in the "Top" node's menu.
359359
#
360360
# texinfo_no_detailmenu = False
361+
362+
# Fix warnings about refernce targets. See link:
363+
# https://stackoverflow.com/questions/11417221/
364+
# sphinx-autodoc-gives-warning-pyclass-reference-target-not-found-type-warning
365+
nitpick_ignore = [
366+
("py:class", "flask.app.Flask"),
367+
("py:class", "datetime.timedelta"),
368+
("py:class", "flask.wrappers.Response"),
369+
]

docs/options.rst

+5
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ General Options:
9393

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

96+
Note: there is ever a need to invalidate all issued tokens (e.g. a security flaw was found,
97+
or the revoked token database was lost), this can be easily done by changing the JWT_SECRET_KEY
98+
(or Flask's SECRET_KEY, if JWT_SECRET_KEY is unset).
99+
100+
96101
Default: ``None``
97102

98103

docs/refreshing_tokens.rst

+9
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,19 @@ website (mobile, api only, etc).
5050
Making a request with a refresh token looks just like making a request with
5151
an access token. Here is an example using `HTTPie <https://httpie.io/>`_.
5252

53+
54+
55+
5356
.. code-block :: bash
5457
5558
$ http POST :5000/refresh Authorization:"Bearer $REFRESH_TOKEN"
5659
60+
.. warning::
61+
62+
Note that when an access token is invalidated (e.g. logging a user out), any
63+
corresponding refresh token(s) must be revoked too. See
64+
:ref:`Revoking Refresh Tokens` for details on how to handle this.
65+
5766

5867
Token Freshness Pattern
5968
~~~~~~~~~~~~~~~~~~~~~~~

examples/blocklist_database.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,23 @@
2828
# This could be expanded to fit the needs of your application. For example,
2929
# it could track who revoked a JWT, when a token expires, notes for why a
3030
# JWT was revoked, an endpoint to un-revoked a JWT, etc.
31+
# Making jti an index can significantly speed up the search when there are
32+
# tens of thousands of records. Remember this query will happen for every
33+
# (protected) request,
34+
# If your database supports a UUID type, this can be used for the jti column
35+
# as well
3136
class TokenBlocklist(db.Model):
3237
id = db.Column(db.Integer, primary_key=True)
33-
jti = db.Column(db.String(36), nullable=False)
38+
jti = db.Column(db.String(36), nullable=False, index=True)
3439
created_at = db.Column(db.DateTime, nullable=False)
3540

3641

3742
# Callback function to check if a JWT exists in the database blocklist
3843
@jwt.token_in_blocklist_loader
39-
def check_if_token_revoked(jwt_header, jwt_payload):
44+
def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool:
4045
jti = jwt_payload["jti"]
4146
token = db.session.query(TokenBlocklist.id).filter_by(jti=jti).scalar()
47+
4248
return token is not None
4349

4450

examples/blocklist_redis.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
# Callback function to check if a JWT exists in the redis blocklist
2828
@jwt.token_in_blocklist_loader
29-
def check_if_token_is_revoked(jwt_header, jwt_payload):
29+
def check_if_token_is_revoked(jwt_header, jwt_payload: dict):
3030
jti = jwt_payload["jti"]
3131
token_in_redis = jwt_redis_blocklist.get(jti)
3232
return token_in_redis is not None

0 commit comments

Comments
 (0)