Skip to content

Commit 250120d

Browse files
pkarmanpre-commit-ci[bot]auvipyn2ygk
authored
Add ClientSecretField field to use Django password hashing algorithms (#1020)
* Add ClientSecretField field to leverage Django password hashing algo features * Document CLIENT_SECRET_HASHER setting * changelog, authors * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix python super() call * improve test coverage * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * improve test coverage * fix bad merge * comment per reviewer feedback * Update CHANGELOG to reference the docs and fix docs RST errors. Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Asif Saif Uddin <[email protected]> Co-authored-by: Alan Crosswell <[email protected]>
1 parent e657d7b commit 250120d

File tree

8 files changed

+76
-11
lines changed

8 files changed

+76
-11
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Jadiel Teófilo
6464
pySilver
6565
Łukasz Skarżyński
6666
Shaheed Haque
67+
Peter Karman
6768
Andrea Greco
6869
Vinay Karanam
6970
Eduardo Oliveira

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
### Added
1919
* #651 Batch expired token deletions in `cleartokens` management command
2020
* Added pt-BR translations.
21+
* #729 Add support for [hashed client_secret values](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#client-secret-hasher).
2122

2223
### Fixed
2324
* #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2).

docs/settings.rst

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ CLIENT_SECRET_GENERATOR_LENGTH
8888
The length of the generated secrets, in characters. If this value is too low,
8989
secrets may become subject to bruteforce guessing.
9090

91+
CLIENT_SECRET_HASHER
92+
~~~~~~~~~~~~~~~~~~~~
93+
If set to one of the Django password hasher algorithm names, client_secret values will be
94+
stored as `hashed Django passwords <https://docs.djangoproject.com/en/stable/topics/auth/passwords/#how-django-stores-passwords>`_.
95+
See the official list in the django.contrib.auth.hashers namespace.
96+
Default is none (stored as plain text).
97+
9198
EXTRA_SERVER_KWARGS
9299
~~~~~~~~~~~~~~~~~~~
93100
A dictionary to be passed to oauthlib's Server class. Three options
@@ -97,19 +104,19 @@ of those three can be a callable) must be passed here directly and classes
97104
must be instantiated (callables should accept request as their only argument).
98105

99106
GRANT_MODEL
100-
~~~~~~~~~~~~~~~~~
107+
~~~~~~~~~~~
101108
The import string of the class (model) representing your grants. Overwrite
102109
this value if you wrote your own implementation (subclass of
103110
``oauth2_provider.models.Grant``).
104111

105112
APPLICATION_ADMIN_CLASS
106-
~~~~~~~~~~~~~~~~~
113+
~~~~~~~~~~~~~~~~~~~~~~~
107114
The import string of the class (model) representing your application admin class.
108115
Overwrite this value if you wrote your own implementation (subclass of
109116
``oauth2_provider.admin.ApplicationAdmin``).
110117

111118
ACCESS_TOKEN_ADMIN_CLASS
112-
~~~~~~~~~~~~~~~~~
119+
~~~~~~~~~~~~~~~~~~~~~~~~
113120
The import string of the class (model) representing your access token admin class.
114121
Overwrite this value if you wrote your own implementation (subclass of
115122
``oauth2_provider.admin.AccessTokenAdmin``).
@@ -121,7 +128,7 @@ Overwrite this value if you wrote your own implementation (subclass of
121128
``oauth2_provider.admin.GrantAdmin``).
122129

123130
REFRESH_TOKEN_ADMIN_CLASS
124-
~~~~~~~~~~~~~~~~~
131+
~~~~~~~~~~~~~~~~~~~~~~~~~
125132
The import string of the class (model) representing your refresh token admin class.
126133
Overwrite this value if you wrote your own implementation (subclass of
127134
``oauth2_provider.admin.RefreshTokenAdmin``).
@@ -154,7 +161,7 @@ If you don't change the validator code and don't run cleartokens all refresh
154161
tokens will last until revoked or the end of time. You should change this.
155162

156163
REFRESH_TOKEN_GRACE_PERIOD_SECONDS
157-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
164+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
158165
The number of seconds between when a refresh token is first used when it is
159166
expired. The most common case of this for this is native mobile applications
160167
that run into issues of network connectivity during the refresh cycle and are
@@ -178,7 +185,7 @@ See also: validator's rotate_refresh_token method can be overridden to make this
178185
when close to expiration, theoretically).
179186

180187
REFRESH_TOKEN_GENERATOR
181-
~~~~~~~~~~~~~~~~~~~~~~~~~~
188+
~~~~~~~~~~~~~~~~~~~~~~~
182189
See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens.
183190
Defaults to access token generator if not provided.
184191

@@ -265,7 +272,7 @@ Default: ``""``
265272
The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled.
266273

267274
OIDC_RSA_PRIVATE_KEYS_INACTIVE
268-
~~~~~~~~~~~~~~~~~~~~
275+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
269276
Default: ``[]``
270277

271278
An array of *inactive* RSA private keys. These keys are not used to sign tokens,
@@ -276,7 +283,7 @@ This is useful for providing a smooth transition during key rotation.
276283
should be retained in this inactive list.
277284

278285
OIDC_JWKS_MAX_AGE_SECONDS
279-
~~~~~~~~~~~~~~~~~~~~~~
286+
~~~~~~~~~~~~~~~~~~~~~~~~~
280287
Default: ``3600``
281288

282289
The max-age value for the Cache-Control header on jwks_uri.
@@ -351,9 +358,9 @@ Time of sleep in seconds used by ``cleartokens`` management command between batc
351358

352359

353360
Settings imported from Django project
354-
--------------------------
361+
-------------------------------------
355362

