Skip to content

Commit fac9fbe

Browse files
authored
Merge branch 'master' into feat/add-token-checksum
2 parents 68e7f9b + 51d9798 commit fac9fbe

File tree

11 files changed

+205
-17
lines changed

11 files changed

+205
-17
lines changed

AUTHORS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Kristian Rune Larsen
8484
Lazaros Toumanidis
8585
Ludwig Hähne
8686
Łukasz Skarżyński
87+
Madison Swain-Bowden
8788
Marcus Sonestedt
8889
Matias Seniquiel
8990
Michael Howitz
@@ -105,6 +106,7 @@ Shaheed Haque
105106
Shaun Stanworth
106107
Silvano Cerza
107108
Sora Yanai
109+
Sören Wegener
108110
Spencer Carroll
109111
Stéphane Raimbault
110112
Tom Evans

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
## [unreleased]
1818
### Added
19-
* Add TokenChecksumField to compute and store SHA-256 checksums for tokens in AbstractAccessToken model.
2019
* Add migration to include `token_checksum` field in AbstractAccessToken model.
20+
* #1404 Add a new setting `REFRESH_TOKEN_REUSE_PROTECTION`
2121
### Changed
22-
* Update token to TextField from CharField with 255 character limit in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims
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
2323

2424
* Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation.
2525
### Deprecated
2626
### Removed
2727
* #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274
2828

2929
### Fixed
30+
* #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.
3031
### Security
3132

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

docs/settings.rst

Lines changed: 12 additions & 0 deletions
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.

oauth2_provider/contrib/rest_framework/authentication.py

Lines changed: 12 additions & 3 deletions
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

Lines changed: 19 additions & 0 deletions
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+
]

oauth2_provider/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ class AbstractRefreshToken(models.Model):
501501
null=True,
502502
related_name="refresh_token",
503503
)
504+
token_family = models.UUIDField(null=True, blank=True, editable=False)
504505

505506
created = models.DateTimeField(auto_now_add=True)
506507
updated = models.DateTimeField(auto_now=True)

oauth2_provider/oauth2_validators.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from django.contrib.auth.hashers import check_password, identify_hasher
1717
from django.core.exceptions import ObjectDoesNotExist
1818
from django.db import transaction
19-
from django.db.models import Q
2019
from django.http import HttpRequest
2120
from django.utils import dateformat, timezone
2221
from django.utils.crypto import constant_time_compare
@@ -650,7 +649,9 @@ def save_bearer_token(self, token, request, *args, **kwargs):
650649
source_refresh_token=refresh_token_instance,
651650
)
652651

653-
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+
)
654655
else:
655656
# make sure that the token data we're returning matches
656657
# the existing token
@@ -694,9 +695,17 @@ def _create_authorization_code(self, request, code, expires=None):
694695
claims=json.dumps(request.claims or {}),
695696
)
696697

697-
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()
698703
return RefreshToken.objects.create(
699-
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,
700709
)
701710

