Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions admin/templates/users/user.html
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ <h2>User: <b>{{ user.username }}</b> <a href="{{ user.absolute_url }}"> ({{user.
{% endif %}
</td>
</tr>
<tr>
<td>Initial ORCID Authorization</td>
<td>{{ user.date_orcid_initial_authorized }}</td>
</tr>
<tr>
<td>Last ORCID Authorization</td>
<td>{{ user.date_orcid_last_authorized }}</td>
</tr>
<tr>
<td>ORCID Token Reserved</td>
<td>{{ user.orcid_token_stored }}</td>
</tr>
<tr>
<td>Registered</td>
<td>{{ user.is_registered }} [{{ user.date_registered }}]</td>
Expand Down
16 changes: 16 additions & 0 deletions framework/auth/cas.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class CasHTTPError(CasError):

def __init__(self, code, message, headers, content):
super().__init__(code, message)
self.message = message
self.headers = headers
self.content = content

Expand Down Expand Up @@ -97,6 +98,10 @@ def get_auth_token_revocation_url(self):
url = furl(self.BASE_URL).add(path=['oauth2', 'revoke'])
return url.url

def get_orcid_token_revocation_url(self):
url = furl(self.BASE_URL).add(path=['osf', 'orcid', 'revoke'])
return url.url

def service_validate(self, ticket, service_url):
"""
Send request to CAS to validate ticket.
Expand Down Expand Up @@ -198,6 +203,17 @@ def revoke_tokens(self, payload):
else:
self._handle_error(resp)

def revoke_orcid_token(self, orcid_id):
url = self.get_orcid_token_revocation_url()
headers = {
'Authorization': f'Bearer {settings.CAS_ORCID_REVOKE_SHARED_SECRET}',
}
resp = requests.post(url, json={'orcid_id': orcid_id}, headers=headers)
if resp.status_code == 204:
return True
else:
self._handle_error(resp)


def parse_auth_header(header):
"""
Expand Down
11 changes: 11 additions & 0 deletions framework/auth/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import itertools
import logging

from django.utils import timezone
from lxml import etree
import pytz
import requests
Expand Down Expand Up @@ -54,6 +55,16 @@ def update_affiliation_for_orcid_sso_users(user_id, orcid_id):
logger.error(error_message)
sentry.log_message(error_message)
return

# Best-effort tracking of ORCID (re)authorization for admin visibility and GDPR-delete triage.
# This reflects that OSF observed a completed ORCID login, not a confirmed CAS-side token write.
now = timezone.now()
if not user.date_orcid_initial_authorized:
user.date_orcid_initial_authorized = now
user.date_orcid_last_authorized = now
user.orcid_token_stored = True
user.save()

institution = check_institution_affiliation(orcid_id)
if institution:
logger.info(f'Eligible institution affiliation has been found for ORCiD SSO user: '
Expand Down
27 changes: 27 additions & 0 deletions osf/migrations/0045_osfuser_orcid_token_tracking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.db import migrations, models
import osf.utils.fields


class Migration(migrations.Migration):

dependencies = [
('osf', '0044_notification_scheduled'),
]

operations = [
migrations.AddField(
model_name='osfuser',
name='date_orcid_initial_authorized',
field=osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='osfuser',
name='date_orcid_last_authorized',
field=osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='osfuser',
name='orcid_token_stored',
field=models.BooleanField(default=False),
),
]
24 changes: 23 additions & 1 deletion osf/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from django.utils import timezone

from framework import sentry
from framework.auth import Auth, signals, utils
from framework.auth import Auth, cas, signals, utils
from framework.auth.core import generate_verification_key
from framework.auth.exceptions import (
ChangePasswordError,
Expand Down Expand Up @@ -402,6 +402,12 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi

chronos_user_id = models.TextField(null=True, blank=True, db_index=True)

date_orcid_initial_authorized = NonNaiveDateTimeField(null=True, blank=True)

date_orcid_last_authorized = NonNaiveDateTimeField(null=True, blank=True)

orcid_token_stored = models.BooleanField(default=False)

allow_indexing = models.BooleanField(null=True, blank=True, default=None)

objects = OSFUserManager()
Expand Down Expand Up @@ -2166,6 +2172,22 @@ def _clear_identifying_information(self):
account.profile_url = None
account.save()
self.external_accounts.clear()

# Revoke any ORCID OAuth token CAS holds for this user, so OSF no longer shows as a
# trusted party on the user's ORCID account. Best-effort: never blocks GDPR delete.
orcid_ids = self.external_identity.get('ORCID', {})
if orcid_ids:
for orcid_id in orcid_ids:
try:
cas.get_client().revoke_orcid_token(orcid_id)
except cas.CasHTTPError as e:
logger.error(f'Unable to revoke ORCID token via CAS for user {self._id}, orcid_id={orcid_id}: {e}')
sentry.log_exception(e)
except Exception as e:
logger.error(f'Unexpected error revoking ORCID token via CAS for user {self._id}, orcid_id={orcid_id}: {e}')
sentry.log_exception(e)
self.orcid_token_stored = False

self.external_identity = {}
self.deleted = timezone.now()

Expand Down
35 changes: 33 additions & 2 deletions osf_tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -2222,11 +2222,15 @@ def test_gdpr_delete_triggers_share_update_for_public_shared_preprints(

assert mock_update_search.called

def test_can_gdpr_delete(self, user):
@mock.patch('framework.auth.cas.CasClient.revoke_orcid_token')
def test_can_gdpr_delete(self, mock_revoke_orcid_token, user):
user.external_identity = {'ORCID': {'fake-orcid-id': 'VERIFIED'}}
user.orcid_token_stored = True
user.save()

user.social = ['fake social']
user.schools = ['fake schools']
user.jobs = ['fake jobs']
user.external_identity = ['fake external identity']
user.external_accounts.add(ExternalAccountFactory())

user.gdpr_delete()
Expand All @@ -2241,6 +2245,33 @@ def test_can_gdpr_delete(self, user):
assert not user.external_accounts.exists()
assert user.is_disabled
assert user.deleted is not None
mock_revoke_orcid_token.assert_called_once_with('fake-orcid-id')
assert user.orcid_token_stored is False

@mock.patch('framework.auth.cas.CasClient.revoke_orcid_token')
def test_gdpr_delete_no_orcid_no_cas_call(self, mock_revoke_orcid_token, user):
assert user.external_identity == {}

user.gdpr_delete()

mock_revoke_orcid_token.assert_not_called()

@mock.patch('framework.auth.cas.CasClient.revoke_orcid_token')
def test_gdpr_delete_orcid_revoke_failure_does_not_block_delete(self, mock_revoke_orcid_token, user):
from framework.auth import cas
mock_revoke_orcid_token.side_effect = cas.CasHTTPError(
code=400, message='Bad Request', headers={}, content=b'',
)
user.external_identity = {'ORCID': {'fake-orcid-id': 'VERIFIED'}}
user.orcid_token_stored = True
user.save()

user.gdpr_delete()

mock_revoke_orcid_token.assert_called_once_with('fake-orcid-id')
assert user.external_identity == {}
assert user.orcid_token_stored is False
assert user.deleted is not None

def test_can_gdpr_delete_personal_nodes(self, user):

Expand Down
1 change: 1 addition & 0 deletions website/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ def parent_dir(path):
SPAM_SUBMIT_TASK_HARD_TIME_LIMIT = 90

CAS_SERVER_URL = 'http://localhost:8080'
CAS_ORCID_REVOKE_SHARED_SECRET = os.environ.get('CAS_ORCID_REVOKE_SHARED_SECRET', 'changeme')
MFR_SERVER_URL = 'http://localhost:7778'

###### ARCHIVER ###########
Expand Down