Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 @@ -60,3 +60,4 @@ Jadiel Teófilo
pySilver
Łukasz Skarżyński
Shaheed Haque
Peter Karman
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request`
to provide compatibility with backends that need one.
* #950 Add support for RSA key rotation.
* #729 Add support for hashed client_secret values.

### Fixed
* #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True.
Expand Down
6 changes: 6 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ CLIENT_SECRET_GENERATOR_LENGTH
The length of the generated secrets, in characters. If this value is too low,
secrets may become subject to bruteforce guessing.

CLIENT_SECRET_HASHER
~~~~~~~~~~~~~~~~~~~~
If set to one of the Django password hasher algorithm names, client_secret
values will be stored as hashed Django passwords. See the official list
in the django.contrib.auth.hashers namespace. Default is none (stored as plain text).

EXTRA_SERVER_KWARGS
~~~~~~~~~~~~~~~~~~~
A dictionary to be passed to oauthlib's Server class. Three options
Expand Down
16 changes: 15 additions & 1 deletion oauth2_provider/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from django.apps import apps
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core.exceptions import ImproperlyConfigured
from django.db import models, transaction
from django.urls import reverse
Expand All @@ -22,6 +23,19 @@
logger = logging.getLogger(__name__)


class ClientSecretField(models.CharField):
def pre_save(self, model_instance, add):
if oauth2_settings.CLIENT_SECRET_HASHER:
plain_secret = getattr(model_instance, self.attname)
if "$" not in plain_secret: # not yet hashed
hashed_secret = make_password(
plain_secret, salt=model_instance.client_id, hasher=oauth2_settings.CLIENT_SECRET_HASHER
)
setattr(model_instance, self.attname, hashed_secret)
return hashed_secret
return super().pre_save(model_instance, add)


class AbstractApplication(models.Model):
"""
An Application instance represents a Client on the Authorization server.
Expand Down Expand Up @@ -88,7 +102,7 @@ class AbstractApplication(models.Model):
)
client_type = models.CharField(max_length=32, choices=CLIENT_TYPES)
authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES)
client_secret = models.CharField(
client_secret = ClientSecretField(
max_length=255, blank=True, default=generate_client_secret, db_index=True
)
name = models.CharField(max_length=255, blank=True)
Expand Down
6 changes: 6 additions & 0 deletions oauth2_provider/oauth2_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import requests
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Q
Expand Down Expand Up @@ -122,6 +123,11 @@ def _authenticate_basic_auth(self, request):
elif request.client.client_id != client_id:
log.debug("Failed basic auth: wrong client id %s" % client_id)
return False
elif "$" in request.client.client_secret:
if not check_password(client_secret, request.client.client_secret):
log.debug("Failed basic auth: wrong hashed client secret %s" % client_secret)
return False
return True
elif request.client.client_secret != client_secret:
log.debug("Failed basic auth: wrong client secret %s" % client_secret)
return False
Expand Down
1 change: 1 addition & 0 deletions oauth2_provider/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator",
"CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator",
"CLIENT_SECRET_GENERATOR_LENGTH": 128,
"CLIENT_SECRET_HASHER": None,
"ACCESS_TOKEN_GENERATOR": None,
"REFRESH_TOKEN_GENERATOR": None,
"EXTRA_SERVER_KWARGS": {},
Expand Down
3 changes: 3 additions & 0 deletions tests/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@
"READ_SCOPE": "read",
"WRITE_SCOPE": "write",
}

# default django auth hasher as of version 3.2
CLIENT_SECRET_HASHER = {"CLIENT_SECRET_HASHER": "pbkdf2_sha256"}
26 changes: 26 additions & 0 deletions tests/test_client_credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
AccessToken = get_access_token_model()
UserModel = get_user_model()

CLIENT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890"


# mocking a protected resource view
class ResourceView(ProtectedResourceView):
Expand All @@ -44,6 +46,7 @@ def setUp(self):
user=self.dev_user,
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS,
client_secret=CLIENT_SECRET,
)

def tearDown(self):
Expand Down Expand Up @@ -79,6 +82,29 @@ def test_client_credential_access_allowed(self):
response = view(request)
self.assertEqual(response, "This is a protected resource")

@pytest.mark.oauth2_settings(presets.CLIENT_SECRET_HASHER)
def test_client_credential_with_hashed_client_secret(self):
"""
Verify client_secret is hashed before writing to the db,
and comparison on request uses same hashing algo.
"""
self.assertNotEqual(self.application.client_secret, CLIENT_SECRET)
self.assertIn("$", self.application.client_secret)
self.assertIn(presets.CLIENT_SECRET_HASHER["CLIENT_SECRET_HASHER"], self.application.client_secret)

token_request_data = {
"grant_type": "client_credentials",
}
auth_headers = get_basic_auth_header(self.application.client_id, CLIENT_SECRET)

response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
self.assertEqual(response.status_code, 200)

# secret mismatch should return a 401
auth_headers = get_basic_auth_header(self.application.client_id, "not-the-secret")
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
self.assertEqual(response.status_code, 401)

def test_client_credential_does_not_issue_refresh_token(self):
token_request_data = {
"grant_type": "client_credentials",
Expand Down