diff --git a/docker-compose.yml b/docker-compose.yml index 564b3b8..c63736b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - POSTGRES_DB=${POSTGRES_DB} - POSTGRES_DB_HOST=${POSTGRES_DB_HOST} - POSTGRES_PORT=${POSTGRES_PORT} + - DB_ENCRYPTION_FILE=${DB_ENCRYPTION_FILE} volumes: #- ./orcidflask/saml/certs:/opt/orcid_integration/orcidflask/saml/certs #- ./orcidflask/saml/settings.json:/opt/orcid_integration/orcidflask/saml/settings.json diff --git a/example.config.py b/example.config.py index 0d7867d..439524e 100644 --- a/example.config.py +++ b/example.config.py @@ -1,3 +1,6 @@ SECRET_KEY = b'' CLIENT_ID = '' -CLIENT_SECRET = '' \ No newline at end of file +CLIENT_SECRET = '' +SLO_REDIRECT = '' +ORCID_SUCCESS_URL = '' +ORCID_FAILURE_URL = '' \ No newline at end of file diff --git a/example.env b/example.env index e9f1586..c64e3c7 100644 --- a/example.env +++ b/example.env @@ -3,3 +3,4 @@ POSTGRES_PASSWORD=orcidpass POSTGRES_DB=orcidig POSTGRES_DB_HOST=db POSTGRES_PORT=5432 +DB_ENCRYPTION_FILE=./db-encrypt.key diff --git a/orcid_utils.py b/orcid_utils.py index b2496d9..2cd7ad1 100644 --- a/orcid_utils.py +++ b/orcid_utils.py @@ -1,4 +1,6 @@ from flask import current_app, url_for +from cryptography.fernet import Fernet +from os.path import exists def prepare_token_payload(code: str): ''' @@ -9,4 +11,49 @@ def prepare_token_payload(code: str): 'client_secret': app.config['CLIENT_SECRET'], 'grant_type': 'authorization_code', 'code': code, - 'redirect_uri': url_for('orcid_redirect', _external=True, _scheme='https')} \ No newline at end of file + 'redirect_uri': url_for('orcid_redirect', _external=True, _scheme='https')} + +def extract_saml_user_data(session): + ''' + Extracts name and email attributes from the samlUserData object. + :param session: a Flask session object + ''' + saml_attrs = ['emailaddress', 'firstname', 'lastname'] + saml_data = {} + if 'samlUserdata' in session: + user_data = session['samlUserdata'] + for saml_attr in saml_attrs: + value = user_data.get(saml_attr) + # Name and email attributes appear to be arrays; not sure if there can be more than one element + if value: + value = value[0] + saml_data[saml_attr] = value + return saml_data + +def new_encryption_key(file, replace=False): + ''' + Creates and stores a new encryption key at the provided path to file and returns the key. If file exists and replace=True, it will overwrite an existing file; otherwise, it will skip saving. + ''' + key = create_encryption_key() + if (not exists(file)) or (replace): + with open(file, 'wb') as f: + f.write(key) + return key + +def create_encryption_key(): + ''' + Creates a secret key using the Fernet algorithm. To be used only when setting up the app; key should be stored in the orcidflask/encryption directory. + ''' + key = Fernet.generate_key() + return key + +def load_encryption_key(file): + ''' + Loads a secret key as binary from the provided file, for use in encrypting database values. If file not found, creates a new key + ''' + try: + with open(file, 'rb') as f: + key = f.read() + except FileNotFoundError: + key = new_encryption_key(file) + return key \ No newline at end of file diff --git a/orcidflask/__init__.py b/orcidflask/__init__.py index 8eea316..373244f 100644 --- a/orcidflask/__init__.py +++ b/orcidflask/__init__.py @@ -1,13 +1,16 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy import os +import click +from orcid_utils import load_encryption_key, new_encryption_key app = Flask(__name__) # load default configs from default_settings.py app.config.from_object('orcidflask.default_settings') # load sensitive config settings 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={scopes}&redirect_uri={redirect_uri}' +# Personal attributes from SAML metadata definitions +app.config['orcid_auth_url'] = 'https://sandbox.orcid.org/oauth/authorize?client_id={orcid_client_id}&response_type=code&scope={scopes}&redirect_uri={redirect_uri}&family_names={lastname}&given_names={firstname}&email={emailaddress}' 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'] @@ -19,7 +22,24 @@ postgres_db = os.getenv('POSTGRES_DB') app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql://{postgres_user}:{postgres_pwd}@{postgres_db_host}:{postgres_port}/{postgres_db}' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db_key_file = os.getenv('DB_ENCRYPTION_FILE') +app.config['db_encryption_key'] = load_encryption_key(db_key_file) db = SQLAlchemy(app) db.create_all() import orcidflask.views + +@app.cli.command('create-secret-key') +@click.argument('file') +def create_secret_key(file): + ''' + Creates a new database encryption key and saves to the provided file path. Will not overwrite the existing file, if it exists. + ''' + store_encryption_key(file) + +@app.cli.command('reset-db') +def reset_db(): + ''' + Resets the associated database by dropping all tables. Warning: for development purposes only. Do not run on a production instance without first backing up the database, as this command will result in the loss of all data. + ''' + db.drop_all() \ No newline at end of file diff --git a/orcidflask/models.py b/orcidflask/models.py index 51a19c6..04a36bc 100644 --- a/orcidflask/models.py +++ b/orcidflask/models.py @@ -1,11 +1,38 @@ -from orcidflask import db +from orcidflask import db, app from sqlalchemy.sql import func +from sqlalchemy import TypeDecorator +from cryptography.fernet import Fernet + +def fernet_encrypt(data): + ''' + Encrypts data using the Fernet algorithm with the key set in the app's config object + ''' + fernet = Fernet(app.config['db_encryption_key']) + return fernet.encrypt(data.encode()) + + +def fernet_decrypt(data): + ''' + Decrypts data using the Fernet algorithm with the key set in the app's config object + ''' + fernet = Fernet(app.config['db_encryption_key']) + return fernet.decrypt(data).decode() + +class EncryptedValue(TypeDecorator): + impl = db.LargeBinary + + def process_bind_param(self, value, dialect): + return fernet_encrypt(value) + + def process_result_value(self, value, dialect): + return fernet_decrypt(value) + class Token(db.Model): id = db.Column(db.Integer, primary_key=True) userId = db.Column(db.String(80), unique=False, nullable=False) - access_token = db.Column(db.String(80), unique=False, nullable=False) - refresh_token = db.Column(db.String(80), unique=False, nullable=False) + access_token = db.Column(EncryptedValue, unique=False, nullable=False) + refresh_token = db.Column(EncryptedValue, unique=False, nullable=False) expires_in = db.Column(db.Integer, nullable=False) token_scope = db.Column(db.String(80), unique=False, nullable=False) orcid = db.Column(db.String(80), unique=False, nullable=False) diff --git a/orcidflask/templates/oauth_error.html b/orcidflask/templates/oauth_error.html new file mode 100644 index 0000000..e849035 --- /dev/null +++ b/orcidflask/templates/oauth_error.html @@ -0,0 +1 @@ +We apologize for the inconvenience. Something is wrong with the service at the moment. Please try again later. \ No newline at end of file diff --git a/orcidflask/templates/orcid_denied.html b/orcidflask/templates/orcid_denied.html new file mode 100644 index 0000000..b53717a --- /dev/null +++ b/orcidflask/templates/orcid_denied.html @@ -0,0 +1 @@ +You did not agree to make GW a trusted partner. \ No newline at end of file diff --git a/orcidflask/views.py b/orcidflask/views.py index 46fd80f..2ead49f 100644 --- a/orcidflask/views.py +++ b/orcidflask/views.py @@ -68,19 +68,13 @@ def index(): elif auth.get_settings().is_debug_active(): error_reason = auth.get_last_error_reason() - attributes, paint_logout = get_attributes(session) - - print('Errors: ', errors) + # Redirect for login if no params provided + else: + # Remove the scopes param in order to solicit scopes from users + return redirect(auth.login(return_to=url_for('orcid_login', scopes='/read-limited'))) - 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 - ) + # Redirect from logout process + return redirect(app.config['SLO_REDIRECT']) @app.route('/attrs/') def attrs(): @@ -111,37 +105,51 @@ def orcid_login(): 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 - if request.method == 'POST': - app.logger.debug(request.form) - scopes = ' '.join(request.form.keys()) + scopes = request.args.get('scopes') + # If no SAML attributes, redirect for SSO + if not session.get('samlNameId'): + return redirect(url_for('index')) + # If the scopes param is part of the request, we're not using the form + elif scopes or request.method == 'POST': + # Get the scopes from the form is not part of the URL + if not scopes: + scopes = ' '.join(request.form.keys()) + # Get user data from SAML for registration form + saml_user_data = extract_saml_user_data(session) return redirect(app.config['orcid_auth_url'].format(orcid_client_id=app.config['CLIENT_ID'], scopes=scopes, redirect_uri=url_for('orcid_redirect', _scheme='https', - _external=True))) - return render_template('orcid_login.html') + _external=True), + **saml_user_data)) + # Used when not passing in scopes from the SLO process (i.e., when getting from the user) + else: + return render_template('orcid_login.html') @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 + # Redirect here for access denied page + if request.args.get('error') == 'access_denied': + return redirect(app.config['ORCID_FAILURE_URL']) + + elif request.args.get('error'): + app.logger.error(f'OAuth Error {request.args.get("error")};') + return render_template('oauth_error.html') + orcid_code = request.args.get('code') headers = {'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded'} try: - app.logger.debug(prepare_token_payload(orcid_code)) response = requests.post(app.config['orcid_token_url'], headers=headers, data=prepare_token_payload(orcid_code)) - app.logger.debug(response.text) response.raise_for_status() except HTTPError as e: - # TO DO: handle HTTP errors - raise + app.logger.error(f'HTTPError {response.status_code}; Message {response.text}') + return render_template('oauth_error.html') orcid_auth = response.json() # Get the user's ID from the SSO process saml_id = session.get('samlNameId') @@ -158,5 +166,6 @@ def orcid_redirect(): db.session.add(token) db.session.commit() - # return success page - return render_template('orcid_success.html', saml_id=saml_id, orcid_auth={k: v for k,v in orcid_auth.items() if not k.endswith('token')}) + # return success page - testing only + #return render_template('orcid_success.html', saml_id=saml_id, orcid_auth={k: v for k,v in orcid_auth.items() if not k.endswith('token')}) + return redirect(app.config['ORCID_SUCCESS_URL']) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 15824fa..b2362e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ certifi==2020.12.5 chardet==4.0.0 click==7.1.2 +cryptography==38.0.1 defusedxml==0.6.0 Flask==1.1.2 Flask-SQLAlchemy==2.5.1