702711
def revoke_token(self, token, token_type_hint, request, *args, **kwargs):
@@ -758,22 +767,25 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs
758767
Also attach User instance to the request object
759768
"""
760769

761-
null_or_recent = Q(revoked__isnull=True) | Q(
762-
revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS)
763-
)
764-
rt = (
765-
RefreshToken.objects.filter(null_or_recent, token=refresh_token)
766-
.select_related("access_token")
767-
.first()
768-
)
770+
rt = RefreshToken.objects.filter(token=refresh_token).select_related("access_token").first()
769771

770772
if not rt:
771773
return False
772774

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+
773784
request.user = rt.user
774785
request.refresh_token = rt.token
775786
# Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token.
776787
request.refresh_token_instance = rt
788+
777789
return rt.application == client
778790

779791
@transaction.atomic

oauth2_provider/settings.py

Lines changed: 1 addition & 0 deletions
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,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('tests', '0005_basetestapplication_allowed_origins_and_more'),
11+
migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL)
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='samplerefreshtoken',
17+
name='token_family',
18+
field=models.UUIDField(blank=True, editable=False, null=True),
19+
),
20+
]

tests/test_authorization_code.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,54 @@ def test_refresh_fail_repeating_requests(self):
985985
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
986986
self.assertEqual(response.status_code, 400)
987987

988+
def test_refresh_repeating_requests_revokes_old_token(self):
989+
"""
990+
If a refresh token is reused, the server should invalidate *all* access tokens that have a relation
991+
to the re-used token. This forces a malicious actor to be logged out.
992+
The server can't determine whether the first or the second client was legitimate, so it needs to
993+
revoke both.
994+
See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations
995+
"""
996+
self.oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION = True
997+
self.client.login(username="test_user", password="123456")
998+
authorization_code = self.get_auth()
999+
1000+
token_request_data = {
1001+
"grant_type": "authorization_code",
1002+
"code": authorization_code,
1003+
"redirect_uri": "http://example.org",
1004+
}
1005+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
1006+
1007+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
1008+
content = json.loads(response.content.decode("utf-8"))
1009+
self.assertTrue("refresh_token" in content)
1010+
1011+
token_request_data = {
1012+
"grant_type": "refresh_token",
1013+
"refresh_token": content["refresh_token"],
1014+
"scope": content["scope"],
1015+
}
1016+
# First response works as usual
1017+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
1018+
self.assertEqual(response.status_code, 200)
1019+
new_tokens = json.loads(response.content.decode("utf-8"))
1020+
1021+
# Second request fails
1022+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
1023+
self.assertEqual(response.status_code, 400)
1024+
1025+
# Previously returned tokens are now invalid as well
1026+
new_token_request_data = {
1027+
"grant_type": "refresh_token",
1028+
"refresh_token": new_tokens["refresh_token"],
1029+
"scope": new_tokens["scope"],
1030+
}
1031+
response = self.client.post(
1032+
reverse("oauth2_provider:token"), data=new_token_request_data, **auth_headers
1033+
)
1034+
self.assertEqual(response.status_code, 400)
1035+
9881036
def test_refresh_repeating_requests(self):
9891037
"""
9901038
Trying to refresh an access token with the same refresh token more than
@@ -1024,6 +1072,63 @@ def test_refresh_repeating_requests(self):
10241072
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
10251073
self.assertEqual(response.status_code, 400)
10261074

1075+
def test_refresh_repeating_requests_grace_period_with_reuse_protection(self):
1076+
"""
1077+
Trying to refresh an access token with the same refresh token more than
1078+
once succeeds. Should work within the grace period, but should revoke previous tokens
1079+
"""
1080+
self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120
1081+
self.oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION = True
1082+
self.client.login(username="test_user", password="123456")
1083+
authorization_code = self.get_auth()
1084+
1085+
token_request_data = {
1086+
"grant_type": "authorization_code",
1087+
"code": authorization_code,
1088+
"redirect_uri": "http://example.org",
1089+
}
1090+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
1091+
1092+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
1093+
content = json.loads(response.content.decode("utf-8"))
1094+
self.assertTrue("refresh_token" in content)
1095+
1096+
refresh_token_1 = content["refresh_token"]
1097+
token_request_data = {
1098+
"grant_type": "refresh_token",
1099+
"refresh_token": refresh_token_1,
1100+
"scope": content["scope"],
1101+
}
1102+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
1103+
self.assertEqual(response.status_code, 200)
1104+
refresh_token_2 = json.loads(response.content.decode("utf-8"))["refresh_token"]
1105+
1106+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
1107+
self.assertEqual(response.status_code, 200)
1108+
refresh_token_3 = json.loads(response.content.decode("utf-8"))["refresh_token"]
1109+
1110+
self.assertEqual(refresh_token_2, refresh_token_3)
1111+
1112+
# Let the first refresh token expire
1113+
rt = RefreshToken.objects.get(token=refresh_token_1)
1114+
rt.revoked = timezone.now() - datetime.timedelta(minutes=10)
1115+
rt.save()
1116+
1117+
# Using the expired token fails
1118+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
1119+
self.assertEqual(response.status_code, 400)
1120+
1121+
# Because we used the expired token, the recently issued token is also revoked
1122+
new_token_request_data = {
1123+
"grant_type": "refresh_token",
1124+
"refresh_token": refresh_token_2,
1125+
"scope": content["scope"],
1126+
}
1127+
response = self.client.post(
1128+
reverse("oauth2_provider:token"), data=new_token_request_data, **auth_headers
1129+
)
1130+
self.assertEqual(response.status_code, 400)
1131+
10271132
def test_refresh_repeating_requests_non_rotating_tokens(self):
10281133
"""
10291134
Try refreshing an access token with the same refresh token more than once when not rotating tokens.

0 commit comments

Comments
 (0)