Skip to content

Commit d8be952

Browse files
BB2-4250: Make v3_endpoints waffle switch app specific (#1429)
* BB2-4250: Initial commit - sorting out how to use the flag in different places * Ensure 403s are thrown if an application is not in the v3_early_adopter waffle_flag. Working for read/search v3 calls, v3 auth/token flows (still need to add to some other auth views) * Some comments, prevent v3/userinfo from returning successfully if the flag is not enabled for that app * Ensure token and auth flows function or throw 403s depending on values in the flag. Add 403 handling for userinfo v3 * Fix some failing unit tests, add integration tests for 403s * Add tests around specific errors being raised for functions added to Token and Authorization views * Cleanup: Remove/add some TODOs * Address security concern raised by Github * Clean up and address PR feedback. Modify conditionals, throw 403 earlier in auth process * Address PR feedback: Handle ObjectNotFound same as app not in flag, remove validate_v3_call variable from AuthorizationView * Ensure a 403 is actually returned not a 500 when debug is set to false * Standardize error responses for token/auth flows - missing token error popped up * Ensure validation for v3 in tokenView only happens on refresh token * Modify comment and fix failing unit test (new error being raised)
1 parent 374e1b1 commit d8be952

File tree

11 files changed

+361
-29
lines changed

11 files changed

+361
-29
lines changed

apps/accounts/views/oauth2_profile.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from apps.fhir.bluebutton.permissions import ApplicationActivePermission
1111

1212
from apps.versions import Versions
13+
from apps.wellknown.permissions import V3EarlyAdopterWellKnownPermission
1314

1415

1516
def _get_userinfo(user, version=Versions.NOT_AN_API_VERSION):
@@ -41,7 +42,8 @@ def _get_userinfo(user, version=Versions.NOT_AN_API_VERSION):
4142
@authentication_classes([OAuth2Authentication])
4243
@permission_classes([ApplicationActivePermission,
4344
TokenHasProtectedCapability,
44-
DataAccessGrantPermission])
45+
DataAccessGrantPermission,
46+
V3EarlyAdopterWellKnownPermission])
4547
@protected_resource() # Django OAuth Toolkit -> resource_owner = AccessToken
4648
def _openidconnect_userinfo(request, version=Versions.NOT_AN_API_VERSION):
4749
# NOTE: The **kwargs are not used anywhere down the callchain, and are being ignored.

apps/authorization/tests/test_data_access_grant_permissions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from apps.authorization.models import (
1414
DataAccessGrant,
1515
)
16-
from waffle.testutils import override_switch
16+
from waffle.testutils import override_switch, override_flag
1717
from apps.fhir.bluebutton.tests.test_fhir_resources_read_search_w_validation import (
1818
get_response_json,
1919
)
@@ -145,6 +145,7 @@ def setUp(self):
145145
self.client = Client()
146146

147147
@override_switch('v3_endpoints', active=True)
148+
@override_flag('v3_early_adopter', active=True)
148149
def _assert_call_all_fhir_endpoints(
149150
self,
150151
access_token=None,

apps/dot_ext/tests/test_authorization.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@
66
from datetime import datetime
77
from dateutil.relativedelta import relativedelta
88
# from oauth2_provider.compat import parse_qs, urlparse
9-
from urllib.parse import parse_qs, urlparse
9+
from oauthlib.oauth2.rfc6749.errors import AccessDeniedError as AccessDeniedTokenCustomError
1010
from oauth2_provider.models import get_access_token_model, get_refresh_token_model
1111
from django.http import HttpRequest
1212
from django.urls import reverse
1313
from django.test import Client
14+
from unittest.mock import patch, MagicMock
15+
from urllib.parse import parse_qs, urlencode, urlparse
1416
from waffle.testutils import override_switch
1517

1618
from apps.test import BaseApiTest
1719
from ..models import Application, ArchivedToken
20+
from apps.dot_ext.views import AuthorizationView, TokenView
1821
from apps.authorization.models import DataAccessGrant, ArchivedDataAccessGrant
1922
from http import HTTPStatus
2023

@@ -1020,3 +1023,103 @@ def _execute_token_endpoint(self, token_path):
10201023
c = Client()
10211024
response = c.post(token_path, data=token_request_data)
10221025
self.assertEqual(response.status_code, 200)
1026+
1027+
@patch('apps.dot_ext.views.authorization.get_user_model')
1028+
@patch('apps.dot_ext.views.authorization.get_application_model')
1029+
@patch('apps.dot_ext.views.authorization.get_waffle_flag_model')
1030+
def test_permission_denied_raised_for_authorize_app_not_in_flag(
1031+
self,
1032+
mock_get_flag_model,
1033+
mock_get_application_model,
1034+
mock_get_user_model
1035+
):
1036+
# Unit test to show that we will raise an AccessDeniedTokenCustomError
1037+
# when the validate_v3_authorization_request of AuthorizationView function is called
1038+
# when the v3_early_adopter flag is not active for an application_user
1039+
# set up a fake request
1040+
# TODO: When we enable v3 endpoints for all applications, remove this test
1041+
query_params = urlencode({'client_id': 'FAKE_CLIENT_ID'})
1042+
request = HttpRequest()
1043+
request.META['QUERY_STRING'] = query_params
1044+
1045+
# Mock the required objects/queries around flag/application/user
1046+
fake_flag = MagicMock()
1047+
fake_flag.id = 123
1048+
fake_flag.name = 'v3_early_adopter'
1049+
fake_flag.is_active_for_user.return_value = False
1050+
mock_get_flag_model.return_value.get.return_value = fake_flag
1051+
1052+
fake_application = MagicMock()
1053+
fake_application.id = 42
1054+
fake_application.user_id = 999
1055+
fake_application.name = 'TestApp'
1056+
mock_manager_app = MagicMock()
1057+
mock_manager_app.get.return_value = fake_application
1058+
mock_get_application_model.return_value.objects = mock_manager_app
1059+
1060+
fake_user = MagicMock()
1061+
fake_user.id = 999
1062+
mock_manager_user = MagicMock()
1063+
mock_manager_user.get.return_value = fake_user
1064+
mock_get_user_model.return_value.objects = mock_manager_user
1065+
1066+
# Create an instance of the view
1067+
view_instance = AuthorizationView()
1068+
view_instance.request = request
1069+
1070+
with self.assertRaises(AccessDeniedTokenCustomError):
1071+
view_instance.validate_v3_authorization_request()
1072+
1073+
@patch('apps.dot_ext.views.authorization.get_user_model')
1074+
@patch('apps.dot_ext.views.authorization.get_application_model')
1075+
@patch('apps.dot_ext.views.authorization.get_refresh_token_model')
1076+
@patch('apps.dot_ext.views.authorization.get_waffle_flag_model')
1077+
def test_permission_denied_raised_for_refresh_token_app_not_in_flag(
1078+
self,
1079+
mock_get_flag_model,
1080+
mock_get_refresh_token_model,
1081+
mock_get_application_model,
1082+
mock_get_user_model
1083+
):
1084+
# BB2-4250Unit test to show that we will raise an PermissionDenied
1085+
# when the validate_v3_token_call of TokenView function is called
1086+
# when the v3_early_adopter flag is not active for an application_user
1087+
# TODO: When we enable v3 endpoints for all applications, remove this test
1088+
token_value = 'FAKE_REFRESH_TOKEN'
1089+
body = urlencode({'refresh_token': token_value}).encode('utf-8')
1090+
request = HttpRequest()
1091+
request._body = body
1092+
1093+
# Mock the required objects/queries around flag/refresh_token/application/user
1094+
fake_flag = MagicMock()
1095+
fake_flag.id = 123
1096+
fake_flag.name = 'v3_early_adopter'
1097+
fake_flag.is_active_for_user.return_value = False
1098+
mock_get_flag_model.return_value.get.return_value = fake_flag
1099+
1100+
fake_refresh_token = MagicMock()
1101+
fake_refresh_token.token = token_value
1102+
fake_refresh_token.application_id = 42
1103+
mock_manager_refresh = MagicMock()
1104+
mock_manager_refresh.get.return_value = fake_refresh_token
1105+
mock_get_refresh_token_model.return_value.objects = mock_manager_refresh
1106+
1107+
fake_application = MagicMock()
1108+
fake_application.id = 42
1109+
fake_application.user_id = 999
1110+
fake_application.name = 'TestApp'
1111+
mock_manager_app = MagicMock()
1112+
mock_manager_app.get.return_value = fake_application
1113+
mock_get_application_model.return_value.objects = mock_manager_app
1114+
1115+
fake_user = MagicMock()
1116+
fake_user.id = 999
1117+
mock_manager_user = MagicMock()
1118+
mock_manager_user.get.return_value = fake_user
1119+
mock_get_user_model.return_value.objects = mock_manager_user
1120+
1121+
# Create an instance of the view
1122+
view_instance = TokenView()
1123+
1124+
with self.assertRaises(AccessDeniedTokenCustomError):
1125+
view_instance.validate_v3_token_call(request)

apps/dot_ext/views/authorization.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,36 @@
44
from functools import wraps
55
from time import strftime
66

7+
from django.conf import settings
8+
from django.contrib.auth import get_user_model
79
from django.contrib.auth.views import redirect_to_login
810
from django.http import JsonResponse
911
from django.http.response import HttpResponse, HttpResponseBadRequest
1012
from django.template.response import TemplateResponse
13+
from django.core.exceptions import ObjectDoesNotExist
1114
from django.utils.decorators import method_decorator
1215
from django.views.decorators.csrf import csrf_exempt
1316
from django.views.decorators.debug import sensitive_post_parameters
1417
from apps.dot_ext.constants import TOKEN_ENDPOINT_V3_KEY
18+
from oauthlib.oauth2.rfc6749.errors import AccessDeniedError as AccessDeniedTokenCustomError
1519
from oauth2_provider.exceptions import OAuthToolkitError
16-
from oauth2_provider.views.base import app_authorized, get_access_token_model
20+
from oauth2_provider.views.base import app_authorized
21+
from oauth2_provider.models import get_refresh_token_model, get_access_token_model
1722
from oauth2_provider.views.base import AuthorizationView as DotAuthorizationView
1823
from oauth2_provider.views.base import TokenView as DotTokenView
1924
from oauth2_provider.views.base import RevokeTokenView as DotRevokeTokenView
2025
from oauth2_provider.views.introspect import (
2126
IntrospectTokenView as DotIntrospectTokenView,
2227
)
23-
from waffle import switch_is_active
28+
from waffle import switch_is_active, get_waffle_flag_model
2429
from oauth2_provider.models import get_application_model
2530
from oauthlib.oauth2 import AccessDeniedError
2631
from oauthlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError, InvalidRequestError
2732
from urllib.parse import urlparse, parse_qs
2833
import html
2934
from apps.dot_ext.scopes import CapabilitiesScopes
3035
import apps.logging.request_logger as bb2logging
36+
from apps.versions import Versions
3137

3238
from ..signals import beneficiary_authorized_application
3339
from ..forms import SimpleAllowForm
@@ -41,6 +47,7 @@
4147
)
4248
from ..models import Approval
4349
from ..utils import (
50+
get_api_version_number_from_url,
4451
remove_application_user_pair_tokens_data_access,
4552
validate_app_is_active,
4653
json_response_from_oauth2_error,
@@ -84,6 +91,7 @@ class AuthorizationView(DotAuthorizationView):
8491
# TODO: rename this so that it isn't the same as self.version (works but confusing)
8592
# this needs to be here for urls.py as_view(version) calls, but don't use it
8693
version = 0
94+
# Variable to help reduce the amount of times validate_v3_authorization_request is called
8795
form_class = SimpleAllowForm
8896
login_url = "/mymedicare/login"
8997

@@ -116,6 +124,8 @@ def _check_for_required_params(self, request):
116124
error_message = "State parameter should have a minimum of 16 characters"
117125
return JsonResponse({"status_code": 400, "message": error_message}, status=400)
118126

127+
# BB2-4250: This code will not execute if the application is not in the v3_early_adopter flag
128+
# so it will not be modified as part of BB2-4250
119129
if switch_is_active('v3_endpoints') and v3:
120130
if 'scope' not in request.GET:
121131
missing_params.append("scope")
@@ -140,6 +150,18 @@ def dispatch(self, request, *args, **kwargs):
140150
initially create an AuthFlowUuid object for authorization
141151
flow tracing in logs.
142152
"""
153+
path_info = self.request.__dict__.get('path_info')
154+
version = get_api_version_number_from_url(path_info)
155+
# If it is not version 3, we don't need to check anything, just return
156+
if version == Versions.V3:
157+
try:
158+
self.validate_v3_authorization_request()
159+
except AccessDeniedTokenCustomError as e:
160+
return JsonResponse(
161+
{'status_code': 403, 'message': str(e)},
162+
status=403,
163+
)
164+
143165
# TODO: Should the client_id match a valid application here before continuing, instead of after matching to FHIR_ID?
144166
if not kwargs.get('is_subclass_approvalview', False):
145167
# Create new authorization flow trace UUID in session and AuthFlowUuid instance, if subclass is not ApprovalView
@@ -224,6 +246,28 @@ def get(self, request, *args, **kwargs):
224246
kwargs['code_challenge_method'] = request.GET.get('code_challenge_method', None)
225247
return super().get(request, *args, **kwargs)
226248

249+
def validate_v3_authorization_request(self):
250+
flag = get_waffle_flag_model().get('v3_early_adopter')
251+
req_meta = self.request.META
252+
url_query = parse_qs(req_meta.get('QUERY_STRING'))
253+
client_id = url_query.get('client_id', [None])
254+
try:
255+
application = get_application_model().objects.get(client_id=client_id[0])
256+
application_user = get_user_model().objects.get(id=application.user_id)
257+
258+
if flag.id is None or flag.is_active_for_user(application_user):
259+
# Update the class variable to ensure subsequent calls to dispatch don't call this function
260+
# more times than is needed
261+
return
262+
else:
263+
raise AccessDeniedTokenCustomError(
264+
description=settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name)
265+
)
266+
except ObjectDoesNotExist:
267+
raise AccessDeniedTokenCustomError(
268+
description='Unable to verify permission.'
269+
)
270+
227271
def form_valid(self, form):
228272
client_id = form.cleaned_data["client_id"]
229273
application = get_application_model().objects.get(client_id=client_id)
@@ -262,6 +306,7 @@ def form_valid(self, form):
262306
refresh_token_delete_cnt = 0
263307

264308
try:
309+
265310
if not scopes:
266311
# Since the create_authorization_response will re-inject scopes even when none are
267312
# valid, we want to pre-emptively treat this as an error case
@@ -410,13 +455,48 @@ def validate_token_endpoint_request_body(self, request):
410455
description=f"Invalid parameters in request: {invalid_parameters}"
411456
)
412457

458+
def validate_v3_token_call(self, request) -> None:
459+
flag = get_waffle_flag_model().get('v3_early_adopter')
460+
461+
try:
462+
url_query = parse_qs(request._body.decode('utf-8'))
463+
refresh_token_from_request = url_query.get('refresh_token', [None])
464+
refresh_token = get_refresh_token_model().objects.get(token=refresh_token_from_request[0])
465+
application = get_application_model().objects.get(id=refresh_token.application_id)
466+
application_user = get_user_model().objects.get(id=application.user_id)
467+
468+
if flag.id is None or flag.is_active_for_user(application_user):
469+
return
470+
else:
471+
raise AccessDeniedTokenCustomError(
472+
description=settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name)
473+
)
474+
except ObjectDoesNotExist:
475+
raise AccessDeniedTokenCustomError(
476+
description='Unable to verify permission.'
477+
)
478+
413479
@method_decorator(sensitive_post_parameters("password"))
414480
def post(self, request, *args, **kwargs):
481+
path_info = self.request.__dict__.get('path_info')
482+
version = get_api_version_number_from_url(path_info)
483+
url_query = parse_qs(request._body.decode('utf-8'))
484+
grant_type = url_query.get('grant_type', [None])
415485
try:
486+
# If it is not version 3, we don't need to check that the application is in the v3_early_adopter flag,
487+
# just continue with standard validation.
488+
# Also, we only want to execute this on refresh_token grant types, not authorization_code
489+
if version == Versions.V3 and grant_type[0] and grant_type[0] == 'refresh_token':
490+
self.validate_v3_token_call(request)
416491
self.validate_token_endpoint_request_body(request)
417492
app = validate_app_is_active(request)
418493
except (InvalidClientError, InvalidGrantError, InvalidRequestError) as error:
419494
return json_response_from_oauth2_error(error)
495+
except AccessDeniedTokenCustomError as e:
496+
return JsonResponse(
497+
{'status_code': 403, 'message': str(e)},
498+
status=403,
499+
)
420500

421501
url, headers, body, status = self.create_token_response(request)
422502

apps/fhir/bluebutton/permissions.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from django.conf import settings
44
from django.contrib.auth import get_user_model
5+
from oauth2_provider.views.base import get_access_token_model
6+
from oauth2_provider.models import get_application_model
57
from rest_framework import permissions, exceptions
6-
from rest_framework.exceptions import AuthenticationFailed
8+
from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
9+
from waffle import get_waffle_flag_model
710
from .constants import ALLOWED_RESOURCE_TYPES
811
from apps.versions import Versions, VersionNotMatched
912

@@ -106,3 +109,22 @@ def has_permission(self, request, view):
106109
)
107110

108111
return True
112+
113+
114+
class V3EarlyAdopterPermission(permissions.BasePermission):
115+
def has_permission(self, request, view):
116+
# if it is not version 3, we do not need to check the waffle switch or flag
117+
if view.version < Versions.V3:
118+
return True
119+
120+
token = get_access_token_model().objects.get(token=request._auth)
121+
application = get_application_model().objects.get(id=token.application_id)
122+
application_user = get_user_model().objects.get(id=application.user_id)
123+
flag = get_waffle_flag_model().get('v3_early_adopter')
124+
125+
if flag.id is None or flag.is_active_for_user(application_user):
126+
return True
127+
else:
128+
raise PermissionDenied(
129+
settings.APPLICATION_DOES_NOT_HAVE_V3_ENABLED_YET.format(application.name)
130+
)

0 commit comments

Comments
 (0)