Repository: jazzband/django-oauth-toolkit (language: python)

Issue:
Refresh token reuse detection
**Is your feature request related to a problem? Please describe.**
I want to increase security by detecting reuse of old refresh tokens. Scenario:

- A malicious actor steals the refresh token of a user
- The malicious actor uses the refresh token to get a new access & refresh token
- The user tries to use the refresh token to get a new access & refresh token
- The user's request is denied because they used an outdated refresh token
- **The malicious actor is still logged in**

**Describe the solution you'd like**
In case an old refresh token is used (taking `REFRESH_TOKEN_GRACE_PERIOD_SECONDS` into consideration), not only should it be rejected, but also the whole token family should be invalidated.

The implementation should be according to the RFC: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-25#section-4.14.2-5.2.2
> **Refresh token rotation:** the authorization server issues a new refresh token with every access token refresh response. The previous refresh token is invalidated but information about the relationship is retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach. The authorization server cannot determine which party submitted the invalid refresh token, but it will revoke the active refresh token. This stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant.
>
> Implementation note: The grant to which a refresh token belongs may be encoded into the refresh token itself. This can enable an authorization server to efficiently determine the grant to which a refresh token belongs, and by extension, all refresh tokens that need to be revoked. Authorization servers MUST ensure the integrity of the refresh token value in this case, for example, using signatures.



**Additional context**
- Description and diagram from auth0 explains the rational pretty good: https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation#automatic-reuse-detection


Refresh token reuse detection
**Is your feature request related to a problem? Please describe.**
I want to increase security by detecting reuse of old refresh tokens. Scenario:

- A malicious actor steals the refresh token of a user
- The malicious actor uses the refresh token to get a new access & refresh token
- The user tries to use the refresh token to get a new access & refresh token
- The user's request is denied because they used an outdated refresh token
- **The malicious actor is still logged in**

**Describe the solution you'd like**
In case an old refresh token is used (taking `REFRESH_TOKEN_GRACE_PERIOD_SECONDS` into consideration), not only should it be rejected, but also the whole token family should be invalidated.

The implementation should be according to the RFC: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-25#section-4.14.2-5.2.2
> **Refresh token rotation:** the authorization server issues a new refresh token with every access token refresh response. The previous refresh token is invalidated but information about the relationship is retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach. The authorization server cannot determine which party submitted the invalid refresh token, but it will revoke the active refresh token. This stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant.
>
> Implementation note: The grant to which a refresh token belongs may be encoded into the refresh token itself. This can enable an authorization server to efficiently determine the grant to which a refresh token belongs, and by extension, all refresh tokens that need to be revoked. Authorization servers MUST ensure the integrity of the refresh token value in this case, for example, using signatures.



**Additional context**
- Description and diagram from auth0 explains the rational pretty good: https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation#automatic-reuse-detection




Discussion / hints:
This sounds great and a PR would be greatly appreciated.
This sounds great and a PR would be greatly appreciated.

Fix patch:
diff --git a/AUTHORS b/AUTHORS
index 17447b108..ce5ec2ec8 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -105,6 +105,7 @@ Shaheed Haque
 Shaun Stanworth
 Silvano Cerza
 Sora Yanai
+Sören Wegener
 Spencer Carroll
 Stéphane Raimbault
 Tom Evans
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 826ae43bc..ed1ec2e89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [unreleased]
 ### Added
+* #1404 Add a new setting `REFRESH_TOKEN_REUSE_PROTECTION`
 ### Changed
 ### Deprecated
 ### Removed
diff --git a/docs/settings.rst b/docs/settings.rst
index 901fe8575..4ebe6cc47 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -185,6 +185,18 @@ The import string of the class (model) representing your refresh tokens. Overwri
 this value if you wrote your own implementation (subclass of
 ``oauth2_provider.models.RefreshToken``).
 
+REFRESH_TOKEN_REUSE_PROTECTION
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+When this is set to ``True`` (default ``False``), and ``ROTATE_REFRESH_TOKEN`` is used, the server will check
+if a previously, already revoked refresh token is used a second time. If it detects a reuse, it will automatically
+revoke all related refresh tokens.
+A reused refresh token indicates a breach. Since the server can't determine which request came from the legitimate
+user and which from an attacker, it will end the session for both. The user is required to perform a new login.
+
+Can be used in combination with ``REFRESH_TOKEN_GRACE_PERIOD_SECONDS``
+
+More details at https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations
+
 ROTATE_REFRESH_TOKEN
 ~~~~~~~~~~~~~~~~~~~~
 When is set to ``True`` (default) a new refresh token is issued to the client when the client refreshes an access token.
