Skip to content

Commit

Permalink
Added SAML code; factored views into separate file
Browse files Browse the repository at this point in the history
  • Loading branch information
dolsysmith committed Apr 23, 2021
1 parent bc4da2c commit cc060d9
Show file tree
Hide file tree
Showing 6 changed files with 326 additions and 52 deletions.
12 changes: 12 additions & 0 deletions orcid_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from flask import current_app

def prepare_token_payload(code: str):
'''
:param code: the code returned from ORCID after the user authorizes our application.
'''
app = current_app._get_current_object()
return {'client_id': app.config['CLIENT_ID'],
'client_secret': app.config['CLIENT_SECRET'],
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': url_for('orcid_redirect', _external=True)}
57 changes: 5 additions & 52 deletions orcidflask/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from flask import Flask, session, request, url_for, redirect
import requests
from requests.exceptions import HTTPError

from flask import Flask
import os

app = Flask(__name__)
# load default configs from default_settings.py
Expand All @@ -10,52 +8,7 @@
app.config.from_envvar('ORCIDFLASK_SETTINGS')
app.config['orcid_auth_url'] = 'https://sandbox.orcid.org/oauth/authorize?client_id={orcid_client_id}&response_type=code&scope=/read-limited /activities/update /person/update&redirect_uri={redirect_uri}'
app.config['orcid_token_url'] = 'https://sandbox.orcid.org/oauth/token'
app.config['SAML_PATH'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'saml')
app.secret_key = app.config['SECRET_KEY']


@app.route('/')
def index():
'''
Should render homepage and if behind SSO, retrieve netID from SAML and store in a session variable.
See the example here: https://github.com/onelogin/python3-saml/blob/master/demo-flask/index.py
'''
# TO DO: Add template with button to take user to the ORCID authorization site
# TO DO: Capture SAML identifier in session object
return redirect(app.config['orcid_auth_url'].format(orcid_client_id=app.config['CLIENT_ID'],
redirect_uri=url_for('orcid_redirect',
_external=True)))

@app.route('/orcid-redirect')
def orcid_redirect():
'''
Redirect route that retrieves the one-time code from ORCID after user logs in and approves.
'''
# TO DO: Check for error/access denied
orcid_code = request.args.get('code')
headers = {'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'}
try:
response = requests.post(app.config['orcid_token_url'],
headers=headers,
data=prepare_token_payload(orcid_code))
response.raise_for_status()
except HTTPError as e:
# TO DO: handle HTTP errors
raise
orcid_auth = response.json()
print(orcid_auth)
# TO DO: Retrieve SAML identifier from session object
# TO DO: Save to data store
# TO DO: return success template
return "Successfully authorized!"


def prepare_token_payload(code: str):
'''
:param code: the code returned from ORCID after the user authorizes our application.
'''
return {'client_id': app.config['CLIENT_ID'],
'client_secret': app.config['CLIENT_SECRET'],
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': url_for('orcid_redirect', _external=True)}

import orcidflask.views
81 changes: 81 additions & 0 deletions orcidflask/saml_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'''
Functions to handle the creation of SAML SP metadata and to retrieve attributes from the IdP metadata.
The following code closely follows the Flask example at https://github.com/onelogin/python3-saml/blob/master/demo-flask/index.py
'''

from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from flask import current_app
from urllib.parse import urlparse

def init_saml_auth(request):
'''
:params request: a Flask request object
returns a python-saml auth object and the modified request object
'''
# Get current Flask app (to access configurations)
app = current_app._get_current_object()
auth_req = prepare_flask_request(request)
auth = OneLogin_Saml2_Auth(auth_req, custom_base_path=app.config['SAML_PATH'])
return auth, auth_req

def prepare_flask_request(request):
'''
:param request: A Flask request object
returns a python-saml request object with data from the Flask request object
'''
# host of the SP
url_data = urlparse(request.url)
return {
'https': 'on' if request.scheme == 'https' else 'off',
'http_host': request.host,
'server_port': url_data.port,
'script_name': request.path,
'get_data': request.args.copy(),
# Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144
# 'lowercase_urlencoding': True,
'post_data': request.form.copy()
}

def get_metadata_from_session(session):
'''
Extracts values from a Flask session object corresponding to certain SAML metadata attributes.
:param session: a Flask session object
'''
# SAML attribute names
attr_names = ['samlNameId', 'samlSessionIndex', 'samlNameIdFormat', 'samlNameIdNameQualifier', 'samlNameIdSPNameQualifier']
# parameter names used by the python3-saml auth object
keys = ['name_id', 'session_index', 'name_id_format', 'nq', 'spqn']
metadata_dict = {key: session.get(attr_name) for (key, attr_name) in zip(keys, attr_names)}
return metadata_dict

def add_metadata_to_session(auth, session):
'''
Stores the SAML metadata in the IdP response in a session object.
:param auth: a python-saml auth object.
:param session: a Flask session object.
'''
# Delete the previous session ID if storing this
if 'AuthNRequestID' in session:
del session['AuthNRequestID']
# Store SAML attributes in the session object
session['samlUserdata'] = auth.get_attributes()
session['samlNameId'] = auth.get_nameid()
session['samlNameIdFormat'] = auth.get_nameid_format()
session['samlNameIdNameQualifier'] = auth.get_nameid_nq()
session['samlNameIdSPNameQualifier'] = auth.get_nameid_spnq()
session['samlSessionIndex'] = auth.get_session_index()

return session

def get_attributes(session):
'''
Retrieve SAML attributes from Flask session data.
:param session: a Flask session object
returns attributes (if any) and a Boolean flag for the logout button --> Not sure we need this
'''
if 'samlUserdata' in session:
if len(session['samlUserdata']) > 0:
attributes = session['samlUserdata'].items()
return attributes, True
return False, False
137 changes: 137 additions & 0 deletions orcidflask/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
from flask import request, url_for, redirect, session
from orcidflask import app
from saml_utils import *
from onelogin.saml2.utils import OneLogin_Saml2_Utils
import requests
from requests.exceptions import HTTPError

@app.route('/login', methods=['GET', 'POST'])
def login():
'''
Route handles the SSO process
'''
auth, auth_req = init_saml_auth(request)
errors = []
error_reason = None
not_auth_warn = False
success_slo = False
attributes = False
paint_logout = False

# Initiating the SSO process
if 'sso' in request.args:
return redirect(auth.login())
# Initiating the SLO process
elif 'slo' in request.args:
metadata = get_metadata_from_session(session)
return redirect(auth.logout(**metadata))

# Consuming the SAML attributes from the IdP
if 'acs' in request.args:
# Getting the request ID, if necessary
request_id = None
if 'AuthNRequestID' in session:
request_id = session['AuthNRequestID']
# Process the XML
auth.process_response(request_id=request_id)
errors = auth.get_errors()
# Check for errors
not_auth_warn = not auth.is_authenticated()
if len(errors) == 0:
# Update the Flask session object with the SAML attributes for this user
# Updating by reference here; otherwise, we break the session context local
add_metadata_to_session(auth, session)
# Redirect to a new page, if necessary
self_url = OneLogin_Saml2_Utils.get_self_url(auth_req)
if 'RelayState' in request.form and self_url != request.form['RelayState']:
return redirect(auth.redirect_to(request.form['RelayState']))
# Get the reason for auth failure if exists
elif auth.get_settings().is_debug_active():
error_reason = auth.get_last_error_reason()

# Handle logout
elif 'sls' in request.args:
request_id = None
if 'LogoutRequestID' in session:
request_id = session['LogoutRequestID']
dscb = lambda: session.clear()
url = auth.process_slo(request_id=request_id, delete_session_cb=dscb)
errors = auth.get_errors()
if len(errors) == 0:
if url is not None:
return redirect(url)
else:
success_slo = True
elif auth.get_settings().is_debug_active():
error_reason = auth.get_last_error_reason()

attributes, paint_logout = get_attributes(session)

return render_template(
'index.html',
errors=errors,
error_reason=error_reason,
not_auth_warn=not_auth_warn,
success_slo=success_slo,
attributes=attributes,
paint_logout=paint_logout
)

@app.route('/attrs/')
def attrs():
attributes, paint_logout = get_attributes(session)
return render_template('attrs.html', paint_logout=paint_logout,
attributes=attributes)


@app.route('/metadata/')
def metadata():
req = prepare_flask_request(request)
auth = init_saml_auth(req)
settings = auth.get_settings()
metadata = settings.get_sp_metadata()
errors = settings.validate_metadata(metadata)

if len(errors) == 0:
resp = make_response(metadata, 200)
resp.headers['Content-Type'] = 'text/xml'
else:
resp = make_response(', '.join(errors), 500)
return resp


@app.route('/')
def index():
'''
Should render homepage and if behind SSO, retrieve netID from SAML and store in a session variable.
See the example here: https://github.com/onelogin/python3-saml/blob/master/demo-flask/index.py
'''
# TO DO: Add template with button to take user to the ORCID authorization site
# TO DO: Capture SAML identifier in session object
return redirect(app.config['orcid_auth_url'].format(orcid_client_id=app.config['CLIENT_ID'],
redirect_uri=url_for('orcid_redirect',
_external=True)))

@app.route('/orcid-redirect')
def orcid_redirect():
'''
Redirect route that retrieves the one-time code from ORCID after user logs in and approves.
'''
# TO DO: Check for error/access denied
orcid_code = request.args.get('code')
headers = {'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'}
try:
response = requests.post(app.config['orcid_token_url'],
headers=headers,
data=prepare_token_payload(orcid_code))
response.raise_for_status()
except HTTPError as e:
# TO DO: handle HTTP errors
raise
orcid_auth = response.json()
print(orcid_auth)
# TO DO: Retrieve SAML identifier from session object
# TO DO: Save to data store
# TO DO: return success template
return "Successfully authorized!"
81 changes: 81 additions & 0 deletions saml_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'''
Functions to handle the creation of SAML SP metadata and to retrieve attributes from the IdP metadata.
The following code closely follows the Flask example at https://github.com/onelogin/python3-saml/blob/master/demo-flask/index.py
'''

from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from flask import current_app
from urllib.parse import urlparse

def init_saml_auth(request):
'''
:params request: a Flask request object
returns a python-saml auth object and the modified request object
'''
# Get current Flask app (to access configurations)
app = current_app._get_current_object()
auth_req = prepare_flask_request(request)
auth = OneLogin_Saml2_Auth(auth_req, custom_base_path=app.config['SAML_PATH'])
return auth, auth_req

def prepare_flask_request(request):
'''
:param request: A Flask request object
returns a python-saml request object with data from the Flask request object
'''
# host of the SP
url_data = urlparse(request.url)
return {
'https': 'on' if request.scheme == 'https' else 'off',
'http_host': request.host,
'server_port': url_data.port,
'script_name': request.path,
'get_data': request.args.copy(),
# Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144
# 'lowercase_urlencoding': True,
'post_data': request.form.copy()
}

def get_metadata_from_session(session):
'''
Extracts values from a Flask session object corresponding to certain SAML metadata attributes.
:param session: a Flask session object
'''
# SAML attribute names
attr_names = ['samlNameId', 'samlSessionIndex', 'samlNameIdFormat', 'samlNameIdNameQualifier', 'samlNameIdSPNameQualifier']
# parameter names used by the python3-saml auth object
keys = ['name_id', 'session_index', 'name_id_format', 'nq', 'spqn']
metadata_dict = {key: session.get(attr_name) for (key, attr_name) in zip(keys, attr_names)}
return metadata_dict

def add_metadata_to_session(auth, session):
'''
Stores the SAML metadata in the IdP response in a session object.
:param auth: a python-saml auth object.
:param session: a Flask session object.
'''
# Delete the previous session ID if storing this
if 'AuthNRequestID' in session:
del session['AuthNRequestID']
# Store SAML attributes in the session object
session['samlUserdata'] = auth.get_attributes()
session['samlNameId'] = auth.get_nameid()
session['samlNameIdFormat'] = auth.get_nameid_format()
session['samlNameIdNameQualifier'] = auth.get_nameid_nq()
session['samlNameIdSPNameQualifier'] = auth.get_nameid_spnq()
session['samlSessionIndex'] = auth.get_session_index()

return session

def get_attributes(session):
'''
Retrieve SAML attributes from Flask session data.
:param session: a Flask session object
returns attributes (if any) and a Boolean flag for the logout button --> Not sure we need this
'''
if 'samlUserdata' in session:
if len(session['samlUserdata']) > 0:
attributes = session['samlUserdata'].items()
return attributes, True
return False, False
10 changes: 10 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from setuptools import setup

setup(
name='orcidflask',
packages=['orcidflask'],
include_package_data=True,
install_requires=[
'flask',
],
)

0 comments on commit cc060d9

Please sign in to comment.