Skip to content

T26 factory pattern #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# Ignore generated files
**/*.pyc
**/*.pyc
config.py
**/__pycache__
orcidflask/saml
.DS_Store
.git
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
config.py
./config.py
__pycache__
orcidflask/__pycache__
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ COPY *.py ./
COPY requirements.txt .
COPY migrations ./migrations
COPY orcidflask/*.py ./orcidflask/
COPY orcidflask/templates ./orcidflask/templates/
COPY orcidflask/registration ./orcidflask/registration
COPY orcidflask/api ./orcidflask/api
COPY orcidflask/db ./orcidflask/db


RUN pip install -r requirements.txt

ENV FLASK_APP=orcidflask
ENV ORCIDFLASK_SETTINGS=/opt/orcid_integration/config.py

CMD [ "gunicorn", "-b", "0.0.0.0:8080", "orcidflask:app" ]
CMD [ "gunicorn", "-b", "0.0.0.0:8080", "orcidflask:create_app()" ]
39 changes: 34 additions & 5 deletions example.docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
version: "2"
services:
db:
image: postgres
image: postgres:16.2
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_DB=${POSTGRES_DB}
user: "1000:1000"
volumes:
- ./data:/var/lib/postgresql/data
restart: always
flask-app:
# Use the tagged image in production
#image: ghcr.io/gwu-libraries/orcid-integration-flask-app:1.1
build:
context: .
dockerfile: Dockerfile
image: flask-app
ports:
- 8080:8080
links:
Expand All @@ -28,11 +33,35 @@ services:
volumes:
- ./orcidflask/saml:/opt/orcid_integration/orcidflask/saml
- ./config.py:/opt/orcid_integration/config.py
- ./orcidflask/db:/opt/orcid_integration/orcidflask/db
- ./certs/db-encrypt.key:/opt/orcid_integration/db-encrypt.key
# Uncomment for use in development
#- .:/opt/orcid_integration
restart: always
token-api:
# Use tagged image in production
#image: ghcr.io/gwu-libraries/orcid-integration-flask-app:1.1
image: flask-app
ports:
- 8081:8081
links:
- db:db
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_DB_HOST=${POSTGRES_DB_HOST}
- POSTGRES_PORT=${POSTGRES_PORT}
- DB_ENCRYPTION_FILE=${DB_ENCRYPTION_FILE}
volumes:
- ./orcidflask/saml:/opt/orcid_integration/orcidflask/saml
- ./config.py:/opt/orcid_integration/config.py
- ./certs/db-encrypt.key:/opt/orcid_integration/db-encrypt.key
# Uncomment for development
#- .:/opt/orcid_integration
command: gunicorn -b 0.0.0.0:8081 "orcidflask:create_app('api')"
restart: always
nginx-proxy:
image: nginxproxy/nginx-proxy:1.5
image: nginxproxy/nginx-proxy:1.6.2
environment:
- LOG_JSON=true
ports:
Expand All @@ -42,5 +71,5 @@ services:
- /var/run/docker.sock:/tmp/docker.sock:ro
# Note that the nginxproxy image require cert & key to reside in the same directory
# And to follow certain naming conventions
- /etc/ssl/certs:/etc/nginx/certs
- ./certs:/etc/nginx/certs
restart: always
2 changes: 1 addition & 1 deletion example.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ POSTGRES_PASSWORD=orcidpass
POSTGRES_DB=orcidig
POSTGRES_DB_HOST=db
POSTGRES_PORT=5432
DB_ENCRYPTION_FILE=/opt/orcid_integration/orcidflask/db/db-encrypt.key
DB_ENCRYPTION_FILE=/opt/orcid_integration/db-encrypt.key
# Values are sandbox or prod
ORCID_SERVER=sandbox
VIRTUAL_HOST=
35 changes: 35 additions & 0 deletions migrations/versions/aa4b644dbbe2_adding_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Adding API

Revision ID: aa4b644dbbe2
Revises: ac9a61050c66
Create Date: 2025-04-30 13:19:30.743197

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'aa4b644dbbe2'
down_revision = 'ac9a61050c66'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('api_key',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('api_key', sa.String(length=36), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('userId', sa.String(length=80), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('api_key')
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('api_key')
# ### end Alembic commands ###
2 changes: 1 addition & 1 deletion migrations/versions/ac9a61050c66_initial_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"""
from alembic import op
import sqlalchemy as sa
from orcidflask.models import EncryptedValue
from orcidflask.db.models import EncryptedValue


# revision identifiers, used by Alembic.
Expand Down
2 changes: 1 addition & 1 deletion orcid_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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('registration.orcid_redirect', _external=True, _scheme='https')}

def extract_saml_user_data(session, populate=True):
'''
Expand Down
107 changes: 71 additions & 36 deletions orcidflask/__init__.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,84 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask.cli import with_appcontext
from flask_migrate import Migrate
import os
import click
from orcid_utils import load_encryption_key, new_encryption_key
from orcidflask.registration.views import registration
from orcidflask.api.views import api
import json
from orcidflask.db import db
from datetime import datetime as dt

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')
# Set the ORCID URL based on the setting in default_settings.py
if os.getenv('ORCID_SERVER') == 'sandbox':
base_url = 'https://sandbox.orcid.org'
else:
base_url = 'https://orcid.org'
# Personal attributes from SAML metadata definitions
app.config['orcid_auth_url'] = base_url + '/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_register_url'] = base_url + '/oauth/authorize?client_id={orcid_client_id}&response_type=code&scope={scopes}&redirect_uri={redirect_uri}&family_names={lastname}&given_names={firstname}&email={emailaddress}&show_login=false'
app.config['orcid_token_url'] = base_url + '/oauth/token'
app.config['SAML_PATH'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'saml')
app.config["SESSION_COOKIE_DOMAIN"] = app.config["SERVER_NAME"]
app.secret_key = app.config['SECRET_KEY']
postgres_user = os.getenv('POSTGRES_USER')
postgres_pwd = os.getenv('POSTGRES_PASSWORD')
postgres_db_host = os.getenv('POSTGRES_DB_HOST')
postgres_port = os.getenv('POSTGRES_PORT')
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)
migrate = Migrate(app, db)
migrate = Migrate()

import orcidflask.views
from orcidflask.models import Token
def create_app(blueprint: str='registration'):
'''
Application factory. The argument should be either "registration" or "api", depending on the version of the application to be started. For invocation, see https://flask.palletsprojects.com/en/stable/cli/
'''
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')
# Set the ORCID URL based on the setting in default_settings.py
if os.getenv('ORCID_SERVER') == 'sandbox':
base_url = 'https://sandbox.orcid.org'
else:
base_url = 'https://orcid.org'
# Personal attributes from SAML metadata definitions
app.config['orcid_auth_url'] = base_url + '/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_register_url'] = base_url + '/oauth/authorize?client_id={orcid_client_id}&response_type=code&scope={scopes}&redirect_uri={redirect_uri}&family_names={lastname}&given_names={firstname}&email={emailaddress}&show_login=false'
app.config['orcid_token_url'] = base_url + '/oauth/token'
app.config['SAML_PATH'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'saml')
app.config["SESSION_COOKIE_DOMAIN"] = app.config["SERVER_NAME"]
app.secret_key = app.config['SECRET_KEY']
postgres_user = os.getenv('POSTGRES_USER')
postgres_pwd = os.getenv('POSTGRES_PASSWORD')
postgres_db_host = os.getenv('POSTGRES_DB_HOST')
postgres_port = os.getenv('POSTGRES_PORT')
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')
if not os.getenv('TESTING'):
app.config['db_encryption_key'] = load_encryption_key(db_key_file)
db.init_app(app)
migrate.init_app(app, db)

if blueprint == 'registration':
app.register_blueprint(registration)
else:
app.register_blueprint(api)

@app.cli.command('create-secret-key')
app.cli.add_command(reset_db)
app.cli.add_command(create_secret_key)
app.cli.add_command(serialize_db)
app.cli.add_command(create_api_key)
return app

from orcidflask.db.models import Token, APIKey, generate_key

@click.command('create-secret-key')
@click.argument('file')
@with_appcontext
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)
new_encryption_key(file)

@app.cli.command('reset-db')
@click.command('reset-db')
@with_appcontext
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()

@app.cli.command('serialize-db')
@click.command('serialize-db')
@click.argument('file', type=click.File('w'))
@with_appcontext
def serialize_db(file):
'''
Serializes the database as a JSON dump. Argument should be the path to a file, preferably in a volume mapped to the container, such as /opt/orcid_integration/data
Expand All @@ -64,4 +88,15 @@ def serialize_db(file):
# convert to Python dicts
records = [record.to_dict() for record in records]
json.dump(records, file)



@click.command('create-api-key')
@click.argument('userid')
@with_appcontext
def create_api_key(userid: str):
'''userId should be an email address identifying the user for whom the key is being created.'''
api_key_str = generate_key()
api_key = APIKey(userId=userid, timestamp=dt.now(), api_key=api_key_str)
db.session.add(api_key)
db.session.commit()
print(f'API key created for user {userid} is {api_key_str}. Please pass this key as a request header when making an API call: Authorization: Apikey YOUR_API_KEY')
Empty file added orcidflask/api/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions orcidflask/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from flask import request, Blueprint, jsonify
from orcidflask.db.models import Token, APIKey
import re

auth_pattern = re.compile(r'Apikey ([A-Za-z0-9\-]{36})')

api = Blueprint('api', __name__, url_prefix='/api')

def is_valid(api_key):
return APIKey.check_api_key(api_key)

@api.route('/get-token')
def get_token():
'''GET request should include Authorization: Apikey header (with a valid API key) and an orcid URL parameter with the ORCiD of the user whose token is to be retrieved.'''
api_key = auth_pattern.match(request.headers.get('Authorization', ''))
if not api_key:
return {'message': 'Please provide a valid API key in the Authorization header of your request.'}, 403
api_key = api_key.group(1)
if not is_valid(api_key):
return {'message': 'API key has not been registered. Please have the application administrator create an API key for you.'}, 403
orcid = request.args.get('orcid')
if not orcid:
return {'message': 'Please provide a valid ORCiD as a URL parameter, e.g., "?orcid=0000-0000-0000-0000"'}, 422
access_token = Token.query.filter_by(orcid=orcid).order_by(Token.timestamp.desc()).first()
if not access_token:
return jsonify({'error': f'Entry not found for ORCiD {orcid}. Has the user registered with the GW ORCiD integration app?'})
return jsonify({'orcid': orcid,
'access_token': access_token.to_dict()['access_token']})




4 changes: 0 additions & 4 deletions orcidflask/db/.gitignore

This file was deleted.

4 changes: 4 additions & 0 deletions orcidflask/db/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()
Loading