diff --git a/oauth2_provider/migrations/0011_refreshtoken_token_family.py b/oauth2_provider/migrations/0011_refreshtoken_token_family.py
new file mode 100644
index 000000000..94fb4e171
--- /dev/null
+++ b/oauth2_provider/migrations/0011_refreshtoken_token_family.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2 on 2024-08-09 16:40
+
+from django.db import migrations, models
+from oauth2_provider.settings import oauth2_settings
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('oauth2_provider', '0010_application_allowed_origins'),
+        migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL)
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='refreshtoken',
+            name='token_family',
+            field=models.UUIDField(blank=True, editable=False, null=True),
+        ),
+    ]
diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py
index 661bd7dfc..9895528de 100644
--- a/oauth2_provider/models.py
+++ b/oauth2_provider/models.py
@@ -490,6 +490,7 @@ class AbstractRefreshToken(models.Model):
         null=True,
         related_name="refresh_token",
     )
+    token_family = models.UUIDField(null=True, blank=True, editable=False)
 
     created = models.DateTimeField(auto_now_add=True)
     updated = models.DateTimeField(auto_now=True)
diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py
index 47d65e851..d1cb8b9b6 100644
--- a/oauth2_provider/oauth2_validators.py
+++ b/oauth2_provider/oauth2_validators.py
@@ -15,7 +15,6 @@
 from django.contrib.auth.hashers import check_password, identify_hasher
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
-from django.db.models import Q
 from django.http import HttpRequest
 from django.utils import dateformat, timezone
 from django.utils.crypto import constant_time_compare
@@ -644,7 +643,9 @@ def save_bearer_token(self, token, request, *args, **kwargs):
                         source_refresh_token=refresh_token_instance,
                     )
 
-                    self._create_refresh_token(request, refresh_token_code, access_token)
+                    self._create_refresh_token(
+                        request, refresh_token_code, access_token, refresh_token_instance
+                    )
                 else:
                     # make sure that the token data we're returning matches
                     # the existing token
@@ -688,9 +689,17 @@ def _create_authorization_code(self, request, code, expires=None):
             claims=json.dumps(request.claims or {}),
         )
 
-    def _create_refresh_token(self, request, refresh_token_code, access_token):
+    def _create_refresh_token(self, request, refresh_token_code, access_token, previous_refresh_token):
+        if previous_refresh_token:
+            token_family = previous_refresh_token.token_family
+        else:
+            token_family = uuid.uuid4()
         return RefreshToken.objects.create(
-            user=request.user, token=refresh_token_code, application=request.client, access_token=access_token
+            user=request.user,
+            token=refresh_token_code,
+            application=request.client,
+            access_token=access_token,
+            token_family=token_family,
         )
 
     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
         Also attach User instance to the request object
         """
 
-        null_or_recent = Q(revoked__isnull=True) | Q(
-            revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS)
-        )
-        rt = (
-            RefreshToken.objects.filter(null_or_recent, token=refresh_token)
-            .select_related("access_token")
-            .first()
-        )
+        rt = RefreshToken.objects.filter(token=refresh_token).select_related("access_token").first()
 
         if not rt:
             return False
 
+        if rt.revoked is not None and rt.revoked <= timezone.now() - timedelta(
+            seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS
+        ):
+            if oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION and rt.token_family:
+                rt_token_family = RefreshToken.objects.filter(token_family=rt.token_family)
+                for related_rt in rt_token_family.all():
+                    related_rt.revoke()
+            return False
+
         request.user = rt.user
         request.refresh_token = rt.token
         # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token.
         request.refresh_token_instance = rt
+
         return rt.application == client
 
     @transaction.atomic
diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py
index 950ab5643..329a1b354 100644
--- a/oauth2_provider/settings.py
+++ b/oauth2_provider/settings.py
@@ -54,6 +54,7 @@
     "ID_TOKEN_EXPIRE_SECONDS": 36000,
     "REFRESH_TOKEN_EXPIRE_SECONDS": None,
     "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0,
+    "REFRESH_TOKEN_REUSE_PROTECTION": False,
     "ROTATE_REFRESH_TOKEN": True,
     "ERROR_RESPONSE_WITH_SCOPES": False,
     "APPLICATION_MODEL": APPLICATION_MODEL,


Test patch:
diff --git a/tests/migrations/0006_basetestapplication_token_family.py b/tests/migrations/0006_basetestapplication_token_family.py
new file mode 100644
index 000000000..6b065a242
--- /dev/null
+++ b/tests/migrations/0006_basetestapplication_token_family.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.2 on 2024-08-09 16:40
+
+from django.db import migrations, models
+from oauth2_provider.settings import oauth2_settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tests', '0005_basetestapplication_allowed_origins_and_more'),
+        migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL)
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='samplerefreshtoken',
+            name='token_family',
+            field=models.UUIDField(blank=True, editable=False, null=True),
+        ),
+    ]
diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py
index b77f4f9ba..ae6e7e76e 100644
--- a/tests/test_authorization_code.py
+++ b/tests/test_authorization_code.py
@@ -985,6 +985,54 @@ def test_refresh_fail_repeating_requests(self):
         response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
         self.assertEqual(response.status_code, 400)
 
+    def test_refresh_repeating_requests_revokes_old_token(self):
+        """
+        If a refresh token is reused, the server should invalidate *all* access tokens that have a relation
+        to the re-used token. This forces a malicious actor to be logged out.
+        The server can't determine whether the first or the second client was legitimate, so it needs to
+        revoke both.
+        See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations
+        """
+        self.oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION = True
+        self.client.login(username="test_user", password="123456")
+        authorization_code = self.get_auth()
+
+        token_request_data = {
+            "grant_type": "authorization_code",
+            "code": authorization_code,
+            "redirect_uri": "http://example.org",
+        }
+        auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
+
+        response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
+        content = json.loads(response.content.decode("utf-8"))
+        self.assertTrue("refresh_token" in content)
+
+        token_request_data = {
+            "grant_type": "refresh_token",
+            "refresh_token": content["refresh_token"],
+            "scope": content["scope"],
+        }
+        # First response works as usual
+        response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
+        self.assertEqual(response.status_code, 200)
+        new_tokens = json.loads(response.content.decode("utf-8"))
+
+        # Second request fails
+        response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
+        self.assertEqual(response.status_code, 400)
+
+        # Previously returned tokens are now invalid as well
+        new_token_request_data = {
+            "grant_type": "refresh_token",
+            "refresh_token": new_tokens["refresh_token"],
+            "scope": new_tokens["scope"],
+        }
+        response = self.client.post(
+            reverse("oauth2_provider:token"), data=new_token_request_data, **auth_headers
+        )
+        self.assertEqual(response.status_code, 400)
+
     def test_refresh_repeating_requests(self):
         """
         Trying to refresh an access token with the same refresh token more than