356363
USE_TZ
357-
~~~~~~~~~~~~~~~~~~~~~~~~~~~
364+
~~~~~~
358365

359366
Used to determine whether or not to make token expire dates timezone aware.

oauth2_provider/models.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from django.apps import apps
88
from django.conf import settings
9+
from django.contrib.auth.hashers import make_password
910
from django.core.exceptions import ImproperlyConfigured
1011
from django.db import models, transaction
1112
from django.urls import reverse
@@ -24,6 +25,19 @@
2425
logger = logging.getLogger(__name__)
2526

2627

28+
class ClientSecretField(models.CharField):
29+
def pre_save(self, model_instance, add):
30+
if oauth2_settings.CLIENT_SECRET_HASHER:
31+
plain_secret = getattr(model_instance, self.attname)
32+
if "$" not in plain_secret: # not yet hashed
33+
hashed_secret = make_password(
34+
plain_secret, salt=model_instance.client_id, hasher=oauth2_settings.CLIENT_SECRET_HASHER
35+
)
36+
setattr(model_instance, self.attname, hashed_secret)
37+
return hashed_secret
38+
return super().pre_save(model_instance, add)
39+
40+
2741
class AbstractApplication(models.Model):
2842
"""
2943
An Application instance represents a Client on the Authorization server.
@@ -90,7 +104,7 @@ class AbstractApplication(models.Model):
90104
)
91105
client_type = models.CharField(max_length=32, choices=CLIENT_TYPES)
92106
authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES)
93-
client_secret = models.CharField(
107+
client_secret = ClientSecretField(
94108
max_length=255, blank=True, default=generate_client_secret, db_index=True
95109
)
96110
name = models.CharField(max_length=255, blank=True)

oauth2_provider/oauth2_validators.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import requests
1212
from django.conf import settings
1313
from django.contrib.auth import authenticate, get_user_model
14+
from django.contrib.auth.hashers import check_password
1415
from django.core.exceptions import ObjectDoesNotExist
1516
from django.db import transaction
1617
from django.db.models import Q
@@ -122,6 +123,17 @@ def _authenticate_basic_auth(self, request):
122123
elif request.client.client_id != client_id:
123124
log.debug("Failed basic auth: wrong client id %s" % client_id)
124125
return False
126+
# we use the "$" as a sentinel character to determine
127+
# whether a secret has been hashed like a Django password or not.
128+
# We can do this because the default oauthlib.common.UNICODE_ASCII_CHARACTER_SET
129+
# used by our default generator does not include the "$" character.
130+
# However, if a different character set was used to generate the secret, this sentinel
131+
# might be a false positive.
132+
elif "$" in request.client.client_secret and request.client.client_secret != client_secret:
133+
if not check_password(client_secret, request.client.client_secret):
134+
log.debug("Failed basic auth: wrong hashed client secret %s" % client_secret)
135+
return False
136+
return True
125137
elif request.client.client_secret != client_secret:
126138
log.debug("Failed basic auth: wrong client secret %s" % client_secret)
127139
return False

oauth2_provider/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator",
3838
"CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator",
3939
"CLIENT_SECRET_GENERATOR_LENGTH": 128,
40+
"CLIENT_SECRET_HASHER": None,
4041
"ACCESS_TOKEN_GENERATOR": None,
4142
"REFRESH_TOKEN_GENERATOR": None,
4243
"EXTRA_SERVER_KWARGS": {},

tests/presets.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@
4444
"READ_SCOPE": "read",
4545
"WRITE_SCOPE": "write",
4646
}
47+
48+
# default django auth hasher as of version 3.2
49+
CLIENT_SECRET_HASHER = {"CLIENT_SECRET_HASHER": "pbkdf2_sha256"}

tests/test_client_credential.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
AccessToken = get_access_token_model()
2525
UserModel = get_user_model()
2626

27+
CLIENT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890"
28+
2729

2830
# mocking a protected resource view
2931
class ResourceView(ProtectedResourceView):
@@ -44,6 +46,7 @@ def setUp(self):
4446
user=self.dev_user,
4547
client_type=Application.CLIENT_PUBLIC,
4648
authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS,
49+
client_secret=CLIENT_SECRET,
4750
)
4851

4952
def tearDown(self):
@@ -79,6 +82,29 @@ def test_client_credential_access_allowed(self):
7982
response = view(request)
8083
self.assertEqual(response, "This is a protected resource")
8184

85+
@pytest.mark.oauth2_settings(presets.CLIENT_SECRET_HASHER)
86+
def test_client_credential_with_hashed_client_secret(self):
87+
"""
88+
Verify client_secret is hashed before writing to the db,
89+
and comparison on request uses same hashing algo.
90+
"""
91+
self.assertNotEqual(self.application.client_secret, CLIENT_SECRET)
92+
self.assertIn("$", self.application.client_secret)
93+
self.assertIn(presets.CLIENT_SECRET_HASHER["CLIENT_SECRET_HASHER"], self.application.client_secret)
94+
95+
token_request_data = {
96+
"grant_type": "client_credentials",
97+
}
98+
auth_headers = get_basic_auth_header(self.application.client_id, CLIENT_SECRET)
99+
100+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
101+
self.assertEqual(response.status_code, 200)
102+
103+
# secret mismatch should return a 401
104+
auth_headers = get_basic_auth_header(self.application.client_id, "not-the-secret")
105+
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
106+
self.assertEqual(response.status_code, 401)
107+
82108
def test_client_credential_does_not_issue_refresh_token(self):
83109
token_request_data = {
84110
"grant_type": "client_credentials",

0 commit comments

Comments
 (0)