Skip to content

Commit

Permalink
Refresh Token Reuse Protection (jazzband#1452)
Browse files Browse the repository at this point in the history
* Implement REFRESH_TOKEN_REUSE_PROTECTION (jazzband#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.
  • Loading branch information
soerface authored and shaleh committed Aug 13, 2024
1 parent f00c393 commit 12ca726
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 12 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Shaheed Haque
Shaun Stanworth
Silvano Cerza
Sora Yanai
Sören Wegener
Spencer Carroll
Stéphane Raimbault
Tom Evans
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
* Transactions wrapping writes of the Tokens now rely on Django's database routers to determine the correct
database to use instead of assuming that 'default' is the correct one.
Expand Down
12 changes: 12 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions oauth2_provider/migrations/0011_refreshtoken_token_family.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions oauth2_provider/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,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)
Expand Down
36 changes: 24 additions & 12 deletions oauth2_provider/oauth2_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from django.contrib.auth.hashers import check_password, identify_hasher
from django.core.exceptions import ObjectDoesNotExist
from django.db import router, 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
Expand Down Expand Up @@ -656,7 +655,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
Expand Down Expand Up @@ -700,9 +701,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):
Expand Down Expand Up @@ -764,22 +773,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

def _save_id_token(self, jti, request, expires, *args, **kwargs):
Expand Down
1 change: 1 addition & 0 deletions oauth2_provider/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions tests/migrations/0006_basetestapplication_token_family.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
105 changes: 105 additions & 0 deletions tests/test_authorization_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,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
Expand Down Expand Up @@ -1025,6 +1073,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.
Expand Down

0 comments on commit 12ca726

Please sign in to comment.