From cc060d915209852bf870a7dac2a3424b9dcf5d3b Mon Sep 17 00:00:00 2001 From: Dolsy Smith Date: Fri, 23 Apr 2021 12:40:59 -0400 Subject: [PATCH] Added SAML code; factored views into separate file --- orcid_utils.py | 12 ++++ orcidflask/__init__.py | 57 ++-------------- orcidflask/saml_utils.py | 81 +++++++++++++++++++++++ orcidflask/views.py | 137 +++++++++++++++++++++++++++++++++++++++ saml_utils.py | 81 +++++++++++++++++++++++ setup.py | 10 +++ 6 files changed, 326 insertions(+), 52 deletions(-) create mode 100644 orcid_utils.py create mode 100644 orcidflask/saml_utils.py create mode 100644 orcidflask/views.py create mode 100644 saml_utils.py create mode 100644 setup.py diff --git a/orcid_utils.py b/orcid_utils.py new file mode 100644 index 0000000..de3c3eb --- /dev/null +++ b/orcid_utils.py @@ -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)} \ No newline at end of file diff --git a/orcidflask/__init__.py b/orcidflask/__init__.py index 6a3ce2b..07b1cae 100644 --- a/orcidflask/__init__.py +++ b/orcidflask/__init__.py @@ -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 @@ -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 \ No newline at end of file diff --git a/orcidflask/saml_utils.py b/orcidflask/saml_utils.py new file mode 100644 index 0000000..297c6c0 --- /dev/null +++ b/orcidflask/saml_utils.py @@ -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 \ No newline at end of file diff --git a/orcidflask/views.py b/orcidflask/views.py new file mode 100644 index 0000000..1ca5044 --- /dev/null +++ b/orcidflask/views.py @@ -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!" \ No newline at end of file diff --git a/saml_utils.py b/saml_utils.py new file mode 100644 index 0000000..297c6c0 --- /dev/null +++ b/saml_utils.py @@ -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 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..da6031f --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +from setuptools import setup + +setup( + name='orcidflask', + packages=['orcidflask'], + include_package_data=True, + install_requires=[ + 'flask', + ], +) \ No newline at end of file