Skip to content

Commit

Permalink
T16 encrypt tokens (#19)
Browse files Browse the repository at this point in the history
Implements database storage and encryption
  • Loading branch information
dolsysmith authored Oct 18, 2022
1 parent d039136 commit 0f52de0
Show file tree
Hide file tree
Showing 10 changed files with 143 additions and 32 deletions.
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion example.config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
SECRET_KEY = b''
CLIENT_ID = ''
CLIENT_SECRET = ''
CLIENT_SECRET = ''
SLO_REDIRECT = ''
ORCID_SUCCESS_URL = ''
ORCID_FAILURE_URL = ''
1 change: 1 addition & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ POSTGRES_PASSWORD=orcidpass
POSTGRES_DB=orcidig
POSTGRES_DB_HOST=db
POSTGRES_PORT=5432
DB_ENCRYPTION_FILE=./db-encrypt.key
49 changes: 48 additions & 1 deletion orcid_utils.py
Original file line number Diff line number Diff line change
@@ -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):
'''
Expand All @@ -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')}
'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
22 changes: 21 additions & 1 deletion orcidflask/__init__.py
Original file line number Diff line number Diff line change
@@ -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']
Expand All @@ -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()
33 changes: 30 additions & 3 deletions orcidflask/models.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
1 change: 1 addition & 0 deletions orcidflask/templates/oauth_error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
We apologize for the inconvenience. Something is wrong with the service at the moment. Please try again later.
1 change: 1 addition & 0 deletions orcidflask/templates/orcid_denied.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
You did not agree to make GW a trusted partner.
61 changes: 35 additions & 26 deletions orcidflask/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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')
Expand All @@ -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'])
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down

0 comments on commit 0f52de0

Please sign in to comment.