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

Feature/orcid login 1243 #16

Draft
wants to merge 14 commits into
base: dev
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ ORCID_REDIRECT_URI=http://localhost:8000/authorcid
ORCID_CLIENT_ID=SECRET
ORCID_CLIENT_SECRET=SECRET
ORCID_SCOPE='/read-limited,/activities/update'
ORCID_LOGIN_ENABLED=True

STORAGE_TYPE=LOCAL

Expand Down
2 changes: 1 addition & 1 deletion physionet-django/console/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3495,7 +3495,7 @@ def event_management(request, event_slug):
# handle the add dataset form(s)
if request.method == "POST":
if "add-event-dataset" in request.POST.keys():
event_dataset_form = EventDatasetForm(request.POST)
event_dataset_form = EventDatasetForm(request.POST, user=request.user)
if event_dataset_form.is_valid():
active_datasets = selected_event.datasets.filter(
dataset=event_dataset_form.cleaned_data["dataset"],
Expand Down
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 = config('ORCID_LOGIN_ENABLED', default=("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