Skip to content

Commit 691870c

Browse files
n2ygkAndrew-Chen-Wangpkarman
authored
Hash application client secrets using Django password hashing (#1093)
* Add ClientSecretField field to use Django password hashing algorithms (#1020) Co-authored-by: Andrew Chen Wang <[email protected]> Co-authored-by: Peter Karman <[email protected]> Co-authored-by: Andrew Chen Wang <[email protected]>
1 parent a6bd0d0 commit 691870c

16 files changed

+185
-109
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
Vinay Karanam
6869
Eduardo Oliveira
6970
Andrea Greco

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### Security
1515
-->
1616

17+
## [unreleased]
18+
19+
## [2.0.0] unreleased
20+
21+
### Changed
22+
* #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/)
23+
client_secret values. This is a **breaking change** that will migrate all your existing
24+
cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm
25+
and can not be reversed. When adding or modifying an Application in the Admin console, you must copy the
26+
auto-generated or manually-entered `client_secret` before hitting Save.
27+
28+
1729
## [1.7.0] 2022-01-23
1830

1931
### Added

docs/settings.rst

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,19 +97,19 @@ of those three can be a callable) must be passed here directly and classes
9797
must be instantiated (callables should accept request as their only argument).
9898

9999
GRANT_MODEL
100-
~~~~~~~~~~~~~~~~~
100+
~~~~~~~~~~~
101101
The import string of the class (model) representing your grants. Overwrite
102102
this value if you wrote your own implementation (subclass of
103103
``oauth2_provider.models.Grant``).
104104

105105
APPLICATION_ADMIN_CLASS
106-
~~~~~~~~~~~~~~~~~
106+
~~~~~~~~~~~~~~~~~~~~~~~
107107
The import string of the class (model) representing your application admin class.
108108
Overwrite this value if you wrote your own implementation (subclass of
109109
``oauth2_provider.admin.ApplicationAdmin``).
110110

111111
ACCESS_TOKEN_ADMIN_CLASS
112-
~~~~~~~~~~~~~~~~~
112+
~~~~~~~~~~~~~~~~~~~~~~~~
113113
The import string of the class (model) representing your access token admin class.
114114
Overwrite this value if you wrote your own implementation (subclass of
115115
``oauth2_provider.admin.AccessTokenAdmin``).
@@ -121,7 +121,7 @@ Overwrite this value if you wrote your own implementation (subclass of
121121
``oauth2_provider.admin.GrantAdmin``).
122122

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

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

180180
REFRESH_TOKEN_GENERATOR
181-
~~~~~~~~~~~~~~~~~~~~~~~~~~
181+
~~~~~~~~~~~~~~~~~~~~~~~
182182
See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens.
183183
Defaults to access token generator if not provided.
184184

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

267267
OIDC_RSA_PRIVATE_KEYS_INACTIVE
268-
~~~~~~~~~~~~~~~~~~~~
268+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
269269
Default: ``[]``
270270

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

278278
OIDC_JWKS_MAX_AGE_SECONDS
279-
~~~~~~~~~~~~~~~~~~~~~~
279+
~~~~~~~~~~~~~~~~~~~~~~~~~
280280
Default: ``3600``
281281

282282
The max-age value for the Cache-Control header on jwks_uri.
@@ -354,9 +354,9 @@ load when clearing large batches of expired tokens.
354354

355355

356356
Settings imported from Django project
357-
--------------------------
357+
-------------------------------------
358358

359359
USE_TZ
360-
~~~~~~~~~~~~~~~~~~~~~~~~~~~
360+
~~~~~~
361361

362362
Used to determine whether or not to make token expire dates timezone aware.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from django.db import migrations
2+
from django.contrib.auth.hashers import identify_hasher, make_password
3+
import logging
4+
import oauth2_provider.generators
5+
import oauth2_provider.models
6+
7+
8+
def forwards_func(apps, schema_editor):
9+
"""
10+
Forward migration touches every application.client_secret which will cause it to be hashed if not already the case.
11+
"""
12+
Application = apps.get_model('oauth2_provider', 'application')
13+
applications = Application.objects.all()
14+
for application in applications:
15+
application.save(update_fields=['client_secret'])
16+
17+
18+
class Migration(migrations.Migration):
19+
20+
dependencies = [
21+
('oauth2_provider', '0005_auto_20211222_2352'),
22+
]
23+
24+
operations = [
25+
migrations.AlterField(
26+
model_name='application',
27+
name='client_secret',
28+
field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255),
29+
),
30+
migrations.RunPython(forwards_func),
31+
]

