Skip to content

Commit 8f7a9bd

Browse files
authored
Merge branch 'master' into BUGFIX/models-pk-instead-of-models-id
2 parents 16cc0d8 + 7e13413 commit 8f7a9bd

24 files changed

+318
-85
lines changed

Diff for: .github/workflows/test.yml

+3-23
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,19 @@ jobs:
1010
fail-fast: false
1111
matrix:
1212
python-version:
13-
- '3.8'
14-
- '3.9'
1513
- '3.10'
1614
- '3.11'
1715
- '3.12'
1816
django-version:
19-
- '3.2'
20-
- '4.0'
21-
- '4.1'
2217
- '4.2'
2318
- '5.0'
19+
- '5.1'
2420
- 'main'
25-
exclude:
21+
include:
2622
# https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django
27-
28-
# < Python 3.10 is not supported by Django 5.0+
29-
- python-version: '3.8'
30-
django-version: '5.0'
31-
- python-version: '3.9'
32-
django-version: '5.0'
3323
- python-version: '3.8'
34-
django-version: 'main'
24+
django-version: '4.2'
3525
- python-version: '3.9'
36-
django-version: 'main'
37-
38-
# Python 3.12 is not supported by Django < 5.0
39-
- python-version: '3.12'
40-
django-version: '3.2'
41-
- python-version: '3.12'
42-
django-version: '4.0'
43-
- python-version: '3.12'
44-
django-version: '4.1'
45-
- python-version: '3.12'
4626
django-version: '4.2'
4727

4828
steps:

Diff for: .pre-commit-config.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/psf/black
3-
rev: 24.4.2
3+
rev: 24.8.0
44
hooks:
55
- id: black
66
exclude: ^(oauth2_provider/migrations/|tests/migrations/)
@@ -21,7 +21,7 @@ repos:
2121
- id: isort
2222
exclude: ^(oauth2_provider/migrations/|tests/migrations/)
2323
- repo: https://github.com/PyCQA/flake8
24-
rev: 7.1.0
24+
rev: 7.1.1
2525
hooks:
2626
- id: flake8
2727
exclude: ^(oauth2_provider/migrations/|tests/migrations/)

Diff for: AUTHORS

+3
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Dylan Tack
5151
Eduardo Oliveira
5252
Egor Poderiagin
5353
Emanuele Palazzetti
54+
Fazeel Ghafoor
5455
Federico Dolce
5556
Florian Demmer
5657
Frederico Vieira
@@ -83,6 +84,7 @@ Kristian Rune Larsen
8384
Lazaros Toumanidis
8485
Ludwig Hähne
8586
Łukasz Skarżyński
87+
Madison Swain-Bowden
8688
Marcus Sonestedt
8789
Matias Seniquiel
8890
Michael Howitz
@@ -105,6 +107,7 @@ Shaun Stanworth
105107
Sayyid Hamid Mahdavi
106108
Silvano Cerza
107109
Sora Yanai
110+
Sören Wegener
108111
Spencer Carroll
109112
Stéphane Raimbault
110113
Tom Evans

Diff for: CHANGELOG.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
## [unreleased]
1818
### Added
19+
* Add migration to include `token_checksum` field in AbstractAccessToken model.
20+
* #1404 Add a new setting `REFRESH_TOKEN_REUSE_PROTECTION`
1921
### Changed
22+
* Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims
23+
* Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation.
24+
* #1446 use generic models pk instead of id.
25+
2026
### Deprecated
2127
### Removed
2228
* #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274
29+
* Remove support for Django versions below 4.2
2330

2431
### Fixed
25-
* now all part of code use pk instead of id for models.
32+
* #1443 Query strings with invalid hex values now raise a SuspiciousOperation exception (in DRF extension) instead of raising a 500 ValueError: Invalid hex encoding in query string.
2633
### Security
2734

2835
## [2.4.0] - 2024-05-13

Diff for: README.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Requirements
4444
------------
4545

4646
* Python 3.8+
47-
* Django 3.2, 4.0 (4.0.1+ due to a regression), 4.1, 4.2, or 5.0
47+
* Django 4.2, 5.0 or 5.1
4848
* oauthlib 3.1+
4949

5050
Installation

Diff for: docs/index.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Requirements
2222
------------
2323

2424
* Python 3.8+
25-
* Django 3.2, 4.0 (4.0.1+ due to a regression), 4.1, 4.2, or 5.0
25+
* Django 4.2, 5.0 or 5.1
2626
* oauthlib 3.1+
2727

2828
Index

Diff for: docs/settings.rst

