From 8d815e5ee6db4f98af5136f3a1c733bb98a4232c Mon Sep 17 00:00:00 2001 From: Bjarke Madsen <bjarke@nordu.net> Date: Thu, 15 Jun 2023 19:03:47 +0200 Subject: [PATCH] Add login management with OAuth2 and flask-login --- compendium_v2/__init__.py | 23 +++++++++++-- compendium_v2/auth/__init__.py | 32 ++++++++++++++++++ compendium_v2/auth/session_management.py | 25 ++++++++++++++ compendium_v2/routes/authentication.py | 42 ++++++++++++++++++++++++ compendium_v2/routes/default.py | 4 ++- 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 compendium_v2/auth/__init__.py create mode 100644 compendium_v2/auth/session_management.py create mode 100644 compendium_v2/routes/authentication.py diff --git a/compendium_v2/__init__.py b/compendium_v2/__init__.py index dbc2d798..1a41ef96 100644 --- a/compendium_v2/__init__.py +++ b/compendium_v2/__init__.py @@ -12,9 +12,14 @@ from flask import Flask from flask_cors import CORS # for debugging # the currently available stubs for flask_migrate are old (they depend on sqlalchemy 1.4 types) from flask_migrate import Migrate, upgrade # type: ignore +from flask_login import LoginManager # type: ignore from compendium_v2 import config, environment from compendium_v2.db import db +from compendium_v2.auth import setup_oauth +from compendium_v2.auth.session_management import setup_login_manager + + sentry_dsn = os.getenv('SENTRY_DSN') if sentry_dsn: sentry_sdk.init( @@ -24,6 +29,8 @@ if sentry_dsn: environment.setup_logging() +logger = logging.getLogger(__name__) + def _create_app(app_config) -> Flask: # used by sphinx to create documentation without config and db migrations @@ -31,10 +38,19 @@ def _create_app(app_config) -> Flask: CORS(app) app.config['CONFIG_PARAMS'] = app_config + app.config['SECRET_KEY'] = app_config['SECRET_KEY'] + if 'oidc' not in app_config: + app.config['LOGIN_DISABLED'] = True + logger.info('No OIDC configuration found, authentication disabled') + else: + logger.info('OIDC configuration found, authentication will be enabled') from compendium_v2.routes import default app.register_blueprint(default.routes, url_prefix='/') + from compendium_v2.routes import authentication + app.register_blueprint(authentication.routes, url_prefix='/') + from compendium_v2.routes import api app.register_blueprint(api.routes, url_prefix='/api') @@ -72,8 +88,11 @@ def create_app() -> Flask: Migrate(app, db, directory=os.path.join(os.path.dirname(__file__), 'migrations')) logging.info('Flask app initialized') - - environment.setup_logging() + login_manager = LoginManager() + login_manager.init_app(app) + login_manager.login_view = 'authentication.login' + setup_login_manager(login_manager) + setup_oauth(app, app_config.get('oidc')) # run migrations on startup with app.app_context(): diff --git a/compendium_v2/auth/__init__.py b/compendium_v2/auth/__init__.py new file mode 100644 index 00000000..b7749107 --- /dev/null +++ b/compendium_v2/auth/__init__.py @@ -0,0 +1,32 @@ +from flask import Flask +from authlib.integrations.flask_client import OAuth, FlaskOAuth2App # type: ignore + +oauth = None + + +def setup_oauth(app: Flask, oidc_config: dict): + global oauth + + if oauth is not None: + return + + oauth = OAuth(app) + + if oidc_config is None: + return + + oauth.register( + name='provider', + client_id=oidc_config['client_id'], + client_secret=oidc_config['client_secret'], + server_metadata_url=oidc_config['server_metadata_url'], + client_kwargs={ + 'scope': ' '.join([ + 'email', + 'profile' + ])}, + ) + + +def get_client() -> FlaskOAuth2App: + return oauth.create_client('provider') # type: ignore diff --git a/compendium_v2/auth/session_management.py b/compendium_v2/auth/session_management.py new file mode 100644 index 00000000..39f8eae5 --- /dev/null +++ b/compendium_v2/auth/session_management.py @@ -0,0 +1,25 @@ +from flask_login import LoginManager, UserMixin # type: ignore + + +# TODO: implement user model as SQLAlchemy model +class User(UserMixin): + pass + + +def fetch_user(email: str): + """ + Function used to retrieve the internal user model for the user attempting login. + + :param profile: The email of the user attempting login. + :return: User object if the user exists, None otherwise. + """ + + # TODO: fetch user from database instead of just creating a user object + user = User() + user.id = email + return user + + +def setup_login_manager(login_manager: LoginManager): + + login_manager.user_loader(fetch_user) diff --git a/compendium_v2/routes/authentication.py b/compendium_v2/routes/authentication.py new file mode 100644 index 00000000..6ab351b7 --- /dev/null +++ b/compendium_v2/routes/authentication.py @@ -0,0 +1,42 @@ + +from flask_login import login_required, login_user, logout_user # type: ignore +from flask import Blueprint, url_for, redirect +from compendium_v2.auth import get_client +from compendium_v2.auth.session_management import fetch_user + +routes = Blueprint('authentication', __name__) + + +@routes.route('/login') +def login(): + client = get_client() + + # _external uses headers to determine the full URL when behind a reverse proxy + redirect_uri = url_for('authentication.authorize', _external=True) + return client.authorize_redirect(redirect_uri) + + +@routes.route('/authorize') +def authorize(): + client = get_client() + token = client.authorize_access_token() + + profile = client.userinfo(token=token) + + if 'email' not in profile or not profile['email']: + return '<h3>Authentication failed: Invalid user response from provider</h3>', 400 + + user = fetch_user(profile['email']) + login_user(user) + + # redirect to /survey since we only require login for this part of the app + return redirect(url_for('compendium-v2-default.survey_index')) + + +@routes.route("/logout") +@login_required +def logout(): + # The user will be logged out of the application, but not the IDP. + # If they visit again before their oauth token expires, they are immediately logged in. + logout_user() + return redirect('/') diff --git a/compendium_v2/routes/default.py b/compendium_v2/routes/default.py index 19f01076..8583d55e 100644 --- a/compendium_v2/routes/default.py +++ b/compendium_v2/routes/default.py @@ -1,6 +1,6 @@ import pkg_resources from flask import Blueprint, jsonify, render_template, Response - +from flask_login import login_required # type: ignore from compendium_v2.routes import common routes = Blueprint('compendium-v2-default', __name__) @@ -45,7 +45,9 @@ def index(path): @routes.route('/survey', defaults={'path': ''}, methods=['GET']) +@routes.route('/survey/', defaults={'path': ''}, methods=['GET']) @routes.route('/survey/<path:path>', methods=['GET']) +@login_required def survey_index(path): is_api = path.startswith('api') -- GitLab