oauth2_provider/models.py

Lines changed: 21 additions & 2 deletions
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 identify_hasher, 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,20 @@
2425
logger = logging.getLogger(__name__)
2526

2627

28+
class ClientSecretField(models.CharField):
29+
def pre_save(self, model_instance, add):
30+
secret = getattr(model_instance, self.attname)
31+
try:
32+
hasher = identify_hasher(secret)
33+
logger.debug(f"{model_instance}: {self.attname} is already hashed with {hasher}.")
34+
except ValueError:
35+
logger.debug(f"{model_instance}: {self.attname} is not hashed; hashing it now.")
36+
hashed_secret = make_password(secret)
37+
setattr(model_instance, self.attname, hashed_secret)
38+
return hashed_secret
39+
return super().pre_save(model_instance, add)
40+
41+
2742
class AbstractApplication(models.Model):
2843
"""
2944
An Application instance represents a Client on the Authorization server.
@@ -90,8 +105,12 @@ class AbstractApplication(models.Model):
90105
)
91106
client_type = models.CharField(max_length=32, choices=CLIENT_TYPES)
92107
authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES)
93-
client_secret = models.CharField(
94-
max_length=255, blank=True, default=generate_client_secret, db_index=True
108+
client_secret = ClientSecretField(
109+
max_length=255,
110+
blank=True,
111+
default=generate_client_secret,
112+
db_index=True,
113+
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
95114
)
96115
name = models.CharField(max_length=255, blank=True)
97116
skip_authorization = models.BooleanField(default=False)

oauth2_provider/oauth2_validators.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import requests
1313
from django.conf import settings
1414
from django.contrib.auth import authenticate, get_user_model
15+
from django.contrib.auth.hashers import check_password
1516
from django.core.exceptions import ObjectDoesNotExist
1617
from django.db import transaction
1718
from django.db.models import Q
@@ -123,7 +124,7 @@ def _authenticate_basic_auth(self, request):
123124
elif request.client.client_id != client_id:
124125
log.debug("Failed basic auth: wrong client id %s" % client_id)
125126
return False
126-
elif request.client.client_secret != client_secret:
127+
elif not check_password(client_secret, request.client.client_secret):
127128
log.debug("Failed basic auth: wrong client secret %s" % client_secret)
128129
return False
129130
else:
@@ -148,7 +149,7 @@ def _authenticate_request_body(self, request):
148149
if self._load_application(client_id, request) is None:
149150
log.debug("Failed body auth: Application %s does not exists" % client_id)
150151
return False
151-
elif request.client.client_secret != client_secret:
152+
elif not check_password(client_secret, request.client.client_secret):
152153
log.debug("Failed body auth: wrong client secret %s" % client_secret)
153154
return False
154155
else:

tests/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
Application = get_application_model()
1717
UserModel = get_user_model()
1818

19+
CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz"
20+
1921

2022
class OAuthSettingsWrapper:
2123
"""
@@ -101,12 +103,14 @@ def application():
101103
client_type=Application.CLIENT_CONFIDENTIAL,
102104
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
103105
algorithm=Application.RS256_ALGORITHM,
106+
client_secret=CLEARTEXT_SECRET,
104107
)
105108

106109

107110
@pytest.fixture
108111
def hybrid_application(application):
109112
application.authorization_grant_type = application.GRANT_OPENID_HYBRID
113+
application.client_secret = CLEARTEXT_SECRET
110114
application.save()
111115
return application
112116

@@ -141,7 +145,7 @@ def oidc_tokens(oauth2_settings, application, test_user, client):
141145
"code": code,
142146
"redirect_uri": "http://example.org",
143147
"client_id": application.client_id,
144-
"client_secret": application.client_secret,
148+
"client_secret": CLEARTEXT_SECRET,
145149
"scope": "openid",
146150
},
147151
)

0 commit comments

Comments
 (0)