+12
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,18 @@ The import string of the class (model) representing your refresh tokens. Overwri
185185
this value if you wrote your own implementation (subclass of
186186
``oauth2_provider.models.RefreshToken``).
187187

188+
REFRESH_TOKEN_REUSE_PROTECTION
189+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
190+
When this is set to ``True`` (default ``False``), and ``ROTATE_REFRESH_TOKEN`` is used, the server will check
191+
if a previously, already revoked refresh token is used a second time. If it detects a reuse, it will automatically
192+
revoke all related refresh tokens.
193+
A reused refresh token indicates a breach. Since the server can't determine which request came from the legitimate
194+
user and which from an attacker, it will end the session for both. The user is required to perform a new login.
195+
196+
Can be used in combination with ``REFRESH_TOKEN_GRACE_PERIOD_SECONDS``
197+
198+
More details at https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations
199+
188200
ROTATE_REFRESH_TOKEN
189201
~~~~~~~~~~~~~~~~~~~~
190202
When is set to ``True`` (default) a new refresh token is issued to the client when the client refreshes an access token.

Diff for: oauth2_provider/contrib/rest_framework/authentication.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import OrderedDict
22

3+
from django.core.exceptions import SuspiciousOperation
34
from rest_framework.authentication import BaseAuthentication
45

56
from ...oauth2_backends import get_oauthlib_core
@@ -23,10 +24,18 @@ def authenticate(self, request):
2324
Returns two-tuple of (user, token) if authentication succeeds,
2425
or None otherwise.
2526
"""
27+
if request is None:
28+
return None
2629
oauthlib_core = get_oauthlib_core()
27-
valid, r = oauthlib_core.verify_request(request, scopes=[])
28-
if valid:
29-
return r.user, r.access_token
30+
try:
31+
valid, r = oauthlib_core.verify_request(request, scopes=[])
32+
except ValueError as error:
33+
if str(error) == "Invalid hex encoding in query string.":
34+
raise SuspiciousOperation(error)
35+
raise
36+
else:
37+
if valid:
38+
return r.user, r.access_token
3039
request.oauth2_error = getattr(r, "oauth2_error", {})
3140
return None
3241

Diff for: oauth2_provider/middleware.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import hashlib
12
import logging
23

34
from django.contrib.auth import authenticate
@@ -55,7 +56,8 @@ def __call__(self, request):
5556
tokenstring = authheader.split()[1]
5657
AccessToken = get_access_token_model()
5758
try:
58-
token = AccessToken.objects.get(token=tokenstring)
59+
token_checksum = hashlib.sha256(tokenstring.encode("utf-8")).hexdigest()
60+
token = AccessToken.objects.get(token_checksum=token_checksum)
5961
request.access_token = token
6062
except AccessToken.DoesNotExist as e:
6163
log.exception(e)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 5.2 on 2024-08-09 16:40
2+
3+
from django.db import migrations, models
4+
from oauth2_provider.settings import oauth2_settings
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('oauth2_provider', '0010_application_allowed_origins'),
10+
migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL)
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='refreshtoken',
16+
name='token_family',
17+
field=models.UUIDField(blank=True, editable=False, null=True),
18+
),
19+
]
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 5.0.7 on 2024-07-29 23:13
2+
3+
import oauth2_provider.models
4+
from django.db import migrations, models
5+
from oauth2_provider.settings import oauth2_settings
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("oauth2_provider", "0011_refreshtoken_token_family"),
10+
migrations.swappable_dependency(oauth2_settings.ACCESS_TOKEN_MODEL),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="accesstoken",
16+
name="token_checksum",
17+
field=oauth2_provider.models.TokenChecksumField(
18+
blank=True, db_index=True, max_length=64, unique=True
19+
),
20+
),
21+
migrations.AlterField(
22+
model_name="accesstoken",
23+
name="token",
24+
field=models.TextField(),
25+
),
26+
]

Diff for: oauth2_provider/models.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import hashlib
12
import logging
23
import time
34
import uuid
@@ -44,6 +45,14 @@ def pre_save(self, model_instance, add):
4445
return super().pre_save(model_instance, add)
4546

4647

48+
class TokenChecksumField(models.CharField):
49+
def pre_save(self, model_instance, add):
50+
token = getattr(model_instance, "token")
51+
checksum = hashlib.sha256(token.encode("utf-8")).hexdigest()
52+
setattr(model_instance, self.attname, checksum)
53+
return super().pre_save(model_instance, add)
54+
55+
4756
class AbstractApplication(models.Model):
4857
"""
4958
An Application instance represents a Client on the Authorization server.
@@ -380,8 +389,10 @@ class AbstractAccessToken(models.Model):
380389
null=True,
381390
related_name="refreshed_access_token",
382391
)
383-
token = models.CharField(
384-
max_length=255,
392+
token = models.TextField()
393+
token_checksum = TokenChecksumField(
394+
max_length=64,
395+
blank=True,
385396
unique=True,
386397
db_index=True,
387398
)
@@ -491,6 +502,7 @@ class AbstractRefreshToken(models.Model):
491502
null=True,
492503
related_name="refresh_token",
493504
)
505+
token_family = models.UUIDField(null=True, blank=True, editable=False)
494506

