Skip to content

Commit

Permalink
Add logging using ORCID oauth
Browse files Browse the repository at this point in the history
[T-CAIREM 1243]
  • Loading branch information
matkaczmarek committed Aug 20, 2024
1 parent b4a53ce commit 03a3e01
Show file tree
Hide file tree
Showing 9 changed files with 255 additions and 18 deletions.
3 changes: 2 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,6 +281,7 @@
# 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)
Expand Down
13 changes: 11 additions & 2 deletions physionet-django/sso/templates/sso/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,20 @@ <h6 class="card-subtitle mb-2 text-muted">Login through an external institute</h
<a
type="button"
class="btn btn-primary center p-2 px-3"
href="{% url 'sso_login' %}"
href="{% url 'login' %}"
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>
<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>
</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;
}
25 changes: 24 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,26 @@ 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 @@ -928,3 +929,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
12 changes: 12 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,18 @@ <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>
<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>
<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 %}
3 changes: 3 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
134 changes: 120 additions & 14 deletions physionet-django/user/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import pytz
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth import login as auth_login
from django.contrib.auth import authenticate
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.tokens import default_token_generator
Expand Down Expand Up @@ -455,26 +456,14 @@ def auth_orcid(request):
"""

client_id = settings.ORCID_CLIENT_ID
client_secret = settings.ORCID_CLIENT_SECRET
redirect_uri = settings.ORCID_REDIRECT_URI
scope = list(settings.ORCID_SCOPE.split(","))
oauth = OAuth2Session(client_id, redirect_uri=redirect_uri,
scope=scope)
params = request.GET.copy()
code = params['code']

try:
token = oauth.fetch_token(settings.ORCID_TOKEN_URL, code=code,
include_client_id=True, client_secret=client_secret)
try:
validators.validate_orcid_token(token['access_token'])
token_valid = True
except ValidationError:
messages.error(request, 'Validation Error: ORCID token validation failed.')
token_valid = False
except InvalidGrantError:
messages.error(request, 'Invalid Grant Error: authorization code may be expired or invalid.')
token_valid = False
token_valid, token = _fetch_and_validate_token(request, code, oauth)

if token_valid:
orcid_profile, _ = Orcid.objects.get_or_create(user=request.user)
Expand All @@ -490,6 +479,123 @@ def auth_orcid(request):

return redirect('edit_orcid')


@disallow_during_maintenance
def auth_orcid_login(request):
"""
Gets a users iD and token information from an ORCID redirect URI after their authorization. Saves the iD and other
token information. Logs user in if the account already exists or redirects to register form. The access_token /
refresh_token can be used to make token exchanges for additional information in the users account. Public
information can be read without access to the member API at ORCID. Limited access information requires an
institution account with ORCID for access to the member API. The member API can also be used to add new
information to a users ORCID profile (ex: a PhysioNet dataset project). See the .env file for an example of how to
do token exchanges.
"""

client_id = settings.ORCID_CLIENT_ID
redirect_uri = settings.ORCID_LOGIN_REDIRECT_URI
scope = list(settings.ORCID_SCOPE.split(","))
oauth = OAuth2Session(client_id, redirect_uri=redirect_uri, scope=scope)
params = request.GET.copy()
code = params['code']

token_valid, token = _fetch_and_validate_token(request, code, oauth)

if token_valid:
orcid_id = token.get('orcid')
orcid_profile = Orcid.objects.filter(orcid_id=orcid_id).first()

if orcid_profile is None:
request.session['orcid_token'] = token
return redirect('orcid_register')

user = authenticate(orcid_profile=orcid_profile)
if user is None:
return render(
request,
'user/register_done.html',
{'email': orcid_profile.user.email, 'sso': False},
)

auth_login(request, user, backend='user.backends.OrcidAuthBackend')

return redirect('home')


def _fetch_and_validate_token(request, code, oauth_session):
"""
Exchange code retrieved from ORCID for token and validate it
"""
try:
client_secret = settings.ORCID_CLIENT_SECRET
token = oauth_session.fetch_token(
settings.ORCID_TOKEN_URL,
code=code,
include_client_id=True,
client_secret=client_secret,
)

try:
validators.validate_orcid_token(token['access_token'])
return True, token
except ValidationError:
messages.error(request, 'Validation Error: ORCID token validation failed.')
except InvalidGrantError:
messages.error(
request,
'Invalid Grant Error: authorization code may be expired or invalid.',
)

return False, None


@disallow_during_maintenance
def orcid_register(request):
"""
ORCID Registration view
GET renders the registration form.
POST submits the registration form.
"""
user = request.user
if user.is_authenticated:
return redirect('project_home')

if request.method == 'POST':
form = forms.OrcidRegistrationForm(
request.POST, orcid_token=request.session['orcid_token']
)

if form.is_valid():
user = form.save()
uidb64 = force_str(urlsafe_base64_encode(force_bytes(user.pk)))
token = default_token_generator.make_token(user)
notify_account_registration(request, user, uidb64, token, sso=False)

return render(
request, 'user/register_done.html', {'email': user.email, 'sso': False}
)
else:
form = forms.OrcidRegistrationForm()

return render(request, 'user/orcid_register.html', {'form': form})


@disallow_during_maintenance
def orcid_init_login(request):
"""
Builds redirect url and redirects to ORCID authorization page
"""
client_id = settings.ORCID_CLIENT_ID
redirect_uri = settings.ORCID_LOGIN_REDIRECT_URI
scope = settings.ORCID_SCOPE
auth_url = settings.ORCID_AUTH_URL

return redirect(
f'{auth_url}?response_type=code&redirect_uri={redirect_uri}&client_id={client_id}&scope={scope}'
)



@login_required
def edit_password_complete(request):
"""
Expand Down

0 comments on commit 03a3e01

Please sign in to comment.