Skip to content

Commit

Permalink
Add login using ORCID oauth (MIT-LCP#2276)
Browse files Browse the repository at this point in the history
Change introduces three new endpoints:

- `authorcid_login` -> which exchanges token with ORCID using Oauth and
logs in the user if ORCID is already connected to the existing user or
redirects to `orcid_register` for new users
- `orcid_register` -> present a form to register new users and links
ORCID to it once registered
- `orcid_init_login` -> builds URL and redirects to ORCID for
authorization

This PR also introduces changes in the login view and SSO login view:
<img width="454" alt="image"
src="https://github.com/user-attachments/assets/37d29dea-4744-4137-bd0b-b50f8ae7f056">
  • Loading branch information
tompollard authored Jan 8, 2025
2 parents 0efaa52 + bcfc4bc commit fec5d6e
Show file tree
Hide file tree
Showing 16 changed files with 376 additions and 27 deletions.
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ ORCID_REDIRECT_URI=http://localhost:8000/authorcid
ORCID_CLIENT_ID=SECRET
ORCID_CLIENT_SECRET=SECRET
ORCID_SCOPE='/read-limited,/activities/update'
ORCID_DOMAIN=https://sandbox.orcid.org
ORCID_LOGIN_REDIRECT_URI=http://localhost:8000/authorcid_login
ORCID_AUTH_URL=https://sandbox.orcid.org/oauth/authorize
ORCID_TOKEN_URL=https://sandbox.orcid.org/oauth/token
ORCID_LOGIN_ENABLED=False
ORCID_LOGIN_BUTTON_TEXT="Log in using ORCID iD"
# JWKS is used to get public key from orcid and validate access token using this public key
ORCID_OPEN_ID_JWKS_URL=https://sandbox.orcid.org/oauth/jwks

STORAGE_TYPE=LOCAL

Expand Down
25 changes: 16 additions & 9 deletions physionet-django/physionet/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@
GCS_SIGNED_URL_LIFETIME_IN_MINUTES = config('GCS_SIGNED_URL_LIFETIME_IN_MINUTES', default=1440, cast=int)


# Tags for the ORCID API
ORCID_DOMAIN = config('ORCID_DOMAIN', default='https://sandbox.orcid.org')
ORCID_REDIRECT_URI = config('ORCID_REDIRECT_URI', default='http://127.0.0.1:8000/authorcid')
ORCID_LOGIN_REDIRECT_URI = config('ORCID_LOGIN_REDIRECT_URI', default='http://127.0.0.1:8000/authorcid_login')
ORCID_AUTH_URL = config('ORCID_AUTH_URL', default='https://sandbox.orcid.org/oauth/authorize')
ORCID_TOKEN_URL = config('ORCID_TOKEN_URL', default='https://sandbox.orcid.org/oauth/token')
ORCID_CLIENT_ID = config('ORCID_CLIENT_ID', default=False)
ORCID_CLIENT_SECRET = config('ORCID_CLIENT_SECRET', default=False)
ORCID_SCOPE = config('ORCID_SCOPE', default=False)
ORCID_LOGIN_ENABLED = config('ORCID_LOGIN_ENABLED', default=False)
ORCID_OPEN_ID_JWKS_URL = config('ORCID_OPEN_ID_JWKS_URL', default="https://sandbox.orcid.org/oauth/jwks")
ORCID_LOGIN_BUTTON_TEXT = config('ORCID_LOGIN_BUTTON_TEXT', default="Log in using ORCID iD")


# Application definition

INSTALLED_APPS = [
Expand Down Expand Up @@ -151,6 +165,8 @@
]

AUTHENTICATION_BACKENDS = ['user.backends.DualAuthModelBackend']
if ORCID_LOGIN_ENABLED:
AUTHENTICATION_BACKENDS.append('user.backends.OrcidAuthBackend')

if ENABLE_SSO:
AUTHENTICATION_BACKENDS += ['sso.auth.RemoteUserBackend']
Expand Down Expand Up @@ -278,15 +294,6 @@
DATACITE_USER = config('DATACITE_USER', default='')
DATACITE_PASS = config('DATACITE_PASS', default='')

# Tags for the ORCID API
ORCID_DOMAIN = config('ORCID_DOMAIN', default='https://sandbox.orcid.org')
ORCID_REDIRECT_URI = config('ORCID_REDIRECT_URI', default='http://127.0.0.1:8000/authorcid')
ORCID_AUTH_URL = config('ORCID_AUTH_URL', default='https://sandbox.orcid.org/oauth/authorize')
ORCID_TOKEN_URL = config('ORCID_TOKEN_URL', default='https://sandbox.orcid.org/oauth/token')
ORCID_CLIENT_ID = config('ORCID_CLIENT_ID', default=False)
ORCID_CLIENT_SECRET = config('ORCID_CLIENT_SECRET', default=False)
ORCID_SCOPE = config('ORCID_SCOPE', default=False)

# Tags for the CITISOAPService API
CITI_USERNAME = config('CITI_USERNAME', default='')
CITI_PASSWORD = config('CITI_PASSWORD', default='')
Expand Down
13 changes: 13 additions & 0 deletions physionet-django/sso/templates/sso/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ <h6 class="card-subtitle mb-2 text-muted">Login through an external institute</h
<i class="fa fa-university fa-lg mr-3"></i>
<span class="h6">{{ sso_login_button_text }}</span>
</a>
<br>
{% if enable_orcid_login %}
<div class="separator">
<span>or</span>
</div>
<a id="orcid_login"
type="button"
class="btn btn-secondary center p-2 px-3"
href="{% url 'orcid_init_login' %}">
<img src="{% static 'images/orcid-icon-small.png' %}" />
<span class="h6"> {{ orcid_login_button_text }} </span>
</a>
{% endif %}
</div>
</div>
</div>
Expand Down
29 changes: 29 additions & 0 deletions physionet-django/static/custom/css/login-register.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,32 @@ input[name="privacy_policy"] {
label[for="id_privacy_policy"] {
width: 90%;
}

.separator {
display: flex;
align-items: center;
text-align: center;
margin: 15px auto; /* Space around the separator */
max-width: 300px
}

.separator::before,
.separator::after {
content: '';
flex: 1;
border-bottom: 1px solid #ccc; /* Light gray line */
}

.separator::before {
margin-right: 10px;
}

.separator::after {
margin-left: 10px;
}

.separator span {
font-size: 14px;
color: #666; /* Gray text color */
font-weight: bold;
}
Binary file removed physionet-django/static/images/ORCIDiD_icon24x24.png
Binary file not shown.
Binary file added physionet-django/static/images/orcid-icon-small.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 23 additions & 1 deletion physionet-django/user/backends.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging

from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.backends import ModelBackend, BaseBackend

from user.models import User

Expand Down Expand Up @@ -33,3 +33,25 @@ def get_user(self, user_id):
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None


class OrcidAuthBackend(BaseBackend):
"""
This is a Base that allows authentication with orcid_profile.
"""
def authenticate(self, request, orcid_profile=None):
if orcid_profile is None:
return None

user = orcid_profile.user
return user if self.user_can_authenticate(user) else None

def user_can_authenticate(self, user):
is_active = getattr(user, 'is_active', None)
return is_active or is_active is None

def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
27 changes: 27 additions & 0 deletions physionet-django/user/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
TrainingType,
TrainingStatus,
RequiredField,
Orcid,
)
from user.trainingreport import TrainingCertificateError, find_training_report_url
from user.userfiles import UserFiles
Expand Down Expand Up @@ -930,3 +931,29 @@ def save(self):
TrainingQuestion.objects.bulk_create(training_questions)

return training


class OrcidRegistrationForm(RegistrationForm):
"""
Form to register new user after signing in with ORCID.
This saves user as the same way RegistrationForm but also stores
Orcid profile linked with this user.
"""

def __init__(self, *args, **kwargs):
self.orcid_token = kwargs.pop('orcid_token', None)
super().__init__(*args, **kwargs)

def save(self):
with transaction.atomic():
user = super().save()
orcid_profile = Orcid.objects.create(
user=user, orcid_id=self.orcid_token.get('orcid')
)
orcid_profile.access_token = self.orcid_token.get('access_token')
orcid_profile.refresh_token = self.orcid_token.get('refresh_token')
orcid_profile.token_type = self.orcid_token.get('token_type')
orcid_profile.token_scope = self.orcid_token.get('scope')
orcid_profile.token_expiration = self.orcid_token.get('expires_at')
orcid_profile.save()
return user
14 changes: 14 additions & 0 deletions physionet-django/user/templates/user/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ <h2 class="form-signin-heading">Account Login</h2>
</div>
<button id="login" class="btn btn-lg btn-primary btn-block" type="submit">Log In</button>
</form>
{% if enable_orcid_login %}
<div class="separator">
<span>or</span>
</div>
<div class="form-signin">
<a id="orcid_login"
type="button"
class="btn btn-lg btn-secondary btn-block"
href="{% url 'orcid_init_login' %}">
<img src="{% static 'images/orcid-icon-small.png' %}" />
{{ orcid_login_button_text }}
</a>
</div>
{% endif %}
<div class="form-signin">
<p>New user? <a id="register" href="{% url 'register' %}">Create an account</a></p>
</div>
Expand Down
27 changes: 27 additions & 0 deletions physionet-django/user/templates/user/orcid_register.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{% extends "base.html" %}

{% load static %}

{% block title %}
Register
{% endblock %}


{% block local_css %}
<link rel="stylesheet" type="text/css" href="{% static 'custom/css/login-register.css' %}"/>
{% endblock %}


{% block content %}
<div class="container">
<form action="{% url 'orcid_register' %}" method="post" class="form-signin">
<h2 class="form-signin-heading">Create Account</h2>
{% csrf_token %}
{% include "form_snippet.html" %}
<button class="btn btn-lg btn-primary btn-block" type="submit">Register</button>
</form>
<div class="form-signin">
<p>Already have an account? <a href="{% url 'login' %}">Log In</a></p>
</div>
</div>
{% endblock %}
10 changes: 10 additions & 0 deletions physionet-django/user/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@
]
)

