Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add login using ORCID oauth #2276

Open
wants to merge 11 commits into
base: dev
Choose a base branch
from
5 changes: 4 additions & 1 deletion physionet-django/physionet/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
},
]

AUTHENTICATION_BACKENDS = ['user.backends.DualAuthModelBackend']
AUTHENTICATION_BACKENDS = ['user.backends.DualAuthModelBackend', 'user.backends.OrcidAuthBackend']

if ENABLE_SSO:
AUTHENTICATION_BACKENDS += ['sso.auth.RemoteUserBackend']
Expand Down Expand Up @@ -281,11 +281,14 @@
# 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 = "openid" in ORCID_SCOPE
ORCID_OPEN_ID_JWKS_URL = config('ORCID_OPEN_ID_JWKS_URL', default=False)

# Tags for the CITISOAPService API
CITI_USERNAME = config('CITI_USERNAME', default='')
Expand Down
13 changes: 12 additions & 1 deletion physionet-django/sso/templates/sso/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,19 @@ <h6 class="card-subtitle mb-2 text-muted">Login through an external institute</h
aria-disabled="true"
>
<i class="fa fa-university fa-lg mr-3"></i>
<span class="h6">{{ sso_login_button_text }}</span>
<span class="h6">login using you institution</span>
</a>
<br>
{% if enable_orcid_login %}
<h6 class="card-subtitle mb-2 mt-3 text-muted">or using ORCID iD</h6>
<a id="orcid_login"
type="button"
class="btn btn-secondary center p-2 px-3"
href="{% url 'orcid_init_login' %}">
<img src="https://orcid.org/sites/default/files/images/orcid_24x24.png" />
<span class="h6"> Log in using ORCID iD </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;
}
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_token and
"""

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="https://orcid.org/sites/default/files/images/orcid_24x24.png" />
Log in using ORCID iD
</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 %}
4 changes: 4 additions & 0 deletions physionet-django/user/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
path("settings/cloud/aws/", views.edit_cloud_aws, name="edit_cloud_aws"),
path("settings/orcid/", views.edit_orcid, name="edit_orcid"),
path("authorcid/", views.auth_orcid, name="auth_orcid"),
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"),
path(
"settings/credentialing/", views.edit_credentialing, name="edit_credentialing"
),
Expand Down Expand Up @@ -136,4 +139,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
Loading