diff --git a/framework/auth/cas.py b/framework/auth/cas.py index 939a5e59821..183eddf3478 100644 --- a/framework/auth/cas.py +++ b/framework/auth/cas.py @@ -10,6 +10,7 @@ from framework.auth import authenticate, external_first_login_authenticate from framework.auth.core import get_user, generate_verification_key from framework.auth.utils import print_cas_log, LogLevel +from framework.celery_tasks.handlers import enqueue_task from framework.flask import redirect from framework.exceptions import HTTPError from website import settings @@ -376,6 +377,9 @@ def get_user_from_cas_resp(cas_resp): external_id=external_credential['id']) # existing user found if user: + # Send to celery the following async task to affiliate the user with eligible institutions if verified + from framework.auth.tasks import update_affiliation_for_orcid_sso_users + enqueue_task(update_affiliation_for_orcid_sso_users.s(user._id, external_credential['id'])) return user, external_credential, 'authenticate' # user first time login through external identity provider else: diff --git a/framework/auth/tasks.py b/framework/auth/tasks.py index 926b212b8d8..56faf3be871 100644 --- a/framework/auth/tasks.py +++ b/framework/auth/tasks.py @@ -1,11 +1,23 @@ from datetime import datetime +import itertools +import logging + +from lxml import etree import pytz +import requests + +from framework import sentry +from framework.celery_tasks import app as celery_app +from website.settings import (DATE_LAST_LOGIN_THROTTLE_DELTA, EXTERNAL_IDENTITY_PROFILE, + ORCID_PUBLIC_API_V3_URL, ORCID_PUBLIC_API_ACCESS_TOKEN, + ORCID_PUBLIC_API_REQUEST_TIMEOUT, ORCID_RECORD_ACCEPT_TYPE, + ORCID_RECORD_EDUCATION_PATH, ORCID_RECORD_EMPLOYMENT_PATH) + -from framework.celery_tasks import app -from website.settings import DATE_LAST_LOGIN_THROTTLE_DELTA +logger = logging.getLogger(__name__) -@app.task +@celery_app.task() def update_user_from_activity(user_id, login_time, cas_login=False, updates=None): from osf.models import OSFUser if not updates: @@ -27,3 +39,122 @@ def update_user_from_activity(user_id, login_time, cas_login=False, updates=None should_save = True if should_save: user.save() + + +@celery_app.task() +def update_affiliation_for_orcid_sso_users(user_id, orcid_id): + """This is an asynchronous task that runs during CONFIRMED ORCiD SSO logins and makes eligible + institution affiliations. + """ + from osf.models import OSFUser + user = OSFUser.load(user_id) + if not user or not verify_user_orcid_id(user, orcid_id): + # This should not happen as long as this task is called at the right place at the right time. + error_message = f'Invalid ORCiD ID [{orcid_id}] for [{user_id}]' if user else f'User [{user_id}] Not Found' + logger.error(error_message) + sentry.log_message(error_message) + return + institution = check_institution_affiliation(orcid_id) + if institution: + logger.info(f'Eligible institution affiliation has been found for ORCiD SSO user: ' + f'institution=[{institution._id}], user=[{user_id}], orcid_id=[{orcid_id}]') + if not user.is_affiliated_with_institution(institution): + user.affiliated_institutions.add(institution) + + +def verify_user_orcid_id(user, orcid_id): + """Verify that the given ORCiD ID is verified for the given user. + """ + provider = EXTERNAL_IDENTITY_PROFILE.get('OrcidProfile') + status = user.external_identity.get(provider, {}).get(orcid_id, None) + return status == 'VERIFIED' + + +def check_institution_affiliation(orcid_id): + """Check user's public ORCiD record and return eligible institution affiliations. + + Note: Current implementation only support one affiliation (i.e. loop returns once eligible + affiliation is found, which improves performance). In the future, if we have multiple + institutions using this feature, we can update the loop easily. + """ + from osf.models import Institution + from osf.models.institution import IntegrationType + employment_source_list = get_orcid_employment_sources(orcid_id) + education_source_list = get_orcid_education_sources(orcid_id) + via_orcid_institutions = Institution.objects.filter( + delegation_protocol=IntegrationType.AFFILIATION_VIA_ORCID.value, + is_deleted=False + ) + # Check both employment and education records + for source in itertools.chain(employment_source_list, education_source_list): + # Check source against all "affiliation-via-orcid" institutions + for institution in via_orcid_institutions: + if source == institution.orcid_record_verified_source: + logger.debug(f'Institution has been found with matching source: ' + f'institution=[{institution._id}], source=[{source}], orcid_id=[{orcid_id}]') + return institution + logger.debug(f'No institution with matching source has been found: orcid_id=[{orcid_id}]') + return None + + +def get_orcid_employment_sources(orcid_id): + """Retrieve employment records for the given ORCiD ID. + """ + employment_data = orcid_public_api_make_request(ORCID_RECORD_EMPLOYMENT_PATH, orcid_id) + source_list = [] + if employment_data is not None: + affiliation_groups = employment_data.findall('{http://www.orcid.org/ns/activities}affiliation-group') + for affiliation_group in affiliation_groups: + employment_summary = affiliation_group.find('{http://www.orcid.org/ns/employment}employment-summary') + source = employment_summary.find('{http://www.orcid.org/ns/common}source') + source_name = source.find('{http://www.orcid.org/ns/common}source-name') + source_list.append(source_name.text) + return source_list + + +def get_orcid_education_sources(orcid_id): + """Retrieve education records for the given ORCiD ID. + """ + education_data = orcid_public_api_make_request(ORCID_RECORD_EDUCATION_PATH, orcid_id) + source_list = [] + if education_data is not None: + affiliation_groups = education_data.findall('{http://www.orcid.org/ns/activities}affiliation-group') + for affiliation_group in affiliation_groups: + education_summary = affiliation_group.find('{http://www.orcid.org/ns/education}education-summary') + source = education_summary.find('{http://www.orcid.org/ns/common}source') + source_name = source.find('{http://www.orcid.org/ns/common}source-name') + source_list.append(source_name.text) + return source_list + + +def orcid_public_api_make_request(path, orcid_id): + """Make the ORCiD public API request and returned a deserialized response. + """ + request_url = ORCID_PUBLIC_API_V3_URL + orcid_id + path + headers = { + 'Accept': ORCID_RECORD_ACCEPT_TYPE, + 'Authorization': f'Bearer {ORCID_PUBLIC_API_ACCESS_TOKEN}', + } + try: + response = requests.get(request_url, headers=headers, timeout=ORCID_PUBLIC_API_REQUEST_TIMEOUT) + except Exception: + error_message = f'ORCiD public API request has encountered an exception: url=[{request_url}]' + logger.error(error_message) + sentry.log_message(error_message) + sentry.log_exception() + return None + if response.status_code != 200: + error_message = f'ORCiD public API request has failed: url=[{request_url}], ' \ + f'status=[{response.status_code}], response = [{response.content}]' + logger.error(error_message) + sentry.log_message(error_message) + return None + try: + xml_data = etree.XML(response.content) + except Exception: + error_message = 'Fail to read and parse ORCiD record response as XML' + logger.error(error_message) + sentry.log_message(error_message) + sentry.log_exception() + return None + return xml_data diff --git a/framework/auth/views.py b/framework/auth/views.py index 0863c702fc1..fab98ec293c 100644 --- a/framework/auth/views.py +++ b/framework/auth/views.py @@ -20,6 +20,7 @@ from framework.auth.decorators import block_bing_preview, collect_auth, must_be_logged_in from framework.auth.forms import ResendConfirmationForm, ForgotPasswordForm, ResetPasswordForm from framework.auth.utils import ensure_external_identity_uniqueness, validate_recaptcha +from framework.celery_tasks.handlers import enqueue_task from framework.exceptions import HTTPError from framework.flask import redirect # VOL-aware redirect from framework.sessions.utils import remove_sessions_for_user, remove_session @@ -672,6 +673,10 @@ def external_login_confirm_email_get(auth, uid, token): can_change_preferences=False, ) + # Send to celery the following async task to affiliate the user with eligible institutions if verified + from framework.auth.tasks import update_affiliation_for_orcid_sso_users + enqueue_task(update_affiliation_for_orcid_sso_users.s(user._id, provider_id)) + # redirect to CAS and authenticate the user with the verification key return redirect(cas.get_login_url( service_url, diff --git a/osf/migrations/0245_auto_20220621_1950.py b/osf/migrations/0245_auto_20220621_1950.py new file mode 100644 index 00000000000..0cfc0b6d7da --- /dev/null +++ b/osf/migrations/0245_auto_20220621_1950.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2022-06-21 19:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0244_auto_20220517_1718'), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='orcid_record_verified_source', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AlterField( + model_name='institution', + name='delegation_protocol', + field=models.CharField(blank=True, choices=[('saml-shib', 'SAML_SHIBBOLETH'), ('cas-pac4j', 'CAS_PAC4J'), ('oauth-pac4j', 'OAUTH_PAC4J'), ('via-orcid', 'AFFILIATION_VIA_ORCID'), ('', 'NONE')], default='', max_length=15), + ), + ] diff --git a/osf/models/institution.py b/osf/models/institution.py index 0b8ff0d4c68..eb5b0fb1b29 100644 --- a/osf/models/institution.py +++ b/osf/models/institution.py @@ -1,5 +1,6 @@ -import logging +from enum import Enum from future.moves.urllib.parse import urljoin +import logging from dirtyfields import DirtyFieldsMixin @@ -22,6 +23,17 @@ logger = logging.getLogger(__name__) +class IntegrationType(Enum): + """Defines 5 SSO types for OSF institution integration. + """ + + SAML_SHIBBOLETH = 'saml-shib' # SSO via SAML (Shibboleth impl) where CAS serves as the SP and institutions as IdP + CAS_PAC4J = 'cas-pac4j' # SSO via CAS (pac4j impl) where CAS serves as the client and institution as server + OAUTH_PAC4J = 'oauth-pac4j' # SSO via OAuth (pac4j impl) where CAS serves as the client and institution as server + AFFILIATION_VIA_ORCID = 'via-orcid' # Using ORCiD SSO for sign in; using ORCiD public API for affiliation + NONE = '' # Institution affiliation is done via email domain whitelist w/o SSO + + class InstitutionManager(models.Manager): def get_queryset(self): @@ -51,18 +63,16 @@ class Institution(DirtyFieldsMixin, Loggable, base.ObjectIDMixin, base.BaseModel banner_name = models.CharField(max_length=255, blank=True, null=True) logo_name = models.CharField(max_length=255, blank=True, null=True) - # The protocol which is used to delegate authentication. - # Currently, we have `CAS`, `SAML`, `OAuth` available. - # For `SAML`, we use Shibboleth. - # For `CAS` and `OAuth`, we use pac4j. - # Only institutions with a valid delegation protocol show up on the institution login page. - DELEGATION_PROTOCOL_CHOICES = ( - ('cas-pac4j', 'CAS by pac4j'), - ('oauth-pac4j', 'OAuth by pac4j'), - ('saml-shib', 'SAML by Shibboleth'), - ('', 'No Delegation Protocol'), + # Institution integration type + delegation_protocol = models.CharField( + choices=[(type.value, type.name) for type in IntegrationType], + max_length=15, + blank=True, + default='' ) - delegation_protocol = models.CharField(max_length=15, choices=DELEGATION_PROTOCOL_CHOICES, blank=True, default='') + + # Verified employment/education affiliation source for `via-orcid` institutions + orcid_record_verified_source = models.CharField(max_length=255, blank=True, default='') # login_url and logout_url can be null or empty login_url = models.URLField(null=True, blank=True) diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 2d4746e469b..e5fdd2241a3 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -253,6 +253,8 @@ class InstitutionFactory(DjangoModelFactory): domains = FakeList('url', n=3) email_domains = FakeList('domain_name', n=1) logo_name = factory.Faker('file_name') + orcid_record_verified_source = '' + delegation_protocol = '' class Meta: model = models.Institution diff --git a/scripts/populate_institutions.py b/scripts/populate_institutions.py index d6c4605aef5..f57cf651ad9 100644 --- a/scripts/populate_institutions.py +++ b/scripts/populate_institutions.py @@ -649,6 +649,19 @@ def main(default_args=False): 'email_domains': [], 'delegation_protocol': 'saml-shib', }, + { + '_id': 'oxford', + 'name': 'University of Oxford', + 'description': '', + 'banner_name': 'oxford-banner.png', + 'logo_name': 'oxford-shield.png', + 'login_url': None, + 'logout_url': None, + 'domains': [], + 'email_domains': [], + 'delegation_protocol': 'via-orcid', + 'orcid_record_verified_source': 'ORCID Integration at the University of Oxford', + }, { '_id': 'pu', 'name': 'Princeton University', @@ -1084,6 +1097,33 @@ def main(default_args=False): 'email_domains': ['yahoo.com'], 'delegation_protocol': '', }, + { + '_id': 'oxford', + 'name': 'University of Oxford [Stage]', + 'description': 'Here is the place to put in links to other resources, security and data policies, research guidelines, and/or a contact for user support within your institution.', + 'banner_name': 'placeholder-banner.png', + 'logo_name': 'placeholder-shield.png', + 'login_url': None, + 'logout_url': None, + 'domains': [], + 'email_domains': [], + 'delegation_protocol': 'via-orcid', + 'orcid_record_verified_source': 'ORCID Integration at the University of Oxford', + }, + { + '_id': 'osftype1', + 'name': 'Fake "via-ORCiD" Institution [Stage]', + 'description': 'Fake OSF Institution Type 1. This institution uses ORCiD SSO for login and its user ' + 'affiliation is retrieved from ORCiD public record.', + 'banner_name': 'placeholder-banner.png', + 'logo_name': 'placeholder-shield.png', + 'login_url': None, + 'logout_url': None, + 'domains': [], + 'email_domains': [], + 'delegation_protocol': 'via-orcid', + 'orcid_record_verified_source': 'OSF Integration', + }, ], 'stage2': [ { @@ -1694,6 +1734,19 @@ def main(default_args=False): 'email_domains': [], 'delegation_protocol': 'saml-shib', }, + { + '_id': 'oxford', + 'name': 'University of Oxford [Test]', + 'description': '', + 'banner_name': 'oxford-banner.png', + 'logo_name': 'oxford-shield.png', + 'login_url': None, + 'logout_url': None, + 'domains': [], + 'email_domains': [], + 'delegation_protocol': 'via-orcid', + 'orcid_record_verified_source': 'ORCID Integration at the University of Oxford', + }, { '_id': 'pu', 'name': 'Princeton University [Test]', @@ -2097,76 +2150,116 @@ def main(default_args=False): ], 'local': [ { - '_id': 'fake-saml-type-0', - 'name': 'Fake SAML-auth Institution Type-0', - 'description': 'A fake SAML-auth institution with no special features', + '_id': 'osftype0', + 'name': 'Fake CAS Institution', + 'description': 'Fake OSF Institution Type 0. Its SSO is done via CAS (pac4j impl) where OSF-CAS serves as ' + 'the CAS client and the institution as the CAS server.', + 'banner_name': 'placeholder-banner.png', + 'logo_name': 'placeholder-shield.png', + 'login_url': None, + 'logout_url': None, + 'domains': [], + 'email_domains': [], + 'delegation_protocol': 'cas-pac4j', + }, + { + '_id': 'osftype1', + 'name': 'Fake "via-ORCiD" Institution', + 'description': 'Fake OSF Institution Type 1. This institution uses ORCiD SSO for login and its user ' + 'affiliation is retrieved from ORCiD public record.', 'banner_name': 'placeholder-banner.png', 'logo_name': 'placeholder-shield.png', - 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('fake-saml-idp-type-0-default')), + 'login_url': None, + 'logout_url': None, + 'domains': [], + 'email_domains': [], + 'delegation_protocol': 'via-orcid', + 'orcid_record_verified_source': 'OSF Integration', + }, + { + '_id': 'osftype2', + 'name': 'Fake SAML Institution - Standard', + 'description': 'Fake OSF Institution Type 2. Its SSO is done via SAML (Shibboleth impl) where OSF-CAS ' + 'serves as the SP and the institution as the IdP.', + 'banner_name': 'placeholder-banner.png', + 'logo_name': 'placeholder-shield.png', + 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('type-2-fake-saml-idp')), 'logout_url': SHIBBOLETH_SP_LOGOUT.format(encode_uri_component('http://localhost:5000/goodbye')), 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'orcid_record_verified_source': '', }, { - '_id': 'fake-saml-type-1', - 'name': 'Fake SAML-auth Institution Type-1', - 'description': 'A fake SAML-auth institution that has shared SSO enabled', + '_id': 'osftype3', + 'name': 'Fake SAML Institution - Shared SSO Primary', + 'description': 'Fake OSF Institution Type 3. Its SSO is done via SAML (Shibboleth impl) where OSF-CAS ' + 'serves as the SP and the institution as the IdP. This institution is a primary one that ' + 'provides shared SSO to secondary institutions.', 'banner_name': 'placeholder-banner.png', 'logo_name': 'placeholder-shield.png', - 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('fake-saml-idp-type-1-shared-sso')), + 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('type-3-fake-saml-idp')), 'logout_url': SHIBBOLETH_SP_LOGOUT.format(encode_uri_component('http://localhost:5000/goodbye')), 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', }, { - '_id': 'fake-saml-type-2', - 'name': 'Fake SAML-auth Institution Type-2', - 'description': 'A fake SAML-auth institution that has selective SSO enabled', + '_id': 'osftype4', + 'name': 'Fake SAML Institution - Shared SSO Secondary', + 'description': 'Fake OSF Institution Type 3. Its SSO is done via SAML (Shibboleth impl) where OSF-CAS ' + 'serves as the SP and the institution as the IdP. This institution is a secondary one that ' + 'uses a primary institution\'s SSO.', 'banner_name': 'placeholder-banner.png', 'logo_name': 'placeholder-shield.png', - 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('fake-saml-idp-type-2-selective-sso')), + 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('type-4-fake-saml-idp')), 'logout_url': SHIBBOLETH_SP_LOGOUT.format(encode_uri_component('http://localhost:5000/goodbye')), 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', }, { - '_id': 'fake-saml-type-3', - 'name': 'Fake SAML-auth Institution Type-3', - 'description': 'A fake SAML-auth institution that uses eduPersonPrimaryOrgUnitDN for department', + '_id': 'osftype5', + 'name': 'Fake SAML Institution - Selective SSO', + 'description': 'Fake OSF Institution Type 3. Its SSO is done via SAML (Shibboleth impl) where OSF-CAS ' + 'serves as the SP and the institution as the IdP. This institution only allows a subset of ' + 'users to use SSO by releasing a special attribute for them.', 'banner_name': 'placeholder-banner.png', 'logo_name': 'placeholder-shield.png', - 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('fake-saml-idp-type-3-department-eduperson')), + 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('type-5-fake-saml-idp')), 'logout_url': SHIBBOLETH_SP_LOGOUT.format(encode_uri_component('http://localhost:5000/goodbye')), 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', + 'orcid_record_verified_source': '', }, { - '_id': 'fake-saml-type-4', - 'name': 'Fake SAML-auth Institution Type-4', - 'description': 'A fake SAML-auth institution that uses a non-eduPerson attribute for department', + '_id': 'osftype6', + 'name': 'Fake SAML Institution - Department I', + 'description': 'Fake OSF Institution Type 3. Its SSO is done via SAML (Shibboleth impl) where OSF-CAS ' + 'serves as the SP and the institution as the IdP. This institution provides the department ' + 'attribute via an eduPerson attribute.', 'banner_name': 'placeholder-banner.png', 'logo_name': 'placeholder-shield.png', - 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('fake-saml-idp-type-4-department-customized')), + 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('type-6-fake-saml-idp')), 'logout_url': SHIBBOLETH_SP_LOGOUT.format(encode_uri_component('http://localhost:5000/goodbye')), 'domains': [], 'email_domains': [], 'delegation_protocol': 'saml-shib', }, { - '_id': 'fake-cas-type-0', - 'name': 'Fake CAS-auth Institution Type-0', - 'description': 'A fake CAS-auth institution with no special features', + '_id': 'osftype7', + 'name': 'Fake SAML Institution - Department II', + 'description': 'Fake OSF Institution Type 3. Its SSO is done via SAML (Shibboleth impl) where OSF-CAS ' + 'serves as the SP and the institution as the IdP. This institution provides the department ' + 'attribute via a customized attribute.', 'banner_name': 'placeholder-banner.png', 'logo_name': 'placeholder-shield.png', - 'login_url': None, - 'logout_url': None, + 'login_url': SHIBBOLETH_SP_LOGIN.format(encode_uri_component('type-7-fake-saml-idp')), + 'logout_url': SHIBBOLETH_SP_LOGOUT.format(encode_uri_component('http://localhost:5000/goodbye')), 'domains': [], 'email_domains': [], - 'delegation_protocol': 'cas-pac4j', + 'delegation_protocol': 'saml-shib', }, ], } diff --git a/tests/test_institutions/__init__.py b/tests/test_institutions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_institutions/education_affiliations.xml b/tests/test_institutions/education_affiliations.xml new file mode 100644 index 00000000000..042a87b5bb7 --- /dev/null +++ b/tests/test_institutions/education_affiliations.xml @@ -0,0 +1,80 @@ + + + 2021-04-30T01:57:42.246Z + + 2021-04-30T01:57:42.246Z + + + 2021-04-30T01:57:42.246Z + 2021-04-30T01:57:42.246Z + + + https://orcid.org/client/6666-7777-8888-9999 + 6666-7777-8888-9999 + orcid.org + + ORCID Integration at a Verified Institution + + A Verified Department + A Verified Title + + 2007 + 09 + 01 + + + 2011 + 6 + 30 + + + A Verified Institution + + A Verified City + A Verified State + A Verified Country + + + 1234567890 + RINGGOLD + + + + + + 2021-04-30T01:57:42.246Z + + + 2021-04-30T01:57:42.246Z + 2021-04-30T01:57:42.246Z + + + https://orcid.org/1111-2222-3333-4444 + 1111-2222-3333-4444 + orcid.org + + An ORCiD User + + A Department + A Title + + 2011 + 09 + 01 + + + 2013 + 6 + 30 + + + An Institution + + A City + A State + A Country + + + + + diff --git a/tests/test_institutions/employment_affiliations.xml b/tests/test_institutions/employment_affiliations.xml new file mode 100644 index 00000000000..05ac0a5117f --- /dev/null +++ b/tests/test_institutions/employment_affiliations.xml @@ -0,0 +1,63 @@ + + + 2015-04-08T11:12:05.195Z + + 2015-04-08T11:12:05.195Z + + + 2015-04-08T11:12:05.195Z + 2015-04-08T11:12:05.195Z + + + https://orcid.org/client/6666-7777-8888-9999 + 6666-7777-8888-9999 + orcid.org + + ORCID Integration at a Verified Institution + + A Verified Department + + A Verified Institution + + A Verified City + A Verified State + A Verified Country + + + 1234567890 + RINGGOLD + + + + + + 2021-04-30T01:57:49.345Z + + + 2021-04-30T01:54:30.883Z + 2021-04-30T01:57:49.345Z + + + https://orcid.org/1111-2222-3333-4444 + 1111-2222-3333-4444 + orcid.org + + An ORCiD User + + A Department + + 2015 + 11 + 16 + + + An Institution + + A City + A State + A Country + + + + + diff --git a/tests/test_institutions/test_affiliation_via_orcid.py b/tests/test_institutions/test_affiliation_via_orcid.py new file mode 100644 index 00000000000..71189a3bbcd --- /dev/null +++ b/tests/test_institutions/test_affiliation_via_orcid.py @@ -0,0 +1,348 @@ +import mock +import os +import pytest + +from lxml import etree +from requests.models import Response + +from framework.auth import tasks +from osf.models.institution import IntegrationType +from osf_tests.factories import UserFactory, InstitutionFactory +from tests.base import fake +from website.settings import ORCID_RECORD_EDUCATION_PATH, ORCID_RECORD_EMPLOYMENT_PATH + + +@pytest.mark.django_db +class TestInstitutionAffiliationViaOrcidSso: + + @pytest.fixture() + def response_content_educations(self): + with open(os.path.join(os.path.dirname(__file__), 'education_affiliations.xml'), 'rb') as fp: + return fp.read() + + @pytest.fixture() + def response_content_employments(self): + with open(os.path.join(os.path.dirname(__file__), 'employment_affiliations.xml'), 'rb') as fp: + return fp.read() + + @pytest.fixture() + def xml_data_educations(self, response_content_educations): + return etree.XML(response_content_educations) + + @pytest.fixture() + def xml_data_employments(self, response_content_employments): + return etree.XML(response_content_employments) + + @pytest.fixture() + def orcid_id_verified(self): + return '1111-2222-3333-4444' + + @pytest.fixture() + def orcid_id_link(self): + return fake.ean() + + @pytest.fixture() + def orcid_id_create(self): + return fake.ean() + + @pytest.fixture() + def orcid_id_random(self): + return fake.ean() + + @pytest.fixture() + def user_with_orcid_id_verified(self, orcid_id_verified): + return UserFactory(external_identity={'ORCID': {orcid_id_verified: 'VERIFIED'}}) + + @pytest.fixture() + def user_with_orcid_id_link(self, orcid_id_link): + return UserFactory(external_identity={'ORCID': {orcid_id_link: 'LINK'}}) + + @pytest.fixture() + def user_with_orcid_id_create(self, orcid_id_create): + return UserFactory(external_identity={'ORCID': {orcid_id_create: 'CREATE'}}) + + @pytest.fixture() + def user_without_orcid_id(self): + return UserFactory() + + @pytest.fixture() + def eligible_institution(self): + institution = InstitutionFactory() + institution.delegation_protocol = IntegrationType.AFFILIATION_VIA_ORCID.value + institution.orcid_record_verified_source = 'ORCID Integration at a Verified Institution' + institution.save() + return institution + + @pytest.fixture() + def another_eligible_institution(self): + institution = InstitutionFactory() + institution.delegation_protocol = IntegrationType.AFFILIATION_VIA_ORCID.value + institution.orcid_record_verified_source = 'ORCID Integration for another Verified Institution' + institution.save() + return institution + + @pytest.fixture() + def user_verified_and_affiliated(self, orcid_id_verified, eligible_institution): + user = UserFactory(external_identity={'ORCID': {orcid_id_verified: 'VERIFIED'}}) + user.affiliated_institutions.add(eligible_institution) + return user + + @mock.patch('framework.auth.tasks.check_institution_affiliation') + @mock.patch('framework.auth.tasks.verify_user_orcid_id') + def test_update_affiliation_for_orcid_sso_users_new_affiliation( + self, + mock_verify_user_orcid_id, + mock_check_institution_affiliation, + user_with_orcid_id_verified, + orcid_id_verified, + eligible_institution, + ): + mock_verify_user_orcid_id.return_value = True + mock_check_institution_affiliation.return_value = eligible_institution + assert eligible_institution not in user_with_orcid_id_verified.affiliated_institutions.all() + tasks.update_affiliation_for_orcid_sso_users(user_with_orcid_id_verified._id, orcid_id_verified) + assert eligible_institution in user_with_orcid_id_verified.affiliated_institutions.all() + + @mock.patch('framework.auth.tasks.check_institution_affiliation') + @mock.patch('framework.auth.tasks.verify_user_orcid_id') + def test_update_affiliation_for_orcid_sso_users_existing_affiliation( + self, + mock_verify_user_orcid_id, + mock_check_institution_affiliation, + user_verified_and_affiliated, + orcid_id_verified, + eligible_institution, + ): + mock_verify_user_orcid_id.return_value = True + mock_check_institution_affiliation.return_value = eligible_institution + assert eligible_institution in user_verified_and_affiliated.affiliated_institutions.all() + tasks.update_affiliation_for_orcid_sso_users(user_verified_and_affiliated._id, orcid_id_verified) + assert eligible_institution in user_verified_and_affiliated.affiliated_institutions.all() + + @mock.patch('framework.auth.tasks.check_institution_affiliation') + @mock.patch('framework.auth.tasks.verify_user_orcid_id') + def test_update_affiliation_for_orcid_sso_users_verification_failed( + self, + mock_verify_user_orcid_id, + mock_check_institution_affiliation, + user_with_orcid_id_link, + orcid_id_link, + eligible_institution, + ): + mock_verify_user_orcid_id.return_value = False + tasks.update_affiliation_for_orcid_sso_users(user_with_orcid_id_link._id, orcid_id_link) + mock_check_institution_affiliation.assert_not_called() + assert eligible_institution not in user_with_orcid_id_link.affiliated_institutions.all() + + @mock.patch('framework.auth.tasks.check_institution_affiliation') + @mock.patch('framework.auth.tasks.verify_user_orcid_id') + def test_update_affiliation_for_orcid_sso_users_institution_not_found( + self, + mock_verify_user_orcid_id, + mock_check_institution_affiliation, + user_with_orcid_id_verified, + orcid_id_verified, + eligible_institution, + ): + mock_verify_user_orcid_id.return_value = True + mock_check_institution_affiliation.return_value = None + assert eligible_institution not in user_with_orcid_id_verified.affiliated_institutions.all() + tasks.update_affiliation_for_orcid_sso_users(user_with_orcid_id_verified._id, orcid_id_verified) + assert eligible_institution not in user_with_orcid_id_verified.affiliated_institutions.all() + + def test_verify_user_orcid_id_verified(self, user_with_orcid_id_verified, orcid_id_verified): + assert tasks.verify_user_orcid_id(user_with_orcid_id_verified, orcid_id_verified) + + def test_verify_user_orcid_id_link(self, user_with_orcid_id_link, orcid_id_link): + assert not tasks.verify_user_orcid_id(user_with_orcid_id_link, orcid_id_link) + + def test_verify_user_orcid_id_create(self, user_with_orcid_id_create, orcid_id_create): + assert not tasks.verify_user_orcid_id(user_with_orcid_id_create, orcid_id_create) + + def test_verify_user_orcid_id_none(self, user_without_orcid_id, orcid_id_random): + assert not tasks.verify_user_orcid_id(user_without_orcid_id, orcid_id_random) + + @mock.patch('framework.auth.tasks.get_orcid_employment_sources') + @mock.patch('framework.auth.tasks.get_orcid_education_sources') + def test_check_institution_affiliation_from_employment_sources( + self, + mock_get_orcid_education_sources, + mock_get_orcid_employment_sources, + user_with_orcid_id_verified, + orcid_id_verified, + eligible_institution, + ): + mock_get_orcid_employment_sources.return_value = [ + eligible_institution.orcid_record_verified_source, + user_with_orcid_id_verified.fullname, + ] + mock_get_orcid_education_sources.return_value = [user_with_orcid_id_verified.username, ] + institution = tasks.check_institution_affiliation(orcid_id_verified) + assert institution == eligible_institution + + @mock.patch('framework.auth.tasks.get_orcid_employment_sources') + @mock.patch('framework.auth.tasks.get_orcid_education_sources') + def test_check_institution_affiliation_from_education_sources( + self, + mock_get_orcid_education_sources, + mock_get_orcid_employment_sources, + user_with_orcid_id_verified, + orcid_id_verified, + eligible_institution, + ): + mock_get_orcid_employment_sources.return_value = [user_with_orcid_id_verified.fullname, ] + mock_get_orcid_education_sources.return_value = [ + eligible_institution.orcid_record_verified_source, + user_with_orcid_id_verified.username, + ] + institution = tasks.check_institution_affiliation(orcid_id_verified) + assert institution == eligible_institution + + @mock.patch('framework.auth.tasks.get_orcid_employment_sources') + @mock.patch('framework.auth.tasks.get_orcid_education_sources') + def test_check_institution_affiliation_no_result( + self, + mock_get_orcid_education_sources, + mock_get_orcid_employment_sources, + user_with_orcid_id_verified, + orcid_id_verified, + eligible_institution, + ): + mock_get_orcid_employment_sources.return_value = [user_with_orcid_id_verified.fullname, ] + mock_get_orcid_education_sources.return_value = [user_with_orcid_id_verified.username, ] + institution = tasks.check_institution_affiliation(orcid_id_verified) + assert institution is None + + @mock.patch('framework.auth.tasks.get_orcid_employment_sources') + @mock.patch('framework.auth.tasks.get_orcid_education_sources') + def test_check_institution_affiliation_multiple_results_case_1( + self, + mock_get_orcid_education_sources, + mock_get_orcid_employment_sources, + user_with_orcid_id_verified, + orcid_id_verified, + eligible_institution, + another_eligible_institution, + ): + mock_get_orcid_employment_sources.return_value = [ + another_eligible_institution.orcid_record_verified_source, + user_with_orcid_id_verified.fullname, + ] + mock_get_orcid_education_sources.return_value = [ + eligible_institution.orcid_record_verified_source, + user_with_orcid_id_verified.username, + ] + institution = tasks.check_institution_affiliation(orcid_id_verified) + assert institution == another_eligible_institution + + @mock.patch('framework.auth.tasks.get_orcid_employment_sources') + @mock.patch('framework.auth.tasks.get_orcid_education_sources') + def test_check_institution_affiliation_multiple_results_case_2( + self, + mock_get_orcid_education_sources, + mock_get_orcid_employment_sources, + user_with_orcid_id_verified, + orcid_id_verified, + eligible_institution, + another_eligible_institution, + ): + mock_get_orcid_employment_sources.return_value = [ + eligible_institution.orcid_record_verified_source, + user_with_orcid_id_verified.fullname, + ] + mock_get_orcid_education_sources.return_value = [ + another_eligible_institution.orcid_record_verified_source, + user_with_orcid_id_verified.username, + ] + institution = tasks.check_institution_affiliation(orcid_id_verified) + assert institution == eligible_institution + + @mock.patch('framework.auth.tasks.orcid_public_api_make_request') + def test_get_orcid_employment_sources( + self, + mock_orcid_public_api_make_request, + orcid_id_verified, + eligible_institution, + xml_data_employments, + ): + mock_orcid_public_api_make_request.return_value = xml_data_employments + source_list = tasks.get_orcid_employment_sources(orcid_id_verified) + assert len(source_list) == 2 + assert eligible_institution.orcid_record_verified_source in source_list + assert 'An ORCiD User' in source_list + + @mock.patch('framework.auth.tasks.orcid_public_api_make_request') + def test_get_orcid_education_sources( + self, + mock_orcid_public_api_make_request, + orcid_id_verified, + eligible_institution, + xml_data_educations, + ): + mock_orcid_public_api_make_request.return_value = xml_data_educations + source_list = tasks.get_orcid_education_sources(orcid_id_verified) + assert len(source_list) == 2 + assert eligible_institution.orcid_record_verified_source in source_list + assert 'An ORCiD User' in source_list + + @mock.patch('requests.get') + def test_orcid_public_api_make_request_education_path( + self, + mock_get, + orcid_id_verified, + response_content_educations + ): + mock_response = Response() + mock_response.status_code = 200 + mock_response._content = response_content_educations + mock_get.return_value = mock_response + xml_data = tasks.orcid_public_api_make_request(ORCID_RECORD_EDUCATION_PATH, orcid_id_verified) + assert xml_data is not None + + @mock.patch('requests.get') + def test_orcid_public_api_make_request_employment_path( + self, + mock_get, + orcid_id_verified, + response_content_employments + ): + mock_response = Response() + mock_response.status_code = 200 + mock_response._content = response_content_employments + mock_get.return_value = mock_response + xml_data = tasks.orcid_public_api_make_request(ORCID_RECORD_EMPLOYMENT_PATH, orcid_id_verified) + assert xml_data is not None + + # For failure cases, either education or employment path is sufficient. + # Thus using the education path for the rest of the tests below to avoid duplicate tests + + @mock.patch('requests.get') + @mock.patch('lxml.etree.XML') + def test_orcid_public_api_make_request_not_200( + self, + mock_XML, + mock_get, + orcid_id_verified, + response_content_educations + ): + mock_response = Response() + mock_response.status_code = 204 + mock_response._content = None + mock_get.return_value = mock_response + xml_data = tasks.orcid_public_api_make_request(ORCID_RECORD_EDUCATION_PATH, orcid_id_verified) + assert xml_data is None + mock_XML.assert_not_called() + + @mock.patch('requests.get') + def test_orcid_public_api_make_request_parsing_error( + self, + mock_get, + orcid_id_verified, + response_content_educations + ): + mock_response = Response() + mock_response.status_code = 200 + mock_response._content = b'invalid_xml' + mock_get.return_value = mock_response + xml_data = tasks.orcid_public_api_make_request(ORCID_RECORD_EDUCATION_PATH, orcid_id_verified) + assert xml_data is None diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 99c5d95ffab..6e6efb01e34 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -750,6 +750,13 @@ class CeleryConfig: 'OrcidProfile': 'ORCID', } +ORCID_PUBLIC_API_ACCESS_TOKEN = None +ORCID_PUBLIC_API_V3_URL = 'https://pub.orcid.org/v3.0/' +ORCID_PUBLIC_API_REQUEST_TIMEOUT = None +ORCID_RECORD_ACCEPT_TYPE = 'application/vnd.orcid+xml' +ORCID_RECORD_EMPLOYMENT_PATH = '/employments' +ORCID_RECORD_EDUCATION_PATH = '/educations' + # Source: https://github.com/maxd/fake_email_validator/blob/master/config/fake_domains.list BLACKLISTED_DOMAINS = [ '0-mail.com', diff --git a/website/static/img/institutions/banners/oxford-banner.png b/website/static/img/institutions/banners/oxford-banner.png new file mode 100644 index 00000000000..fe484b02c14 Binary files /dev/null and b/website/static/img/institutions/banners/oxford-banner.png differ diff --git a/website/static/img/institutions/shields-rounded-corners/oxford-shield-rounded-corners.png b/website/static/img/institutions/shields-rounded-corners/oxford-shield-rounded-corners.png new file mode 100644 index 00000000000..2e271ab4ec8 Binary files /dev/null and b/website/static/img/institutions/shields-rounded-corners/oxford-shield-rounded-corners.png differ diff --git a/website/static/img/institutions/shields/oxford-shield.png b/website/static/img/institutions/shields/oxford-shield.png new file mode 100644 index 00000000000..0a6ad91d365 Binary files /dev/null and b/website/static/img/institutions/shields/oxford-shield.png differ