Skip to content
This repository has been archived by the owner on Dec 18, 2024. It is now read-only.

Commit

Permalink
Remove connexion
Browse files Browse the repository at this point in the history
https://mhclgdigital.atlassian.net/browse/FSPT-189

In the process of merging authenticator with pre-award-frontend, we
found that the versions of Flask and Connexion in use between everything
are incompatible.

We remove connexion here because it takes us in the general direction of
travel (everything is just a Flask app), and allows us to integrate the
authenticator code (after this change) with pre-award-frontend (which
doesn't use connexion currently). This feels better than adding
connexion to pre-award-frontend, to then remove it again later.
  • Loading branch information
samuelhwilliams committed Dec 12, 2024
1 parent 3adb765 commit 4260c95
Show file tree
Hide file tree
Showing 20 changed files with 234 additions and 507 deletions.
4 changes: 2 additions & 2 deletions api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from api.magic_links.routes import MagicLinksView # noqa
from api.session.auth_session import AuthSessionView # noqa
from api.sso.routes import SsoView # noqa
from api.session.auth_session import AuthSessionUserView, AuthSessionSignOutView # noqa
from api.sso.routes import SsoLoginView, SsoLogoutView, SsoGraphCallView, SsoGetTokenView # noqa
13 changes: 9 additions & 4 deletions api/magic_links/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
from flask.views import MethodView
from fsd_utils.authentication.decorators import login_requested

from api.session.auth_session import AuthSessionView
from api.session.auth_session import AuthSessionBase
from common.blueprints import Blueprint
from config import Config
from models.account import AccountMethods
from models.magic_link import MagicLinkMethods

api_magic_link_bp = Blueprint("api_magic_links", __name__)


class MagicLinksView(MagicLinkMethods, MethodView):
@login_requested
def use(self, link_id: str):
def get(self, link_id: str):
"""
GET /magic-links/{link_id} endpoint
If the link_id matches a valid link key, this then:
Expand All @@ -28,7 +31,6 @@ def use(self, link_id: str):
:param link_id: String short key for the link
:return: 302 Redirect / 404 Error
"""

fund_short_name = request.args.get("fund")
round_short_name = request.args.get("round")

Expand Down Expand Up @@ -62,7 +64,7 @@ def use(self, link_id: str):

# Check link is not expired
if link.get("exp") > int(datetime.now().timestamp()):
return AuthSessionView.create_session_and_redirect(
return AuthSessionBase.create_session_and_redirect(
account=account,
is_via_magic_link=True,
redirect_url=link.get("redirectUrl"),
Expand Down Expand Up @@ -105,3 +107,6 @@ def use(self, link_id: str):
round=round_short_name,
)
)


api_magic_link_bp.add_url_rule("/magic-links/<link_id>", view_func=MagicLinksView.as_view("use"))
86 changes: 49 additions & 37 deletions api/session/auth_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
from typing import TYPE_CHECKING

import jwt
from flask import abort, current_app, make_response, redirect, request, session, url_for
from flask import current_app, make_response, redirect, request, session, url_for
from flask.views import MethodView
from fsd_utils import clear_sentry

from api.responses import error_response
from api.session.exceptions import SessionCreateError
from common.blueprints import Blueprint
from config import Config
from models.magic_link import MagicLinkMethods
from security.utils import create_token, decode_with_options, validate_token
Expand All @@ -16,28 +17,11 @@
from models.account import Account as Account


class AuthSessionView(MethodView):
"""
Views for session related operations
"""
api_sessions_bp = Blueprint("api_sessions", __name__)

@staticmethod
def user():
"""
GET /sessions/user endpoint
Shows the user details of the current user session
or an error if no authenticated user session found
:return: 200 user details json or 404 error
"""
token = request.cookies.get(Config.FSD_USER_TOKEN_COOKIE_NAME)
if token:
try:
valid_token = validate_token(token)
return make_response(valid_token), 200
except jwt.PyJWTError:
error_response(404, "Session token expired or invalid")
error_response(404, "No session token found")

class AuthSessionBase:
@staticmethod
def clear_session(return_app=None, return_path=None):
"""
Clears the user session (signing them out)
Expand Down Expand Up @@ -90,7 +74,9 @@ def clear_session(return_app=None, return_path=None):
)
else:
current_app.logger.warning("{return_app} not listed as a safe app.", extra=dict(return_app=return_app))
abort(400, "Unknown return app.")
resp = make_response({"detail": "Unknown return app."}, 400)
resp.headers["Content-Type"] = "application/json"
return resp

# Clear the cookie and redirect to signed out page
signed_out_url = url_for(
Expand All @@ -110,21 +96,6 @@ def clear_session(return_app=None, return_path=None):
)
return response

# Deprecation warning (Use clear_session_post instead)
@staticmethod
def clear_session_get():
"""GET /sessions/sign-out endpoint"""
return_app = request.args.get("return_app")
return_path = request.args.get("return_path")
return AuthSessionView.clear_session(return_app, return_path)

@staticmethod
def clear_session_post():
"""POST /sessions/sign-out endpoint"""
return_app = request.form.get("return_app")
return_path = request.form.get("return_path")
return AuthSessionView.clear_session(return_app, return_path)

@classmethod
def create_session_and_redirect(
cls,
Expand Down Expand Up @@ -233,3 +204,44 @@ def create_session_details_with_fund_and_round(
session_details.update({"token": create_token(session_details)})
session.update(session_details)
return session_details


class AuthSessionSignOutView(AuthSessionBase, MethodView):
"""
Views for session related operations
"""

# Deprecation warning (Use clear_session_post instead)
def get(self):
"""GET /sessions/sign-out endpoint"""
return_app = request.args.get("return_app")
return_path = request.args.get("return_path")
return self.clear_session(return_app, return_path)

def post(self):
"""POST /sessions/sign-out endpoint"""
return_app = request.form.get("return_app")
return_path = request.form.get("return_path")
return self.clear_session(return_app, return_path)


class AuthSessionUserView(AuthSessionBase, MethodView):
def get(self):
"""
GET /sessions/user endpoint
Shows the user details of the current user session
or an error if no authenticated user session found
:return: 200 user details json or 404 error
"""
token = request.cookies.get(Config.FSD_USER_TOKEN_COOKIE_NAME)
if token:
try:
valid_token = validate_token(token)
return make_response(valid_token), 200
except jwt.PyJWTError:
error_response(404, "Session token expired or invalid")
error_response(404, "No session token found")


api_sessions_bp.add_url_rule("/sessions/user", view_func=AuthSessionUserView.as_view("user"))
api_sessions_bp.add_url_rule("/sessions/sign-out", view_func=AuthSessionSignOutView.as_view("sign_out"))
145 changes: 82 additions & 63 deletions api/sso/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,56 @@
from flask.views import MethodView
from fsd_utils import clear_sentry

from api.session.auth_session import AuthSessionView
from api.session.auth_session import AuthSessionBase
from common.blueprints import Blueprint
from config import Config
from models.account import AccountMethods

api_sso_bp = Blueprint("api_sso", __name__, url_prefix="/sso")

class SsoView(MethodView):
def login(self):
"""
GET /sso/login endpoint
Redirects to the Azure AD auth uri
:return: 302 redirect to Microsoft Login
"""
session["flow"] = self.build_auth_code_flow(scopes=Config.MS_GRAPH_PERMISSIONS_SCOPE)

if return_app := request.args.get("return_app"):
session["return_app"] = return_app
session["return_path"] = request.args.get("return_path")
current_app.logger.debug(
"Setting return app to {return_app} for this session", extra=dict(return_app=return_app)
)
class SsoBase:
@staticmethod
def _load_cache():
cache = msal.SerializableTokenCache()
if session.get("token_cache"):
cache.deserialize(session["token_cache"])
return cache

return redirect(session["flow"]["auth_uri"]), 302
@staticmethod
def _save_cache(cache):
if cache.has_state_changed:
session["token_cache"] = cache.serialize()

def logout(post_logout_redirect_uri=None):
@staticmethod
def _build_msal_app(cache=None, authority=None):
return msal.ConfidentialClientApplication(
Config.AZURE_AD_CLIENT_ID,
authority=authority or Config.AZURE_AD_AUTHORITY,
client_credential=Config.AZURE_AD_CLIENT_SECRET,
token_cache=cache,
)

@staticmethod
def _get_origin_from_url(url):
parsed_uri = urlparse(url)
return f"{parsed_uri.scheme}://{parsed_uri.netloc}"

def build_auth_code_flow(self, authority=None, scopes=None):
return self._build_msal_app(authority=authority).initiate_auth_code_flow(
scopes or [], redirect_uri=Config.AZURE_AD_REDIRECT_URI
)

def _get_token_from_cache(self, scope=None):
cache = self._load_cache() # This web app maintains one cache per session
cca = self._build_msal_app(cache=cache)
accounts = cca.get_accounts()
if accounts: # So all account(s) belong to the current signed-in user
result = cca.acquire_token_silent(scope, account=accounts[0])
self._save_cache(cache)
return result

def logout(self, post_logout_redirect_uri=None):
"""
Clears the user session then redirects to
Azure AD logout endpoint to logout from our tenants web session too
Expand Down Expand Up @@ -57,28 +83,51 @@ def logout(post_logout_redirect_uri=None):
clear_sentry()
return response


class SsoLoginView(SsoBase, MethodView):
def get(self):
"""
GET /sso/login endpoint
Redirects to the Azure AD auth uri
:return: 302 redirect to Microsoft Login
"""
session["flow"] = self.build_auth_code_flow(scopes=Config.MS_GRAPH_PERMISSIONS_SCOPE)

if return_app := request.args.get("return_app"):
session["return_app"] = return_app
session["return_path"] = request.args.get("return_path")
current_app.logger.debug(
"Setting return app to {return_app} for this session", extra=dict(return_app=return_app)
)

return redirect(session["flow"]["auth_uri"]), 302


class SsoLogoutView(SsoBase, MethodView):
# Deprecation warning (Use logout_post instead)
def logout_get(self):
def get(self):
"""GET /sso/logout endpoint"""
post_logout_redirect_uri = request.args.get(
"post_logout_redirect_uri",
Config.SSO_POST_SIGN_OUT_URL + f"?{urlencode({'return_app': session['return_app']})}"
if "return_app" in session
else "",
)
return SsoView.logout(post_logout_redirect_uri)
return self.logout(post_logout_redirect_uri)

def logout_post(self):
def post(self):
"""POST /sso/logout endpoint"""
post_logout_redirect_uri = request.form.get(
"post_logout_redirect_uri",
Config.SSO_POST_SIGN_OUT_URL + f"?{urlencode({'return_app': session['return_app']})}"
if "return_app" in session
else "",
)
return SsoView.logout(post_logout_redirect_uri)
return self.logout(post_logout_redirect_uri)

def get_token(self):

class SsoGetTokenView(SsoBase, MethodView):
def get(self):
"""
GET /sso/get-token
The endpoint that Azure AD redirects back to
Expand Down Expand Up @@ -127,17 +176,21 @@ def get_token(self):
)
else:
current_app.logger.warning("{return_app} not listed as a safe app.", extra=dict(return_app=return_app))
abort(400, "Unknown return app.")
resp = make_response({"detail": "Unknown return app."}, 400)
resp.headers["Content-Type"] = "application/json"
return resp

# Create session token, set cookie and redirect
return AuthSessionView.create_session_and_redirect(
return AuthSessionBase.create_session_and_redirect(
account=updated_account,
redirect_url=redirect_url,
is_via_magic_link=False,
timeout_seconds=Config.FSD_ASSESSMENT_SESSION_TIMEOUT_SECONDS,
)

def graph_call(self):

class SsoGraphCallView(SsoBase, MethodView):
def get(self):
"""
GET /sso/graph-call endpoint
Shows the graph object for the current authenticated user
Expand All @@ -153,42 +206,8 @@ def graph_call(self):
).json()
return graph_data, 200

