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