From b3ac32db5bc0c5f2864026e7608bffd9155bd284 Mon Sep 17 00:00:00 2001 From: mahdavi Date: Sat, 24 Oct 2020 17:11:08 +0330 Subject: [PATCH] modify gooyal_oauth to get setting --- apps/gooyal_oauth2/migrations/0004_tokens.py | 4 +- .../migrations/0007_auto_20200926_0855.py | 18 ++ apps/gooyal_oauth2/models.py | 2 +- apps/gooyal_oauth2/rest_framework.py | 19 ++ apps/gooyal_oauth2/settings.py | 10 + apps/gooyal_oauth2/validators.py | 205 ++++++++++++++---- gooyal_accounts/settings.py | 2 +- requirements.in | 2 +- requirements.txt | 6 +- 9 files changed, 218 insertions(+), 50 deletions(-) create mode 100644 apps/gooyal_oauth2/migrations/0007_auto_20200926_0855.py create mode 100644 apps/gooyal_oauth2/rest_framework.py create mode 100644 apps/gooyal_oauth2/settings.py diff --git a/apps/gooyal_oauth2/migrations/0004_tokens.py b/apps/gooyal_oauth2/migrations/0004_tokens.py index a1f9f5a..af39bf8 100644 --- a/apps/gooyal_oauth2/migrations/0004_tokens.py +++ b/apps/gooyal_oauth2/migrations/0004_tokens.py @@ -3,7 +3,7 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion -import django_mysql.models +from django.db.models import JSONField class Migration(migrations.Migration): @@ -48,7 +48,7 @@ class Migration(migrations.Migration): ('scope', models.TextField(blank=True)), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), - ('detail', django_mysql.models.JSONField(blank=True, default=dict, null=True)), + ('detail', JSONField(blank=True, default=dict, null=True)), ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='gooyal_oauth2_accesstoken', to=settings.AUTH_USER_MODEL)), ], diff --git a/apps/gooyal_oauth2/migrations/0007_auto_20200926_0855.py b/apps/gooyal_oauth2/migrations/0007_auto_20200926_0855.py new file mode 100644 index 0000000..388199c --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0007_auto_20200926_0855.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.1 on 2020-09-26 05:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gooyal_oauth2', '0006_auto_20200825_0615'), + ] + + operations = [ + migrations.AlterField( + model_name='accesstoken', + name='detail', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/apps/gooyal_oauth2/models.py b/apps/gooyal_oauth2/models.py index 3e31ef5..f4bbf46 100644 --- a/apps/gooyal_oauth2/models.py +++ b/apps/gooyal_oauth2/models.py @@ -5,7 +5,7 @@ Django models for the gooyal-restrict-scopes package. import requests from django.conf import settings from django.db import models -from django_mysql.models import JSONField +from django.db.models import JSONField from oauth2_provider.models import AbstractApplication, AbstractAccessToken, AbstractGrant, AbstractRefreshToken from oauth2_provider.scopes import get_scopes_backend from oauth2_provider.settings import oauth2_settings diff --git a/apps/gooyal_oauth2/rest_framework.py b/apps/gooyal_oauth2/rest_framework.py new file mode 100644 index 0000000..b9fec63 --- /dev/null +++ b/apps/gooyal_oauth2/rest_framework.py @@ -0,0 +1,19 @@ +import logging + +from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements, OAuth2Authentication +from rest_framework.permissions import ( + IsAuthenticated +) + +log = logging.getLogger("oauth2_provider") + + +class IsAuthenticatedOrTokenMatchesOASRequirements(TokenMatchesOASRequirements): + def has_permission(self, request, view): + is_authenticated = IsAuthenticated().has_permission(request, view) + oauth2authenticated = False + if is_authenticated: + oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication) + + token_has_scope = TokenMatchesOASRequirements() + return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view) diff --git a/apps/gooyal_oauth2/settings.py b/apps/gooyal_oauth2/settings.py new file mode 100644 index 0000000..36d03fd --- /dev/null +++ b/apps/gooyal_oauth2/settings.py @@ -0,0 +1,10 @@ +from oauth2_provider.settings import OAuth2ProviderSettings, USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY + +GOOYAL_DEFAULTS = { + "RESOURCE_SERVER_CLIENT_ID": None, + "RESOURCE_SERVER_CLIENT_SECRET": None +} + +DEFAULTS.update(GOOYAL_DEFAULTS) + +oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py index 687b9bf..697f52d 100755 --- a/apps/gooyal_oauth2/validators.py +++ b/apps/gooyal_oauth2/validators.py @@ -1,14 +1,25 @@ -from oauth2_provider.oauth2_validators import OAuth2Validator +import base64 +import logging +from datetime import datetime, timedelta +import requests +import service_clients +from django.conf import settings from django.contrib.auth import get_user_model -from oauth2_provider.settings import oauth2_settings +from django.utils.timezone import make_aware +from oauth2_provider.models import get_access_token_model +from oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator +from .settings import oauth2_settings -from apps.gooyal_oauth2.models import Resource +log = logging.getLogger("oauth2_provider") + +AccessTokenModel = get_access_token_model() +UserModel = get_user_model() USER_MODEL = get_user_model() -class MultiGatewayOAuth2Validator(OAuth2Validator): # pylint: disable=w0223 +class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 def validate_user(self, username, password, client, request, *args, **kwargs): auth_fields = getattr(request, 'auth_fields', 'username:password').split(':') @@ -26,7 +37,7 @@ class MultiGatewayOAuth2Validator(OAuth2Validator): # pylint: disable=w0223 if not username or not password: return False - user = USER_MODEL.objects.filter(**{user_field:username}).first() + user = USER_MODEL.objects.filter(**{user_field: username}).first() if not user: return False @@ -40,42 +51,150 @@ class MultiGatewayOAuth2Validator(OAuth2Validator): # pylint: disable=w0223 return False + def _get_token_from_gooyal_authentication_server( + self, token, introspection_url, introspection_token, introspection_credentials, introspection_client_id, + introspection_client_secret + ): + """Use external introspection endpoint to "crack open" the token. + :param introspection_url: introspection endpoint URL + :param introspection_token: Bearer token + :param introspection_credentials: Basic Auth credentials (id,secret) + :return: :class:`models.AccessToken` -# class IntrospectOAuth2Validator(OAuth2Validator): -# def validate_bearer_token(self, token, scopes, request): -# """ -# When users try to access resources, check that provided token is valid -# """ -# if not token: -# return False -# -# introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL -# introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN -# introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS -# -# try: -# access_token = AccessToken.objects.select_related("application", "user").get(token=token) -# except AccessToken.DoesNotExist: -# access_token = None -# -# # if there is no token or it's invalid then introspect the token if there's an external OAuth server -# if not access_token or not access_token.is_valid(scopes): -# if introspection_url and (introspection_token or introspection_credentials): -# access_token = self._get_token_from_authentication_server( -# token, -# introspection_url, -# introspection_token, -# introspection_credentials -# ) -# -# if access_token and access_token.is_valid(scopes): -# request.client = access_token.application -# request.user = access_token.user or (access_token.application and access_token.application.user) -# request.scopes = scopes -# -# # this is needed by django rest framework -# request.access_token = access_token -# return True -# else: -# self._set_oauth2_error_on_request(request, access_token, scopes) -# return False + Some RFC 7662 implementations (including this one) use a Bearer token while others use Basic + Auth. Depending on the external AS's implementation, provide either the introspection_token + or the introspection_credentials. + + If the resulting access_token identifies a username (e.g. Authorization Code grant), add + that user to the UserModel. Also cache the access_token up until its expiry time or a + configured maximum time. + + """ + + headers = None + if introspection_token: + headers = {"Authorization": "Bearer {}".format(introspection_token)} + try: + response = requests.post( + introspection_url, + data={"token": token}, headers=headers + ) + except requests.exceptions.RequestException: + log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) + return None + + elif introspection_credentials: + client_id = introspection_credentials[0].encode("utf-8") + client_secret = introspection_credentials[1].encode("utf-8") + basic_auth = base64.b64encode(client_id + b":" + client_secret) + headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))} + try: + response = requests.post( + introspection_url, + data={"token": token}, headers=headers + ) + except requests.exceptions.RequestException: + log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) + return None + + elif introspection_client_id and introspection_client_secret: + client = service_clients.Client(client_id=introspection_client_id, + client_secret=introspection_client_secret, + grant_type=service_clients.AccountsClient.GRANT_CLIENT_CREDENTIALS, + scopes=['introspection']) + data = {"token": token} + response = client.request(url=introspection_url, method='post', data=data, + required_scopes=['introspection'], login_required=True) + + try: + content = response.json() + except ValueError: + log.exception("Introspection: Failed to parse response as json") + return None + + if "active" in content and content["active"] is True: + if "username" in content: + headers = {"Authorization": "Bearer {}".format(token)} + user_account = requests.get(f'{settings.BASE_ACCOUNTS_URL}/account', headers=headers).json() + user_phone_number = user_account['phone_number'] + user = UserModel.objects.get( + **{'phone_number': user_phone_number} + ) + else: + user = None + + max_caching_time = datetime.now() + timedelta( + seconds=oauth2_settings.RESOURCE_SERVER_TOKEN_CACHING_SECONDS + ) + + if "exp" in content: + expires = datetime.utcfromtimestamp(content["exp"]) + if expires > max_caching_time: + expires = max_caching_time + else: + expires = max_caching_time + + scope = content.get("scope", "") + expires = make_aware(expires) + + try: + access_token = AccessTokenModel.objects.select_related("application", "user").get(token=token) + except AccessTokenModel.DoesNotExist: + access_token = AccessTokenModel.objects.create( + user=user, + token=token, + application=None, + scope=scope, + expires=expires, + detail=content + ) + else: + access_token.expires = expires + access_token.scope = scope + access_token.detail = content + access_token.save() + + return access_token + + def validate_bearer_token(self, token, scopes, request): + """ + When users try to access resources, check that provided token is valid + """ + if not token: + return False + + introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL + introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN + introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + introspection_client_id = oauth2_settings.RESOURCE_SERVER_CLIENT_ID + introspection_client_secret = oauth2_settings.RESOURCE_SERVER_CLIENT_SECRET + + try: + access_token = AccessTokenModel.objects.select_related("application", "user").get(token=token) + except AccessTokenModel.DoesNotExist: + access_token = None + + # if there is no token or it's invalid then introspect the token if there's an external OAuth server + if not access_token or not access_token.is_valid(scopes): + if introspection_url and (introspection_token or introspection_credentials or (introspection_client_id and + introspection_client_secret)): + access_token = self._get_token_from_gooyal_authentication_server( + token, + introspection_url, + introspection_token, + introspection_credentials, + introspection_client_id, + introspection_client_secret + ) + + if access_token and access_token.is_valid(scopes): + request.client = access_token.application + request.user = access_token.user + request.scopes = scopes + + # this is needed by django rest framework + request.access_token = access_token + return True + else: + self._set_oauth2_error_on_request(request, access_token, scopes) + return False diff --git a/gooyal_accounts/settings.py b/gooyal_accounts/settings.py index 0e62645..63b8e77 100644 --- a/gooyal_accounts/settings.py +++ b/gooyal_accounts/settings.py @@ -69,7 +69,7 @@ OAUTH2_PROVIDER = { 'write': 'Write scope', 'groups': 'Access to your groups', 'introspection': 'Introspect token scope'}, - 'OAUTH2_VALIDATOR_CLASS': 'apps.gooyal_oauth2.validators.MultiGatewayOAuth2Validator' + 'OAUTH2_VALIDATOR_CLASS': 'apps.gooyal_oauth2.validators.OAuth2Validator' } # GOOYAL_DYNAMIC_SCOPES diff --git a/requirements.in b/requirements.in index 3ae0cf9..ee27c97 100644 --- a/requirements.in +++ b/requirements.in @@ -4,7 +4,7 @@ django-oauth-toolkit djangorestframework markdown django-filter -django-cors-middleware +django-cors-headers pillow django-model-utils mysqlclient diff --git a/requirements.txt b/requirements.txt index 038d43e..bd95e01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,17 +10,18 @@ billiard==3.6.3.0 # via celery celery==4.4.7 # via -r requirements.in certifi==2020.6.20 # via requests chardet==3.0.4 # via requests -django-cors-middleware==1.5.0 # via -r requirements.in +django-cors-headers==3.5.0 # via -r requirements.in django-filter==2.3.0 # via -r requirements.in django-model-utils==4.0.0 # via -r requirements.in django-mysql==3.8.1 # via -r requirements.in django-oauth-toolkit==1.3.2 # via -r requirements.in -django==3.1.1 # via -r requirements.in, django-filter, django-model-utils, django-mysql, django-oauth-toolkit, djangorestframework +django==3.1.1 # via -r requirements.in, django-cors-headers, django-filter, django-model-utils, django-mysql, django-oauth-toolkit, djangorestframework djangorestframework==3.11.1 # via -r requirements.in gevent==20.6.2 # via -r requirements.in greenlet==0.4.16 # via gevent gunicorn==20.0.4 # via -r requirements.in idna==2.10 # via requests +importlib-metadata==2.0.0 # via kombu, markdown kombu==4.6.11 # via celery markdown==3.2.2 # via -r requirements.in mysqlclient==2.0.1 # via -r requirements.in @@ -33,6 +34,7 @@ requests==2.24.0 # via django-oauth-toolkit sqlparse==0.3.1 # via django urllib3==1.25.10 # via requests vine==1.3.0 # via amqp, celery +zipp==3.3.1 # via importlib-metadata zope.event==4.4 # via gevent zope.interface==5.1.0 # via gevent