@staticmethod
def _load_cache():
cache = msal.SerializableTokenCache()
if session.get("token_cache"):
cache.deserialize(session["token_cache"])
return cache

@staticmethod
def _save_cache(cache):
if cache.has_state_changed:
session["token_cache"] = cache.serialize()

@staticmethod
def _build_msal_app(cache=None, authority=None):
return msal.ConfidentialClientApplication(
Config.AZURE_AD_CLIENT_ID,
authority=authority or Config.AZURE_AD_AUTHORITY,
client_credential=Config.AZURE_AD_CLIENT_SECRET,
token_cache=cache,
)

@staticmethod
def _get_origin_from_url(url):
parsed_uri = urlparse(url)
return f"{parsed_uri.scheme}://{parsed_uri.netloc}"

def build_auth_code_flow(self, authority=None, scopes=None):
return self._build_msal_app(authority=authority).initiate_auth_code_flow(
scopes or [], redirect_uri=Config.AZURE_AD_REDIRECT_URI
)

def _get_token_from_cache(self, scope=None):
cache = self._load_cache() # This web app maintains one cache per session
cca = self._build_msal_app(cache=cache)
accounts = cca.get_accounts()
if accounts: # So all account(s) belong to the current signed-in user
result = cca.acquire_token_silent(scope, account=accounts[0])
self._save_cache(cache)
return result
api_sso_bp.add_url_rule("/login", view_func=SsoLoginView.as_view("login"))
api_sso_bp.add_url_rule("/logout", view_func=SsoLogoutView.as_view("logout"))
api_sso_bp.add_url_rule("/get-token", view_func=SsoGetTokenView.as_view("get_token"))
api_sso_bp.add_url_rule("/graph-call", view_func=SsoGraphCallView.as_view("graph_call"))
Loading

0 comments on commit 4260c95

Please sign in to comment.