if settings.ORCID_LOGIN_ENABLED:
urlpatterns.extend(
[
path("authorcid_login/", views.auth_orcid_login, name="auth_orcid_login"),
path("orcid_init_login", views.orcid_init_login, name="orcid_init_login"),
path("orcid_register/", views.orcid_register, name="orcid_register"),
]
)

# Parameters for testing URLs (see physionet/test_urls.py)
TEST_DEFAULTS = {
"_user_": "aewj",
Expand All @@ -136,4 +145,5 @@
"reset_password_confirm": {"uidb64": "x", "token": "x", "_skip_": True},
# Testing auth_orcid requires a mock oauth server. Skip this URL.
"auth_orcid": {"_skip_": True},
"auth_orcid_login": {"_skip_": True},
}
35 changes: 35 additions & 0 deletions physionet-django/user/validators.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import re
import requests
import jwt
import json

from django.conf import settings
from django.contrib.auth.validators import UnicodeUsernameValidator
Expand Down Expand Up @@ -208,6 +211,7 @@ def validate_nan(value):
if re.fullmatch(r'[0-9\-+()]*', value):
raise ValidationError('Cannot be a number.')


def validate_orcid_token(value):
"""
Validation to verify the token returned during
Expand All @@ -216,6 +220,37 @@ def validate_orcid_token(value):
if not re.fullmatch(r'^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$', value):
raise ValidationError('ORCID token is not in expected format.')


def validate_orcid_id_token(token):
"""
When openid scope is enabled then ORCID returns
access_token and signed id_token, this function validates id_token signature
"""

jwks_url = settings.ORCID_OPEN_ID_JWKS_URL
jwks = requests.get(jwks_url).json()
headers = jwt.get_unverified_header(token)

public_keys = {}
for jwk in jwks['keys']:
kid = jwk['kid']
public_keys[kid] = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))

rsa_key = public_keys[headers['kid']]
if rsa_key is None:
raise ValidationError('ORCID id_token is invalid.')

try:
jwt.decode(
token,
rsa_key,
algorithms=['RS256'],
audience=settings.ORCID_CLIENT_ID,
issuer=settings.ORCID_DOMAIN
)
except jwt.InvalidTokenError:
raise ValidationError('ORCID id_token is invalid.')

def validate_orcid_id(value):
"""
Validation to verify the ID returned during
Expand Down
Loading

0 comments on commit fec5d6e

Please sign in to comment.