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