@@ -1024,6 +1072,63 @@ def test_refresh_repeating_requests(self):
         response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
         self.assertEqual(response.status_code, 400)
 
+    def test_refresh_repeating_requests_grace_period_with_reuse_protection(self):
+        """
+        Trying to refresh an access token with the same refresh token more than
+        once succeeds. Should work within the grace period, but should revoke previous tokens
+        """
+        self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120
+        self.oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION = True
+        self.client.login(username="test_user", password="123456")
+        authorization_code = self.get_auth()
+
+        token_request_data = {
+            "grant_type": "authorization_code",
+            "code": authorization_code,
+            "redirect_uri": "http://example.org",
+        }
+        auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
+
+        response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
+        content = json.loads(response.content.decode("utf-8"))
+        self.assertTrue("refresh_token" in content)
+
+        refresh_token_1 = content["refresh_token"]
+        token_request_data = {
+            "grant_type": "refresh_token",
+            "refresh_token": refresh_token_1,
+            "scope": content["scope"],
+        }
+        response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
+        self.assertEqual(response.status_code, 200)
+        refresh_token_2 = json.loads(response.content.decode("utf-8"))["refresh_token"]
+
+        response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
+        self.assertEqual(response.status_code, 200)
+        refresh_token_3 = json.loads(response.content.decode("utf-8"))["refresh_token"]
+
+        self.assertEqual(refresh_token_2, refresh_token_3)
+
+        # Let the first refresh token expire
+        rt = RefreshToken.objects.get(token=refresh_token_1)
+        rt.revoked = timezone.now() - datetime.timedelta(minutes=10)
+        rt.save()
+
+        # Using the expired token fails
+        response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
+        self.assertEqual(response.status_code, 400)
+
+        # Because we used the expired token, the recently issued token is also revoked
+        new_token_request_data = {
+            "grant_type": "refresh_token",
+            "refresh_token": refresh_token_2,
+            "scope": content["scope"],
+        }
+        response = self.client.post(
+            reverse("oauth2_provider:token"), data=new_token_request_data, **auth_headers
+        )
+        self.assertEqual(response.status_code, 400)
+
     def test_refresh_repeating_requests_non_rotating_tokens(self):
         """
         Try refreshing an access token with the same refresh token more than once when not rotating tokens.