495507
created = models.DateTimeField(auto_now_add=True)
496508
updated = models.DateTimeField(auto_now=True)

Diff for: oauth2_provider/oauth2_validators.py

+31-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import binascii
3+
import hashlib
34
import http.client
45
import inspect
56
import json
@@ -15,7 +16,6 @@
1516
from django.contrib.auth.hashers import check_password, identify_hasher
1617
from django.core.exceptions import ObjectDoesNotExist
1718
from django.db import transaction
18-
from django.db.models import Q
1919
from django.http import HttpRequest
2020
from django.utils import dateformat, timezone
2121
from django.utils.crypto import constant_time_compare
@@ -462,7 +462,12 @@ def validate_bearer_token(self, token, scopes, request):
462462
return False
463463

464464
def _load_access_token(self, token):
465-
return AccessToken.objects.select_related("application", "user").filter(token=token).first()
465+
token_checksum = hashlib.sha256(token.encode("utf-8")).hexdigest()
466+
return (
467+
AccessToken.objects.select_related("application", "user")
468+
.filter(token_checksum=token_checksum)
469+
.first()
470+
)
466471

467472
def validate_code(self, client_id, code, client, request, *args, **kwargs):
468473
try:
@@ -644,7 +649,9 @@ def save_bearer_token(self, token, request, *args, **kwargs):
644649
source_refresh_token=refresh_token_instance,
645650
)
646651

647-
self._create_refresh_token(request, refresh_token_code, access_token)
652+
self._create_refresh_token(
653+
request, refresh_token_code, access_token, refresh_token_instance
654+
)
648655
else:
649656
# make sure that the token data we're returning matches
650657
# the existing token
@@ -688,9 +695,17 @@ def _create_authorization_code(self, request, code, expires=None):
688695
claims=json.dumps(request.claims or {}),
689696
)
690697

691-
def _create_refresh_token(self, request, refresh_token_code, access_token):
698+
def _create_refresh_token(self, request, refresh_token_code, access_token, previous_refresh_token):
699+
if previous_refresh_token:
700+
token_family = previous_refresh_token.token_family
701+
else:
702+
token_family = uuid.uuid4()
692703
return RefreshToken.objects.create(
693-
user=request.user, token=refresh_token_code, application=request.client, access_token=access_token
704+
user=request.user,
705+
token=refresh_token_code,
706+
application=request.client,
707+
access_token=access_token,
708+
token_family=token_family,
694709
)
695710

696711
def revoke_token(self, token, token_type_hint, request, *args, **kwargs):
@@ -752,22 +767,25 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs
752767
Also attach User instance to the request object
753768
"""
754769

755-
null_or_recent = Q(revoked__isnull=True) | Q(
756-
revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS)
757-
)
758-
rt = (
759-
RefreshToken.objects.filter(null_or_recent, token=refresh_token)
760-
.select_related("access_token")
761-
.first()
762-
)
770+
rt = RefreshToken.objects.filter(token=refresh_token).select_related("access_token").first()
763771

764772
if not rt:
765773
return False
766774

775+
if rt.revoked is not None and rt.revoked <= timezone.now() - timedelta(
776+
seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS
777+
):
778+
if oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION and rt.token_family:
779+
rt_token_family = RefreshToken.objects.filter(token_family=rt.token_family)
780+
for related_rt in rt_token_family.all():
781+
related_rt.revoke()
782+
return False
783+
767784
request.user = rt.user
768785
request.refresh_token = rt.token
769786
# Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token.
770787
request.refresh_token_instance = rt
788+
771789
return rt.application == client
772790

773791
@transaction.atomic

Diff for: oauth2_provider/settings.py

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"ID_TOKEN_EXPIRE_SECONDS": 36000,
5555
"REFRESH_TOKEN_EXPIRE_SECONDS": None,
5656
"REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0,
57+
"REFRESH_TOKEN_REUSE_PROTECTION": False,
5758
"ROTATE_REFRESH_TOKEN": True,
5859
"ERROR_RESPONSE_WITH_SCOPES": False,
5960
"APPLICATION_MODEL": APPLICATION_MODEL,

0 commit comments

Comments
 (0)