Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refresh Token Reuse Protection #1452

Merged
merged 4 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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
### Deprecated
### Removed
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'),
n2ygk marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -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)
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 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 @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
n2ygk marked this conversation as resolved.
Show resolved Hide resolved

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
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,
n2ygk marked this conversation as resolved.
Show resolved Hide resolved
"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
n2ygk marked this conversation as resolved.
Show resolved Hide resolved
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'),
n2ygk marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading