diff --git a/app/app/settings.py b/app/app/settings.py index d294ca5b..f0de907c 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -22,7 +22,7 @@ from furl import furl BASE_DIR = path.dirname(path.dirname(path.abspath(__file__))) env = Env() -env.read_env(BASE_DIR, recurse=False) +env.read_env(path.join(BASE_DIR, '.env'), recurse=False) # Quick-start development settings - unsuitable for production @@ -131,10 +131,34 @@ AUTHENTICATION_BACKENDS = [ SOCIAL_AUTH_GITHUB_KEY = env('OAUTH_GITHUB_KEY', None) SOCIAL_AUTH_GITHUB_SECRET = env('OAUTH_GITHUB_SECRET', None) +GITHUB_ADMIN_ORG_NAME = env('GITHUB_ADMIN_ORG_NAME', None) +GITHUB_ADMIN_TEAM_NAME = env('GITHUB_ADMIN_TEAM_NAME', None) + +if GITHUB_ADMIN_ORG_NAME and GITHUB_ADMIN_TEAM_NAME: + SOCIAL_AUTH_GITHUB_SCOPE = ['read:org'] SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY = env('OAUTH_AAD_KEY', None) SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET = env('OAUTH_AAD_SECRET', None) SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID = env('OAUTH_AAD_TENANT', None) +AZUREAD_ADMIN_GROUP_ID = env('AZUREAD_ADMIN_GROUP_ID', None) + +if AZUREAD_ADMIN_GROUP_ID: + SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_RESOURCE = 'https://graph.microsoft.com/' + SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SCOPE = ['Directory.Read.All'] + +SOCIAL_AUTH_PIPELINE = [ + 'social_core.pipeline.social_auth.social_details', + 'social_core.pipeline.social_auth.social_uid', + 'social_core.pipeline.social_auth.auth_allowed', + 'social_core.pipeline.social_auth.social_user', + 'social_core.pipeline.user.get_username', + 'social_core.pipeline.user.create_user', + 'social_core.pipeline.social_auth.associate_user', + 'social_core.pipeline.social_auth.load_extra_data', + 'social_core.pipeline.user.user_details', + 'server.social_auth.fetch_github_permissions', + 'server.social_auth.fetch_azuread_permissions', +] # Database # https://docs.djangoproject.com/en/2.0/ref/settings/#databases diff --git a/app/server/social_auth.py b/app/server/social_auth.py new file mode 100644 index 00000000..35adf722 --- /dev/null +++ b/app/server/social_auth.py @@ -0,0 +1,70 @@ +import requests +from django.conf import settings +from social_core.backends.azuread_tenant import AzureADTenantOAuth2 +from social_core.backends.github import GithubOAuth2 + + +# noinspection PyUnusedLocal +def fetch_github_permissions(strategy, details, user=None, is_new=False, *args, **kwargs): + org_name = getattr(settings, 'GITHUB_ADMIN_ORG_NAME', '') + team_name = getattr(settings, 'GITHUB_ADMIN_TEAM_NAME', '') + if not user or not isinstance(kwargs['backend'], GithubOAuth2) or not org_name or not team_name: + return + + response = requests.post( + url='https://api.github.com/graphql', + headers={ + 'Authorization': 'Bearer {}'.format(kwargs['response']['access_token']), + }, + json={ + 'query': ''' + query($userName: String!, $orgName: String!, $teamName: String!) { + organization(login: $orgName) { + teams(query: $teamName, userLogins: [$userName], first: 1) { + nodes { + name + } + } + } + } + ''', + 'variables': { + 'userName': details['username'], + 'orgName': org_name, + 'teamName': team_name, + } + } + ) + response.raise_for_status() + response = response.json() + + is_superuser = {'name': team_name} in response['data']['organization']['teams']['nodes'] + + if user.is_superuser != is_superuser: + user.is_superuser = is_superuser + user.save() + + +# noinspection PyUnusedLocal +def fetch_azuread_permissions(strategy, details, user=None, is_new=False, *args, **kwargs): + group_id = getattr(settings, 'AZUREAD_ADMIN_GROUP_ID', '') + if not user or not isinstance(kwargs['backend'], AzureADTenantOAuth2) or not group_id: + return + + response = requests.post( + url='https://graph.microsoft.com/v1.0/me/checkMemberGroups', + headers={ + 'Authorization': 'Bearer {}'.format(kwargs['response']['access_token']), + }, + json={ + 'groupIds': [group_id] + } + ) + response.raise_for_status() + response = response.json() + + is_superuser = group_id in response['value'] + + if user.is_superuser != is_superuser: + user.is_superuser = is_superuser + user.save() diff --git a/app/server/tests/cassettes/TestAzureADTenantSocialAuth.test_fetch_permissions_is_admin.yaml b/app/server/tests/cassettes/TestAzureADTenantSocialAuth.test_fetch_permissions_is_admin.yaml new file mode 100644 index 00000000..a9c7989c --- /dev/null +++ b/app/server/tests/cassettes/TestAzureADTenantSocialAuth.test_fetch_permissions_is_admin.yaml @@ -0,0 +1,52 @@ +interactions: +- request: + body: '{"groupIds": ["dddddddd-dddd-dddd-dddd-dddddddddddd"]}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '54' + Content-Type: + - application/json + User-Agent: + - python-requests/2.21.0 + method: POST + uri: https://graph.microsoft.com/v1.0/me/checkMemberGroups + response: + body: + string: '{"@odata.context":"https://graph.microsoft.com/v1.0/$metadata#Collection(Edm.String)","value":["dddddddd-dddd-dddd-dddd-dddddddddddd"]}' + headers: + Cache-Control: + - private + Content-Type: + - application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8 + Date: + - Mon, 20 May 2019 19:34:27 GMT + Duration: + - '89.2611' + Location: + - https://graph.microsoft.com + OData-Version: + - '4.0' + Strict-Transport-Security: + - max-age=31536000 + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + client-request-id: + - 78c55087-ba07-4e80-8498-8b23b1901356 + content-length: + - '135' + request-id: + - 78c55087-ba07-4e80-8498-8b23b1901356 + x-ms-ags-diagnostic: + - '{"ServerInfo":{"DataCenter":"East US","Slice":"SliceC","Ring":"2","ScaleUnit":"000","RoleInstance":"AGSFE_IN_51","ADSiteName":"EUS"}}' + status: + code: 200 + message: OK +version: 1 diff --git a/app/server/tests/cassettes/TestAzureADTenantSocialAuth.test_fetch_permissions_not_admin.yaml b/app/server/tests/cassettes/TestAzureADTenantSocialAuth.test_fetch_permissions_not_admin.yaml new file mode 100644 index 00000000..7d2cba35 --- /dev/null +++ b/app/server/tests/cassettes/TestAzureADTenantSocialAuth.test_fetch_permissions_not_admin.yaml @@ -0,0 +1,52 @@ +interactions: +- request: + body: '{"groupIds": ["eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"]}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '54' + Content-Type: + - application/json + User-Agent: + - python-requests/2.21.0 + method: POST + uri: https://graph.microsoft.com/v1.0/me/checkMemberGroups + response: + body: + string: '{"@odata.context":"https://graph.microsoft.com/v1.0/$metadata#Collection(Edm.String)","value":[]}' + headers: + Cache-Control: + - private + Content-Type: + - application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8 + Date: + - Mon, 20 May 2019 19:34:52 GMT + Duration: + - '84.3986' + Location: + - https://graph.microsoft.com + OData-Version: + - '4.0' + Strict-Transport-Security: + - max-age=31536000 + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + client-request-id: + - 69bf4728-ae4e-47ef-afd0-7d2c31129b83 + content-length: + - '97' + request-id: + - 69bf4728-ae4e-47ef-afd0-7d2c31129b83 + x-ms-ags-diagnostic: + - '{"ServerInfo":{"DataCenter":"East US","Slice":"SliceC","Ring":"2","ScaleUnit":"000","RoleInstance":"AGSFE_IN_5","ADSiteName":"EUS"}}' + status: + code: 200 + message: OK +version: 1 diff --git a/app/server/tests/cassettes/TestGithubSocialAuth.test_fetch_permissions_is_admin.yaml b/app/server/tests/cassettes/TestGithubSocialAuth.test_fetch_permissions_is_admin.yaml new file mode 100644 index 00000000..1b3c3694 --- /dev/null +++ b/app/server/tests/cassettes/TestGithubSocialAuth.test_fetch_permissions_is_admin.yaml @@ -0,0 +1,76 @@ +interactions: +- request: + body: '{"query": "\n query($userName: String!, $orgName: String!, + $teamName: String!) {\n organization(login: $orgName) {\n teams(query: + $teamName, userLogins: [$userName], first: 1) {\n nodes + {\n name\n }\n }\n }\n }\n ", + "variables": {"userName": "c-w", "orgName": "CatalystCode", "teamName": "doccano-dev"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '513' + Content-Type: + - application/json + User-Agent: + - python-requests/2.21.0 + method: POST + uri: https://api.github.com/graphql + response: + body: + string: '{"data":{"organization":{"teams":{"nodes":[{"name":"doccano-dev"}]}}}}' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, + X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, + X-GitHub-Media-Type + Cache-Control: + - no-cache + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json; charset=utf-8 + Date: + - Mon, 20 May 2019 17:38:20 GMT + Referrer-Policy: + - origin-when-cross-origin, strict-origin-when-cross-origin + Server: + - GitHub.com + Status: + - 200 OK + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + Transfer-Encoding: + - chunked + X-Accepted-OAuth-Scopes: + - repo + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - deny + X-GitHub-Media-Type: + - github.v4; format=json + X-GitHub-Request-Id: + - E979:03BD:225D930:49FB694:5CE2E60C + X-OAuth-Scopes: + - read:org + X-RateLimit-Limit: + - '5000' + X-RateLimit-Remaining: + - '4955' + X-RateLimit-Reset: + - '1558377500' + X-XSS-Protection: + - 1; mode=block + content-length: + - '70' + status: + code: 200 + message: OK +version: 1 diff --git a/app/server/tests/cassettes/TestGithubSocialAuth.test_fetch_permissions_not_admin.yaml b/app/server/tests/cassettes/TestGithubSocialAuth.test_fetch_permissions_not_admin.yaml new file mode 100644 index 00000000..5f9c7565 --- /dev/null +++ b/app/server/tests/cassettes/TestGithubSocialAuth.test_fetch_permissions_not_admin.yaml @@ -0,0 +1,77 @@ +interactions: +- request: + body: '{"query": "\n query($userName: String!, $orgName: String!, + $teamName: String!) {\n organization(login: $orgName) {\n teams(query: + $teamName, userLogins: [$userName], first: 1) {\n nodes + {\n name\n }\n }\n }\n }\n ", + "variables": {"userName": "hirosan", "orgName": "CatalystCode", "teamName": + "doccano-dev"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '517' + Content-Type: + - application/json + User-Agent: + - python-requests/2.21.0 + method: POST + uri: https://api.github.com/graphql + response: + body: + string: '{"data":{"organization":{"teams":{"nodes":[]}}}}' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, + X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, + X-GitHub-Media-Type + Cache-Control: + - no-cache + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json; charset=utf-8 + Date: + - Mon, 20 May 2019 17:38:20 GMT + Referrer-Policy: + - origin-when-cross-origin, strict-origin-when-cross-origin + Server: + - GitHub.com + Status: + - 200 OK + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + Transfer-Encoding: + - chunked + X-Accepted-OAuth-Scopes: + - repo + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - deny + X-GitHub-Media-Type: + - github.v4; format=json + X-GitHub-Request-Id: + - E97B:0EFE:220AB47:4963FE2:5CE2E60C + X-OAuth-Scopes: + - read:org + X-RateLimit-Limit: + - '5000' + X-RateLimit-Remaining: + - '4954' + X-RateLimit-Reset: + - '1558377500' + X-XSS-Protection: + - 1; mode=block + content-length: + - '48' + status: + code: 200 + message: OK +version: 1 diff --git a/app/server/tests/test_social_auth.py b/app/server/tests/test_social_auth.py new file mode 100644 index 00000000..e8eca0cd --- /dev/null +++ b/app/server/tests/test_social_auth.py @@ -0,0 +1,95 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from social_core.backends.azuread_tenant import AzureADTenantOAuth2 +from social_core.backends.github import GithubOAuth2 +from vcr_unittest import VCRMixin + +from .. import social_auth + +User = get_user_model() + + +class VCRTestCase(VCRMixin, TestCase): + @property + def access_token(self): + raise NotImplementedError() + + def _get_vcr(self, **kwargs): + kwargs['decode_compressed_response'] = True + kwargs['record_mode'] = 'none' if self.access_token == 'censored' else 'all' + return super()._get_vcr(**kwargs) + + def _get_vcr_kwargs(self, **kwargs): + kwargs['filter_headers'] = ['Authorization'] + return super()._get_vcr_kwargs(**kwargs) + + +@override_settings(GITHUB_ADMIN_ORG_NAME='CatalystCode') +@override_settings(GITHUB_ADMIN_TEAM_NAME='doccano-dev') +class TestGithubSocialAuth(VCRTestCase): + strategy = None + backend = GithubOAuth2(strategy=strategy) + access_token = 'censored' + + def test_fetch_permissions_is_admin(self): + user = User() + + social_auth.fetch_github_permissions( + strategy=self.strategy, + details={'username': 'c-w'}, + user=user, + backend=self.backend, + response={'access_token': self.access_token}, + ) + + self.assertTrue(user.is_superuser) + + def test_fetch_permissions_not_admin(self): + user = User() + + social_auth.fetch_github_permissions( + strategy=self.strategy, + details={'username': 'hirosan'}, + user=user, + backend=self.backend, + response={'access_token': self.access_token}, + ) + + self.assertFalse(user.is_superuser) + + +@override_settings(SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY='aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') +@override_settings(SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=') +@override_settings(SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT='cccccccc-cccc-cccc-cccc-cccccccccccc') +class TestAzureADTenantSocialAuth(VCRTestCase): + strategy = None + backend = AzureADTenantOAuth2(strategy=strategy) + access_token = 'censored' + + @override_settings(AZUREAD_ADMIN_GROUP_ID='dddddddd-dddd-dddd-dddd-dddddddddddd') + def test_fetch_permissions_is_admin(self): + user = User() + + social_auth.fetch_azuread_permissions( + strategy=self.strategy, + details={}, + user=user, + backend=self.backend, + response={'access_token': self.access_token}, + ) + + self.assertTrue(user.is_superuser) + + @override_settings(AZUREAD_ADMIN_GROUP_ID='eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee') + def test_fetch_permissions_not_admin(self): + user = User() + + social_auth.fetch_azuread_permissions( + strategy=self.strategy, + details={}, + user=user, + backend=self.backend, + response={'access_token': self.access_token}, + ) + + self.assertFalse(user.is_superuser) diff --git a/requirements.txt b/requirements.txt index 85240443..65134b3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ model-mommy==1.6.0 psycopg2-binary==2.7.7 python-dateutil==2.7.3 pytz==2018.4 +requests==2.21.0 six==1.11.0 seqeval==0.0.6 social-auth-app-django==3.1.0 @@ -29,4 +30,6 @@ social-auth-core[azuread]==3.0.0 text-unidecode==1.2 tornado==5.0.2 unittest-xml-reporting==2.5.1 +vcrpy==2.0.1 +vcrpy-unittest==0.1.7 whitenoise[brotli]==4.1.2