Skip to content

Commit b5f6674

Browse files
Always hash application.client_secret.
Apply suggestions from code review Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com>
1 parent 36f767c commit b5f6674

16 files changed

+141
-147
lines changed

CHANGELOG.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
## [unreleased]
1818

19-
### Added
20-
* #729 Add support for [hashed client_secret values](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#client-secret-hasher).
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+
2128

2229
## [1.7.0] 2022-01-23
2330

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
1-
# Generated by Django 4.0.1 on 2022-01-07 22:40
2-
31
from django.db import migrations
2+
from django.contrib.auth.hashers import identify_hasher, make_password
3+
import logging
44
import oauth2_provider.generators
55
import oauth2_provider.models
66

77

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+
818
class Migration(migrations.Migration):
919

1020
dependencies = [
@@ -15,6 +25,7 @@ class Migration(migrations.Migration):
1525
migrations.AlterField(
1626
model_name='application',
1727
name='client_secret',
18-
field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255),
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),
1929
),
30+
migrations.RunPython(forwards_func),
2031
]

oauth2_provider/models.py

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

77
from django.apps import apps
88
from django.conf import settings
9-
from django.contrib.auth.hashers import make_password
9+
from django.contrib.auth.hashers import identify_hasher, make_password
1010
from django.core.exceptions import ImproperlyConfigured
1111
from django.db import models, transaction
1212
from django.urls import reverse
@@ -27,14 +27,15 @@
2727

2828
class ClientSecretField(models.CharField):
2929
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
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
3839
return super().pre_save(model_instance, add)
3940

4041

@@ -105,7 +106,11 @@ class AbstractApplication(models.Model):
105106
client_type = models.CharField(max_length=32, choices=CLIENT_TYPES)
106107
authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES)
107108
client_secret = ClientSecretField(
108-
max_length=255, blank=True, default=generate_client_secret, db_index=True
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."),
109114
)
110115
name = models.CharField(max_length=255, blank=True)
111116
skip_authorization = models.BooleanField(default=False)

oauth2_provider/oauth2_validators.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,7 @@ def _authenticate_basic_auth(self, request):
124124
elif request.client.client_id != client_id:
125125
log.debug("Failed basic auth: wrong client id %s" % client_id)
126126
return False
127-
# we use the "$" as a sentinel character to determine
128-
# whether a secret has been hashed like a Django password or not.
129-
# We can do this because the default oauthlib.common.UNICODE_ASCII_CHARACTER_SET
130-
# used by our default generator does not include the "$" character.
131-
# However, if a different character set was used to generate the secret, this sentinel
132-
# might be a false positive.
133-
elif "$" in request.client.client_secret and request.client.client_secret != client_secret:
134-
if not check_password(client_secret, request.client.client_secret):
135-
log.debug("Failed basic auth: wrong hashed client secret %s" % client_secret)
136-
return False
137-
return True
138-
elif request.client.client_secret != client_secret:
127+
elif not check_password(client_secret, request.client.client_secret):
139128
log.debug("Failed basic auth: wrong client secret %s" % client_secret)
140129
return False
141130
else:
@@ -160,7 +149,7 @@ def _authenticate_request_body(self, request):
160149
if self._load_application(client_id, request) is None:
161150
log.debug("Failed body auth: Application %s does not exists" % client_id)
162151
return False
163-
elif request.client.client_secret != client_secret:
152+
elif not check_password(client_secret, request.client.client_secret):
164153
log.debug("Failed body auth: wrong client secret %s" % client_secret)
165154
return False
166155
else:

oauth2_provider/settings.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
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,
4140
"ACCESS_TOKEN_GENERATOR": None,
4241
"REFRESH_TOKEN_GENERATOR": None,
4342
"EXTRA_SERVER_KWARGS": {},

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
)

tests/presets.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,3 @@
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_authorization_code.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
URI_OOB = "urn:ietf:wg:oauth:2.0:oob"
3636
URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto"
37+
CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz"
3738

3839

3940
# mocking a protected resource view
@@ -60,6 +61,7 @@ def setUp(self):
6061
user=self.dev_user,
6162
client_type=Application.CLIENT_CONFIDENTIAL,
6263
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
64+
client_secret=CLEARTEXT_SECRET,
6365
)
6466

6567
def tearDown(self):
@@ -677,7 +679,7 @@ def test_basic_auth(self):
677679
"code": authorization_code,
678680
"redirect_uri": "http://example.org",
679681
}
680-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
682+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
681683

682684
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
683685
self.assertEqual(response.status_code, 200)
@@ -699,7 +701,7 @@ def test_refresh(self):
699701
"code": authorization_code,
700702
"redirect_uri": "http://example.org",
701703
}
702-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
704+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
703705

704706
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
705707
content = json.loads(response.content.decode("utf-8"))
@@ -744,7 +746,7 @@ def test_refresh_with_grace_period(self):
744746
"code": authorization_code,
745747
"redirect_uri": "http://example.org",
746748
}
747-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
749+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
748750

749751
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
750752
content = json.loads(response.content.decode("utf-8"))
@@ -795,7 +797,7 @@ def test_refresh_invalidates_old_tokens(self):
795797
"code": authorization_code,
796798
"redirect_uri": "http://example.org",
797799
}
798-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
800+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
799801

800802
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
801803
content = json.loads(response.content.decode("utf-8"))
@@ -827,7 +829,7 @@ def test_refresh_no_scopes(self):
827829
"code": authorization_code,
828830
"redirect_uri": "http://example.org",
829831
}
830-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
832+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
831833

