Skip to content

Commit fef446a

Browse files
authored
Merge branch 'devel' into root_logger
2 parents 91957c5 + 7933f91 commit fef446a

File tree

20 files changed

+277
-74
lines changed

20 files changed

+277
-74
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ name: Release django-ansible-base
33

44
env:
55
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
6+
PROJECT_NAME: django-ansible-base
67

78
on:
89
workflow_dispatch:
910

10-
env:
11-
PROJECT_NAME: django-ansible-base
12-
1311
jobs:
1412
build:
1513
runs-on: ubuntu-latest

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

0 commit comments

Comments
 (0)