Skip to content

Commit 38b9844

Browse files
committed
Implement REFRESH_TOKEN_REUSE_PROTECTION (#1404)
According to https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations, the authorization server needs a way to determine which refresh tokens belong to the same session, so it is able to figure out which tokens to revoke. Therefore, this commit introduces a "token_family" field to the RefreshToken table. Whenever a revoked refresh token is reused, the auth server uses the token family to revoke all related tokens.
1 parent 0d6ea9a commit 38b9844

File tree

6 files changed

+90
-14
lines changed

6 files changed

+90
-14
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2 on 2024-08-09 16:40
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('oauth2_provider', '0010_application_allowed_origins'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='refreshtoken',
15+
name='token_family',
16+
field=models.UUIDField(blank=True, editable=False, null=True),
17+
),
18+
]

oauth2_provider/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ class AbstractRefreshToken(models.Model):
490490
null=True,
491491
related_name="refresh_token",
492492
)
493+
token_family = models.UUIDField(null=True, blank=True, editable=False)
493494

494495
created = models.DateTimeField(auto_now_add=True)
495496
updated = models.DateTimeField(auto_now=True)

oauth2_provider/oauth2_validators.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from django.contrib.auth.hashers import check_password, identify_hasher
1616
from django.core.exceptions import ObjectDoesNotExist
1717
from django.db import transaction
18-
from django.db.models import Q
1918
from django.http import HttpRequest
2019
from django.utils import dateformat, timezone
2120
from django.utils.crypto import constant_time_compare
@@ -644,7 +643,9 @@ def save_bearer_token(self, token, request, *args, **kwargs):
644643
source_refresh_token=refresh_token_instance,
645644
)
646645

647-
self._create_refresh_token(request, refresh_token_code, access_token)
646+
self._create_refresh_token(
647+
request, refresh_token_code, access_token, refresh_token_instance
648+
)
648649
else:
649650
# make sure that the token data we're returning matches
650651
# the existing token
@@ -688,9 +689,17 @@ def _create_authorization_code(self, request, code, expires=None):
688689
claims=json.dumps(request.claims or {}),
689690
)
690691

691-
def _create_refresh_token(self, request, refresh_token_code, access_token):
692+
def _create_refresh_token(self, request, refresh_token_code, access_token, previous_refresh_token):
693+
if previous_refresh_token:
694+
token_family = previous_refresh_token.token_family
695+
else:
696+
token_family = uuid.uuid4()
692697
return RefreshToken.objects.create(
693-
user=request.user, token=refresh_token_code, application=request.client, access_token=access_token
698+
user=request.user,
699+
token=refresh_token_code,
700+
application=request.client,
701+
access_token=access_token,
702+
token_family=token_family,
694703
)
695704

696705
def revoke_token(self, token, token_type_hint, request, *args, **kwargs):
@@ -752,22 +761,25 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs
752761
Also attach User instance to the request object
753762
"""
754763

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-
)
764+
rt = RefreshToken.objects.filter(token=refresh_token).select_related("access_token").first()
763765

764766
if not rt:
765767
return False
766768

767-
request.user = rt.user
768769
request.refresh_token = rt.token
769770
# Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token.
770771
request.refresh_token_instance = rt
772+
773+
if rt.revoked is not None and rt.revoked <= timezone.now() - timedelta(
774+
seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS
775+
):
776+
if oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION:
777+
rt_token_family = RefreshToken.objects.filter(token_family=rt.token_family)
778+
for related_rt in rt_token_family.all():
779+
related_rt.revoke()
780+
return False
781+
782+
request.user = rt.user
771783
return rt.application == client
772784

773785
@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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 5.2 on 2024-08-09 16:40
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('tests', '0005_basetestapplication_allowed_origins_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='samplerefreshtoken',
15+
name='token_family',
16+
field=models.UUIDField(blank=True, editable=False, null=True),
17+
),
18+
migrations.AlterField(
19+
model_name='basetestapplication',
20+
name='allowed_origins',
21+
field=models.TextField(blank=True, default='', help_text='Allowed origins list to enable CORS, space separated'),
22+
),
23+
migrations.AlterField(
24+
model_name='basetestapplication',
25+
name='post_logout_redirect_uris',
26+
field=models.TextField(blank=True, default='', help_text='Allowed Post Logout URIs list, space separated'),
27+
),
28+
migrations.AlterField(
29+
model_name='sampleaccesstoken',
30+
name='token',
31+
field=models.CharField(db_index=True, max_length=255, unique=True),
32+
),
33+
migrations.AlterField(
34+
model_name='sampleapplication',
35+
name='allowed_origins',
36+
field=models.TextField(blank=True, default='', help_text='Allowed origins list to enable CORS, space separated'),
37+
),
38+
migrations.AlterField(
39+
model_name='sampleapplication',
40+
name='post_logout_redirect_uris',
41+
field=models.TextField(blank=True, default='', help_text='Allowed Post Logout URIs list, space separated'),
42+
),
43+
]

tests/test_authorization_code.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,7 @@ def test_refresh_repeating_requests_revokes_old_token(self):
10131013
"refresh_token": content["refresh_token"],
10141014
"scope": content["scope"],
10151015
}
1016+
# First response works as usual
10161017
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
10171018
self.assertEqual(response.status_code, 200)
10181019
new_tokens = json.loads(response.content.decode("utf-8"))
@@ -1021,7 +1022,7 @@ def test_refresh_repeating_requests_revokes_old_token(self):
10211022
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
10221023
self.assertEqual(response.status_code, 400)
10231024

1024-
# Previously returned tokens are now invalid
1025+
# Previously returned tokens are now invalid as well
10251026
new_token_request_data = {
10261027
"grant_type": "refresh_token",
10271028
"refresh_token": new_tokens["refresh_token"],

0 commit comments

Comments
 (0)