Repository: jazzband/django-oauth-toolkit

Issue:
Why are secrets in plain text?
Hello all,
I was digging in the data and I found out that all the secrets are stored in plain text in the DB. 

Why so?

reading this https://github.com/jazzband/django-oauth-toolkit/issues/276 it seems that the client_secret is used to generate/calculate some value, which one precisely? so for that, there's no way to hash it.

However, what about access_token and refresh tokens? why are not they hashed? in the end, they should be, somehow, as a password, so the real value is not needed (although you will have access to the real value each request if needed).




Discussion:
I think the main reason is that most of the time it's not required. With the multiple factors involved (you usually need client secret + password to get an access token) and them being random, often temporary, and not reused anywhere, stealing a bunch of client secrets doesn't give intruder much more than they already have.

But I can see some value in hashing and salting not just client secrets but also all the tokens and grants. Hashing access tokens by default is usually pointless (would make requests too slow) but in case of extreme paranoia (e.g. auth servers used by important resource servers) could be possible as well (even introspection should work, I think, token is always presented plain when something needs to be done), probably with some clever optimization to speed up verifying requests or perhaps just lighter hash algo as those tokens are short-lived and random enough.

I think it could be implemented as an extension over DOT or even as an optional model mixin or something here. Feel free to PR and, you know, occasionally ask questions about how to override one method or other.
The client credentials (2-legged) flow does not require an additional password, so in that sense the `client_id` and `client_secret` act like a username/password pair. Not hashing the secrets does feel like a gap, since a compromise of the database effectively gives you plain text passwords for all client credentials applications.

I'd be happy to work up a patch to address this. I did not see any mention of it elsewhere.

Resolving patch:
diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py
--- a/oauth2_provider/models.py
+++ b/oauth2_provider/models.py
@@ -6,6 +6,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
@@ -24,6 +25,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.
@@ -90,7 +104,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)
diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py
--- a/oauth2_provider/oauth2_validators.py
+++ b/oauth2_provider/oauth2_validators.py
@@ -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
@@ -122,6 +123,17 @@ 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
+        # we use the "$" as a sentinel character to determine
+        # whether a secret has been hashed like a Django password or not.
+        # We can do this because the default oauthlib.common.UNICODE_ASCII_CHARACTER_SET
+        # used by our default generator does not include the "$" character.
+        # However, if a different character set was used to generate the secret, this sentinel
+        # might be a false positive.
+        elif "$" in request.client.client_secret and request.client.client_secret != 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
diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py
--- a/oauth2_provider/settings.py
+++ b/oauth2_provider/settings.py
@@ -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": {},


Test patch:
diff --git a/tests/presets.py b/tests/presets.py
--- a/tests/presets.py
+++ b/tests/presets.py
@@ -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"}
diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py
--- a/tests/test_client_credential.py
+++ b/tests/test_client_credential.py
@@ -24,6 +24,8 @@
 AccessToken = get_access_token_model()
 UserModel = get_user_model()
 
+CLIENT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890"
+
 
 # mocking a protected resource view
 class ResourceView(ProtectedResourceView):
@@ -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):
@@ -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",