832834
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
833835
content = json.loads(response.content.decode("utf-8"))
@@ -855,7 +857,7 @@ def test_refresh_bad_scopes(self):
855857
"code": authorization_code,
856858
"redirect_uri": "http://example.org",
857859
}
858-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
860+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
859861

860862
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
861863
content = json.loads(response.content.decode("utf-8"))
@@ -881,7 +883,7 @@ def test_refresh_fail_repeating_requests(self):
881883
"code": authorization_code,
882884
"redirect_uri": "http://example.org",
883885
}
884-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
886+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
885887

886888
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
887889
content = json.loads(response.content.decode("utf-8"))
@@ -911,7 +913,7 @@ def test_refresh_repeating_requests(self):
911913
"code": authorization_code,
912914
"redirect_uri": "http://example.org",
913915
}
914-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
916+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
915917

916918
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
917919
content = json.loads(response.content.decode("utf-8"))
@@ -948,7 +950,7 @@ def test_refresh_repeating_requests_non_rotating_tokens(self):
948950
"code": authorization_code,
949951
"redirect_uri": "http://example.org",
950952
}
951-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
953+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
952954

953955
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
954956
content = json.loads(response.content.decode("utf-8"))
@@ -977,7 +979,7 @@ def test_basic_auth_bad_authcode(self):
977979
"code": "BLAH",
978980
"redirect_uri": "http://example.org",
979981
}
980-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
982+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
981983

982984
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
983985
self.assertEqual(response.status_code, 400)
@@ -989,7 +991,7 @@ def test_basic_auth_bad_granttype(self):
989991
self.client.login(username="test_user", password="123456")
990992

991993
token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"}
992-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
994+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
993995

994996
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
995997
self.assertEqual(response.status_code, 400)
@@ -1014,7 +1016,7 @@ def test_basic_auth_grant_expired(self):
10141016
"code": "BLAH",
10151017
"redirect_uri": "http://example.org",
10161018
}
1017-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
1019+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
10181020

10191021
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
10201022
self.assertEqual(response.status_code, 400)
@@ -1049,7 +1051,7 @@ def test_basic_auth_wrong_auth_type(self):
10491051
"redirect_uri": "http://example.org",
10501052
}
10511053

1052-
user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret)
1054+
user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET)
10531055
auth_string = base64.b64encode(user_pass.encode("utf-8"))
10541056
auth_headers = {
10551057
"HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"),
@@ -1070,7 +1072,7 @@ def test_request_body_params(self):
10701072
"code": authorization_code,
10711073
"redirect_uri": "http://example.org",
10721074
"client_id": self.application.client_id,
1073-
"client_secret": self.application.client_secret,
1075+
"client_secret": CLEARTEXT_SECRET,
10741076
}
10751077

10761078
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data)
@@ -1445,7 +1447,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self):
14451447
"code": authorization_code,
14461448
"redirect_uri": "http://example.org?foo=bar",
14471449
}
1448-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
1450+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
14491451

14501452
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
14511453
self.assertEqual(response.status_code, 200)
@@ -1480,7 +1482,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self):
14801482
"code": authorization_code,
14811483
"redirect_uri": "http://example.org?foo=baraa",
14821484
}
1483-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
1485+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
14841486

14851487
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
14861488
self.assertEqual(response.status_code, 400)
@@ -1520,7 +1522,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param
15201522
"code": authorization_code,
15211523
"redirect_uri": "http://example.com?bar=baz&foo=bar",
15221524
}
1523-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
1525+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
15241526

15251527
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
15261528
self.assertEqual(response.status_code, 200)
@@ -1565,7 +1567,7 @@ def test_oob_as_html(self):
15651567
"code": authorization_code,
15661568
"redirect_uri": URI_OOB,
15671569
"client_id": self.application.client_id,
1568-
"client_secret": self.application.client_secret,
1570+
"client_secret": CLEARTEXT_SECRET,
15691571
}
15701572

15711573
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data)
@@ -1605,7 +1607,7 @@ def test_oob_as_json(self):
16051607
"code": authorization_code,
16061608
"redirect_uri": URI_OOB_AUTO,
16071609
"client_id": self.application.client_id,
1608-
"client_secret": self.application.client_secret,
1610+
"client_secret": CLEARTEXT_SECRET,
16091611
}
16101612

16111613
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data)
@@ -1681,7 +1683,7 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu
16811683
"code": authorization_code,
16821684
"redirect_uri": "http://example.com?bar=baz&foo=bar",
16831685
}
1684-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
1686+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
16851687

16861688
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
16871689
self.assertEqual(response.status_code, 200)
@@ -1715,7 +1717,7 @@ def test_id_token(self):
17151717
"code": authorization_code,
17161718
"redirect_uri": "http://example.org",
17171719
"client_id": self.application.client_id,
1718-
"client_secret": self.application.client_secret,
1720+
"client_secret": CLEARTEXT_SECRET,
17191721
"scope": "openid",
17201722
}
17211723

@@ -1761,7 +1763,7 @@ def test_resource_access_allowed(self):
17611763
"code": authorization_code,
17621764
"redirect_uri": "http://example.org",
17631765
}
1764-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
1766+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
17651767

17661768
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
17671769
content = json.loads(response.content.decode("utf-8"))
@@ -1819,7 +1821,7 @@ def test_id_token_resource_access_allowed(self):
18191821
"code": authorization_code,
18201822
"redirect_uri": "http://example.org",
18211823
}
1822-
auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret)
1824+
auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET)
18231825

18241826
response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers)
18251827
content = json.loads(response.content.decode("utf-8"))

0 commit comments

Comments
 (0)