Skip to content

Commit 58f85da

Browse files
authored
[oauth2_provider] Hash access and refresh tokens (ansible#641)
Previously, OAuth2 Access tokens including PATs were not hashed or encrypted in any way, and were stored in plaintext in the database. Seeing them required direct database access, but it is still better to hash them, since they are long-lived. This commit implements hashing (using sha256) of access tokens. Hashing is unsalted, as we need to be able to key on a stable input - but also because the tokens are already random strings and a salt adds nothing more of security. The input bearer token (used to auth a user) is hashed in LoggedOAuth2Authentication and stuffed back into the request. This might seem like a weird place to inject the hash, but it avoids having to override any DOT internals. The serializer has been updated to account for the new functionality and still works the same way as in the past: On POST (new token creation), the token will be displayed -- after that it will not. Test fixtures and tests have been updated as well. --------- Signed-off-by: Rick Elrod <[email protected]>
1 parent ac57d9a commit 58f85da

File tree

18 files changed

+247
-71
lines changed

18 files changed

+247
-71
lines changed

ansible_base/lib/utils/hashing.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,18 @@ def hash_serializer_data(instance: Model, serializer: Type[Serializer], field: O
1616
serialized_data = serialized_data[field]
1717
metadata_json = json.dumps(serialized_data, sort_keys=True).encode("utf-8")
1818
return hasher(metadata_json).hexdigest()
19+
20+
21+
def hash_string(inp: str, hasher: Callable = hashlib.sha256, algo=""):
22+
"""
23+
Takes a string and hashes it with the given hasher function.
24+
If algo is given, it is prepended to the hash between dollar signs ($)
25+
before the hash is returned.
26+
27+
NOTE: There is no salt or pepper here, so this is not secure for passwords.
28+
It is, however, useful for *random* strings like tokens, that need to be secured.
29+
"""
30+
hash = hasher(inp.encode("utf-8")).hexdigest()
31+
if algo:
32+
return f"${algo}${hash}"
33+
return hash

ansible_base/oauth2_provider/authentication.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import hashlib
12
import logging
23

34
from django.utils.encoding import smart_str
45
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
56
from oauth2_provider.oauth2_backends import OAuthLibCore as _OAuthLibCore
67
from rest_framework.exceptions import UnsupportedMediaType
78

9+
from ansible_base.lib.utils.hashing import hash_string
10+
811
logger = logging.getLogger('ansible_base.oauth2_provider.authentication')
912

1013

@@ -18,7 +21,24 @@ def extract_body(self, request):
1821

1922
class LoggedOAuth2Authentication(OAuth2Authentication):
2023
def authenticate(self, request):
21-
ret = super().authenticate(request)
24+
# sha256 the bearer token. We store the hash in the database
25+
# and this gives us a place to hash the incoming token for comparison
26+
did_hash_token = False
27+
bearer_token = request.META.get('HTTP_AUTHORIZATION')
28+
if bearer_token and bearer_token.lower().startswith('bearer '):
29+
token_component = bearer_token.split(' ', 1)[1]
30+
hashed = hash_string(token_component, hasher=hashlib.sha256, algo="sha256")
31+
did_hash_token = True
32+
request.META['HTTP_AUTHORIZATION'] = f"Bearer {hashed}"
33+
34+
# We don't /really/ want to modify the request, so after we're done authing,
35+
# revert what we did above.
36+
try:
37+
ret = super().authenticate(request)
38+
finally:
39+
if did_hash_token:
40+
request.META['HTTP_AUTHORIZATION'] = bearer_token
41+
2242
if ret:
2343
user, token = ret
2444
username = user.username if user else '<none>'

ansible_base/oauth2_provider/fixtures.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import hashlib
12
from datetime import datetime, timezone
23

34
import pytest
45
from oauthlib.common import generate_token
56

67
from ansible_base.lib.testing.fixtures import copy_fixture
8+
from ansible_base.lib.utils.hashing import hash_string
79
from ansible_base.lib.utils.response import get_relative_url
810
from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2Application
911

@@ -62,10 +64,18 @@ def oauth2_application_password(randname):
6264

6365
@pytest.fixture
6466
def oauth2_admin_access_token(oauth2_application, admin_api_client, admin_user):
67+
"""
68+
3-tuple with (token object with hashed token, plaintext token, plaintext_refresh_token)
69+
"""
6570
url = get_relative_url('token-list')
6671
response = admin_api_client.post(url, {'application': oauth2_application[0].pk})
6772
assert response.status_code == 201
68-
return OAuth2AccessToken.objects.get(token=response.data['token'])
73+
74+
plaintext_token = response.data['token']
75+
plaintext_refresh_token = response.data['refresh_token']
76+
hashed_token = hash_string(plaintext_token, hasher=hashlib.sha256, algo="sha256")
77+
token = OAuth2AccessToken.objects.get(token=hashed_token)
78+
return (token, plaintext_token, plaintext_refresh_token)
6979

7080

7181
@copy_fixture(copies=3)

ansible_base/oauth2_provider/management/commands/create_oauth2_token.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,5 @@ def __init__(self):
3131
self.user = user
3232

3333
serializer_obj.context['request'] = FakeRequest()
34-
token_record = serializer_obj.create(config)
35-
self.stdout.write(token_record.token)
34+
serializer_obj.create(config)
35+
self.stdout.write(serializer_obj.unencrypted_token)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django.db import migrations
2+
3+
from ansible_base.oauth2_provider.migrations._utils import hash_tokens
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("dab_oauth2_provider", "0004_alter_oauth2accesstoken_scope"),
9+
]
10+
11+
operations = [
12+
migrations.RunPython(hash_tokens),
13+
]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import hashlib
2+
3+
from ansible_base.lib.utils.hashing import hash_string
4+
5+
6+
def hash_tokens(apps, schema_editor):
7+
OAuth2AccessToken = apps.get_model("dab_oauth2_provider", "OAuth2AccessToken")
8+
OAuth2RefreshToken = apps.get_model("dab_oauth2_provider", "OAuth2RefreshToken")
9+
for model in (OAuth2AccessToken, OAuth2RefreshToken):
10+
for token in model.objects.all():
11+
# Never re-hash a hashed token
12+
if token.token.startswith("$"):
13+
continue
14+
hashed = hash_string(token.token, hasher=hashlib.sha256, algo="sha256")
15+
token.token = hashed
16+
token.save()

ansible_base/oauth2_provider/models/access_token.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import hashlib
2+
13
import oauth2_provider.models as oauth2_models
24
from django.conf import settings
35
from django.core.exceptions import ValidationError
@@ -7,6 +9,7 @@
79
from oauthlib import oauth2
810

911
from ansible_base.lib.abstract_models.common import CommonModel
12+
from ansible_base.lib.utils.hashing import hash_string
1013
from ansible_base.lib.utils.models import prevent_search
1114
from ansible_base.lib.utils.settings import get_setting
1215
from ansible_base.oauth2_provider.utils import is_external_account
@@ -103,4 +106,5 @@ def validate_external_users(self):
103106
def save(self, *args, **kwargs):
104107
if not self.pk:
105108
self.validate_external_users()
109+
self.token = hash_string(self.token, hasher=hashlib.sha256, algo="sha256")
106110
super().save(*args, **kwargs)

ansible_base/oauth2_provider/models/refresh_token.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import hashlib
2+
13
import oauth2_provider.models as oauth2_models
24
from django.conf import settings
35
from django.db import models
46
from django.utils.translation import gettext_lazy as _
57

68
from ansible_base.lib.abstract_models.common import CommonModel
9+
from ansible_base.lib.utils.hashing import hash_string
710
from ansible_base.lib.utils.models import prevent_search
811

912
activitystream = object
@@ -21,3 +24,8 @@ class Meta(oauth2_models.AbstractRefreshToken.Meta):
2124

2225
token = prevent_search(models.CharField(max_length=255))
2326
updated = None # Tracked in CommonModel with 'modified', no need for this
27+
28+
def save(self, *args, **kwargs):
29+
if not self.pk:
30+
self.token = hash_string(self.token, hasher=hashlib.sha256, algo="sha256")
31+
super().save(*args, **kwargs)

ansible_base/oauth2_provider/serializers/token.py

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
logger = logging.getLogger("ansible_base.oauth2_provider.serializers.token")
2121

2222

23-
class BaseOAuth2TokenSerializer(CommonModelSerializer):
23+
class OAuth2TokenSerializer(CommonModelSerializer):
2424
refresh_token = SerializerMethodField()
25-
token = SerializerMethodField()
25+
26+
unencrypted_token = None # Only used in POST so we can return the token in the response
27+
unencrypted_refresh_token = None # Only used in POST so we can return the refresh token in the response
2628

2729
class Meta:
2830
model = OAuth2AccessToken
@@ -40,23 +42,23 @@ class Meta:
4042
read_only_fields = ('user', 'token', 'expires', 'refresh_token')
4143
extra_kwargs = {'scope': {'allow_null': False, 'required': False}, 'user': {'allow_null': False, 'required': True}}
4244

43-
def get_token(self, obj) -> str:
44-
request = self.context.get('request')
45-
try:
46-
if request and request.method == 'POST':
47-
return obj.token
48-
else:
49-
return ENCRYPTED_STRING
50-
except ObjectDoesNotExist:
51-
return ''
45+
def to_representation(self, instance):
46+
request = self.context.get('request', None)
47+
ret = super().to_representation(instance)
48+
if request and request.method == 'POST':
49+
# If we're creating the token, show it. Otherwise, show the encrypted string.
50+
ret['token'] = self.unencrypted_token
51+
else:
52+
ret['token'] = ENCRYPTED_STRING
53+
return ret
5254

5355
def get_refresh_token(self, obj) -> Optional[str]:
5456
request = self.context.get('request')
5557
try:
5658
if not obj.refresh_token:
5759
return None
5860
elif request and request.method == 'POST':
59-
return getattr(obj.refresh_token, 'token', '')
61+
return self.unencrypted_refresh_token
6062
else:
6163
return ENCRYPTED_STRING
6264
except ObjectDoesNotExist:
@@ -78,26 +80,30 @@ def validate_scope(self, value):
7880
raise ValidationError(_('Must be a simple space-separated string with allowed scopes {}.').format(SCOPES))
7981
return value
8082

81-
def create(self, validated_data):
82-
validated_data['user'] = self.context['request'].user
83-
try:
84-
return super().create(validated_data)
85-
except AccessDeniedError as e:
86-
raise PermissionDenied(str(e))
87-
88-
89-
class OAuth2TokenSerializer(BaseOAuth2TokenSerializer):
9083
def create(self, validated_data):
9184
current_user = get_current_user()
9285
validated_data['token'] = generate_token()
9386
expires_delta = get_setting('OAUTH2_PROVIDER', {}).get('ACCESS_TOKEN_EXPIRE_SECONDS', 0)
9487
if expires_delta == 0:
9588
logger.warning("OAUTH2_PROVIDER.ACCESS_TOKEN_EXPIRE_SECONDS was set to 0, creating token that has already expired")
9689
validated_data['expires'] = now() + timedelta(seconds=expires_delta)
97-
obj = super().create(validated_data)
90+
validated_data['user'] = self.context['request'].user
91+
self.unencrypted_token = validated_data.get('token') # Before it is hashed
92+
93+
try:
94+
obj = super().create(validated_data)
95+
except AccessDeniedError as e:
96+
raise PermissionDenied(str(e))
97+
9898
if obj.application and obj.application.user:
9999
obj.user = obj.application.user
100100
obj.save()
101101
if obj.application:
102-
OAuth2RefreshToken.objects.create(user=current_user, token=generate_token(), application=obj.application, access_token=obj)
102+
self.unencrypted_refresh_token = generate_token()
103+
OAuth2RefreshToken.objects.create(
104+
user=current_user,
105+
token=self.unencrypted_refresh_token,
106+
application=obj.application,
107+
access_token=obj,
108+
)
103109
return obj

ansible_base/oauth2_provider/views/token.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import hashlib
12
from datetime import timedelta
23

34
from django.utils.timezone import now
45
from oauth2_provider import views as oauth_views
56
from oauthlib import oauth2
67
from rest_framework.viewsets import ModelViewSet
78

9+
from ansible_base.lib.utils.hashing import hash_string
810
from ansible_base.lib.utils.settings import get_setting
911
from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView
1012
from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2RefreshToken
@@ -28,7 +30,8 @@ def create_token_response(self, request):
2830
# This code detects and auto-expires them on refresh grant
2931
# requests.
3032
if request.POST.get('grant_type') == 'refresh_token' and 'refresh_token' in request.POST:
31-
refresh_token = OAuth2RefreshToken.objects.filter(token=request.POST['refresh_token']).first()
33+
hashed_refresh_token = hash_string(request.POST['refresh_token'], hasher=hashlib.sha256, algo="sha256")
34+
refresh_token = OAuth2RefreshToken.objects.filter(token=hashed_refresh_token).first()
3235
if refresh_token:
3336
expire_seconds = get_setting('OAUTH2_PROVIDER', {}).get('REFRESH_TOKEN_EXPIRE_SECONDS', 0)
3437
if refresh_token.created + timedelta(seconds=expire_seconds) < now():
@@ -38,7 +41,23 @@ def create_token_response(self, request):
3841

3942
# oauth2_provider.oauth2_backends.OAuthLibCore.create_token_response
4043
# (we override this so we can implement our own error handling to be compatible with AWX)
41-
uri, http_method, body, headers = core._extract_params(request)
44+
45+
# This is really, really ugly. Modify the request to hash the refresh_token
46+
# but only long enough for the oauth lib to do its magic.
47+
did_hash_refresh_token = False
48+
old_post = request.POST
49+
if 'refresh_token' in request.POST:
50+
did_hash_refresh_token = True
51+
request.POST = request.POST.copy() # so it's mutable
52+
hashed_refresh_token = hash_string(request.POST['refresh_token'], hasher=hashlib.sha256, algo="sha256")
53+
request.POST['refresh_token'] = hashed_refresh_token
54+
55+
try:
56+
uri, http_method, body, headers = core._extract_params(request)
57+
finally:
58+
if did_hash_refresh_token:
59+
request.POST = old_post
60+
4261
extra_credentials = core._get_extra_credentials(request)
4362
try:
4463
headers, body, status = core.server.create_token_response(uri, http_method, body, headers, extra_credentials)

0 commit comments

Comments
 (0)