From e5cc1e4c8945ec6f3e78d9e3e7d2dd911a43b962 Mon Sep 17 00:00:00 2001 From: William May Date: Fri, 29 Nov 2024 14:48:39 +0000 Subject: [PATCH] FS-4805 - Adding authentication to FAB --- app/blueprints/dev/routes.py | 17 ++++++ app/blueprints/fund_builder/routes.py | 53 ++++++++++++++++++- .../fund_builder/templates/login.html | 21 ++++++++ app/blueprints/self_serve/routes.py | 17 ++++++ app/blueprints/templates/routes.py | 7 +++ app/create_app.py | 5 ++ app/templates/403.html | 13 +++++ config/envs/default.py | 10 ++++ config/envs/development.py | 9 ++++ .../fsd-fund-application-builder/manifest.yml | 3 +- pyproject.toml | 2 +- tests/test_routes.py | 22 ++++++++ uv.lock | 10 ++-- 13 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 app/blueprints/fund_builder/templates/login.html create mode 100644 app/templates/403.html diff --git a/app/blueprints/dev/routes.py b/app/blueprints/dev/routes.py index b0396367..aa57abca 100644 --- a/app/blueprints/dev/routes.py +++ b/app/blueprints/dev/routes.py @@ -3,6 +3,9 @@ from flask import redirect from flask import request from flask import url_for +from fsd_utils.authentication.decorators import SupportedApp +from fsd_utils.authentication.decorators import check_internal_user +from fsd_utils.authentication.decorators import login_required from app.blueprints.self_serve.data.data_access import clear_all_responses from app.blueprints.self_serve.data.data_access import get_all_components @@ -22,6 +25,8 @@ @dev_bp.route("/responses") +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_responses(): """ Retrieves all responses received from a 'Save per page' callback when a form is in preview mode. @@ -32,6 +37,8 @@ def view_responses(): @dev_bp.route("/responses/clear") +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def clear_responses(): """ Clears all responses received from a 'Save per page' callback when a form is in preview mode. @@ -41,6 +48,8 @@ def clear_responses(): @dev_bp.route("/save", methods=["PUT"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def save_per_page(): """ Mock version of the 'save per page' route in application store - used to save and enable viewing of save @@ -66,24 +75,32 @@ def save_per_page(): @dev_bp.route("/forms") +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_forms(): forms = get_saved_forms() return forms @dev_bp.route("/pages") +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_pages(): forms = get_all_pages() return forms @dev_bp.route("/sections") +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_sections(): forms = get_all_sections() return forms @dev_bp.route("/questions") +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_questions(): forms = get_all_components() return forms diff --git a/app/blueprints/fund_builder/routes.py b/app/blueprints/fund_builder/routes.py index f2d7bdbc..8f92dc66 100644 --- a/app/blueprints/fund_builder/routes.py +++ b/app/blueprints/fund_builder/routes.py @@ -11,11 +11,16 @@ from flask import Response from flask import after_this_request from flask import flash +from flask import g from flask import redirect from flask import render_template from flask import request from flask import send_file from flask import url_for +from fsd_utils.authentication.decorators import SupportedApp +from fsd_utils.authentication.decorators import check_internal_user +from fsd_utils.authentication.decorators import login_requested +from fsd_utils.authentication.decorators import login_required from app.all_questions.metadata_utils import generate_print_data_for_sections from app.blueprints.fund_builder.forms.fund import FundForm @@ -46,7 +51,9 @@ from app.db.queries.round import get_round_by_id from app.db.queries.round import update_round from app.export_config.generate_all_questions import print_html -from app.export_config.generate_assessment_config import generate_assessment_config_for_round +from app.export_config.generate_assessment_config import ( + generate_assessment_config_for_round, +) from app.export_config.generate_form import build_form_json from app.export_config.generate_fund_round_config import generate_config_for_round from app.export_config.generate_fund_round_form_jsons import ( @@ -66,12 +73,34 @@ ) +@build_fund_bp.route("/healthcheck") +def healthcheck(): + return "OK", 200 + + @build_fund_bp.route("/") +@login_requested def index(): + if not g.is_authenticated: + return redirect(url_for("build_fund_bp.login")) + return redirect(url_for("build_fund_bp.dashboard")) + + +@build_fund_bp.route("/login", methods=["GET"]) +def login(): + return render_template("login.html") + + +@build_fund_bp.route("/dashboard", methods=["GET"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user +def dashboard(): return render_template("index.html") @build_fund_bp.route("/fund/round//section", methods=["GET", "POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def section(round_id): round_obj = get_round_by_id(round_id) fund_obj = get_fund_by_id(round_obj.fund_id) @@ -134,6 +163,8 @@ def section(round_id): @build_fund_bp.route("/fund/round//section//forms", methods=["POST", "GET"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def configure_forms_in_section(round_id, section_id): if request.method == "GET": if request.args.get("action") == "remove": @@ -162,6 +193,8 @@ def all_funds_as_govuk_select_items(all_funds: list) -> list: @build_fund_bp.route("/fund/view", methods=["GET", "POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_fund(): """ Renders a template providing a drop down list of funds. If a fund is selected, renders its config info @@ -185,6 +218,8 @@ def view_fund(): @build_fund_bp.route("/fund/round//application_config") +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def build_application(round_id): """ Renders a template displaying application configuration info for the chosen round @@ -200,6 +235,8 @@ def build_application(round_id): @build_fund_bp.route("/fund//round//clone") +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def clone_round(round_id, fund_id): cloned = clone_single_round( round_id=round_id, new_fund_id=fund_id, new_short_name=f"R-C{randint(0, 999)}" # nosec B311 @@ -211,6 +248,8 @@ def clone_round(round_id, fund_id): @build_fund_bp.route("/fund", methods=["GET", "POST"]) @build_fund_bp.route("/fund/", methods=["GET", "POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def fund(fund_id=None): """ Renders a template to allow a user to add or update a fund, when saved validates the form data and saves to DB @@ -276,6 +315,8 @@ def fund(fund_id=None): @build_fund_bp.route("/round", methods=["GET", "POST"]) @build_fund_bp.route("/round/", methods=["GET", "POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def round(round_id=None): """ Renders a template to select a fund and add or update a round to that fund. If saved, validates the round form data @@ -531,6 +572,8 @@ def create_new_round(form): @build_fund_bp.route("/preview/", methods=["GET"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def preview_form(form_id): """ Generates the form json for a chosen form, does not persist this, but publishes it to the form runner using the @@ -552,6 +595,8 @@ def preview_form(form_id): @build_fund_bp.route("/download/", methods=["GET"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def download_form_json(form_id): """ Generates form json for the selected form and returns it as a file download @@ -567,6 +612,8 @@ def download_form_json(form_id): @build_fund_bp.route("/fund/round//all_questions", methods=["GET"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_all_questions(round_id): """ Generates the form data for all sections in the selected round, then uses that to generate the 'All Questions' @@ -595,6 +642,8 @@ def view_all_questions(round_id): @build_fund_bp.route("/fund/round//all_questions/", methods=["GET"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_form_questions(round_id, form_id): """ Generates the form data for this form, then uses that to generate the 'All Questions' @@ -630,6 +679,8 @@ def create_export_zip(directory_to_zip, zip_file_name, random_post_fix) -> str: @build_fund_bp.route("/create_export_files/", methods=["GET"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def create_export_files(round_id): round_short_name = get_round_by_id(round_id).short_name # Construct the path to the output directory relative to this file's location diff --git a/app/blueprints/fund_builder/templates/login.html b/app/blueprints/fund_builder/templates/login.html new file mode 100644 index 00000000..4f217e67 --- /dev/null +++ b/app/blueprints/fund_builder/templates/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{%- from 'govuk_frontend_jinja/components/button/macro.html' import govukButton -%} + +{# Override main width to two thirds #} +{% set mainClasses = "govuk-!-width-two-thirds" %} + +{% block content %} +
+

Sign in to use FAB

+

+ Sign in using the Microsoft account for your work email address. +

+ + {{ govukButton({ + "text": "Sign in using Microsoft", + "href": config['AUTHENTICATOR_HOST'] + "/sso/login?return_app=fund-application-builder", + "classes": "login-button" + }) }} +
+{% endblock content %} diff --git a/app/blueprints/self_serve/routes.py b/app/blueprints/self_serve/routes.py index 5b254854..188c18bd 100644 --- a/app/blueprints/self_serve/routes.py +++ b/app/blueprints/self_serve/routes.py @@ -8,6 +8,9 @@ from flask import render_template from flask import request from flask import url_for +from fsd_utils.authentication.decorators import SupportedApp +from fsd_utils.authentication.decorators import check_internal_user +from fsd_utils.authentication.decorators import login_required from app.all_questions.metadata_utils import generate_print_data_for_sections from app.blueprints.self_serve.data.data_access import get_all_components @@ -38,6 +41,8 @@ @self_serve_bp.route("/download_json", methods=["POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def generate_json(): form_json = generate_form_config_from_request()["form_json"] @@ -66,6 +71,8 @@ def generate_form_config_from_request(): @self_serve_bp.route("/form_questions", methods=["POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_form_questions(): form_config = generate_form_config_from_request() print_data = generate_print_data_for_sections( @@ -82,6 +89,8 @@ def view_form_questions(): @self_serve_bp.route("/section_questions", methods=["POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_section_questions(): # form_config = generate_form_config_from_request() # print_data = generate_print_data_for_sections( @@ -103,6 +112,8 @@ def view_section_questions(): # Create routes @self_serve_bp.route("section", methods=["GET", "POST", "PUT", "DELETE"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def section(): # TODO: Create frontend routes and connect to middleware if request.method == "PUT": @@ -131,6 +142,8 @@ def section(): @self_serve_bp.route("/form", methods=["GET", "POST", "PUT", "DELETE"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def form(): # TODO: Create frontend routes and connect to middleware if request.method == "PUT": @@ -169,6 +182,8 @@ def form(): @self_serve_bp.route("/page", methods=["GET", "POST", "PUT", "DELETE"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def page(): # TODO: Create frontend routes and connect to middleware if request.method == "PUT": @@ -201,6 +216,8 @@ def page(): @self_serve_bp.route("/question", methods=["GET", "PUT", "POST", "DELETE"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def question(): # TODO: Create frontend routes and connect to middleware if request.method == "PUT": diff --git a/app/blueprints/templates/routes.py b/app/blueprints/templates/routes.py index ab24556d..2a50aed4 100644 --- a/app/blueprints/templates/routes.py +++ b/app/blueprints/templates/routes.py @@ -5,6 +5,9 @@ from flask import render_template from flask import request from flask import url_for +from fsd_utils.authentication.decorators import SupportedApp +from fsd_utils.authentication.decorators import check_internal_user +from fsd_utils.authentication.decorators import login_required from werkzeug.utils import secure_filename from app.blueprints.fund_builder.forms.templates import TemplateFormForm @@ -54,6 +57,8 @@ def _build_rows(forms: list[Form]) -> list[dict]: @template_bp.route("/all", methods=["GET", "POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def view_templates(): sections = get_all_template_sections() forms = get_all_template_forms() @@ -92,6 +97,8 @@ def view_templates(): @template_bp.route("/forms/", methods=["GET", "POST"]) +@login_required(return_app=SupportedApp.FUND_APPLICATION_BUILDER) +@check_internal_user def edit_form_template(form_id): template_form = TemplateFormForm() params = { diff --git a/app/create_app.py b/app/create_app.py index 447575aa..5028f4bb 100644 --- a/app/create_app.py +++ b/app/create_app.py @@ -1,4 +1,5 @@ from flask import Flask +from flask import render_template from flask_assets import Environment from fsd_utils.logging import logging from jinja2 import ChoiceLoader @@ -62,6 +63,10 @@ def create_app() -> Flask: ) flask_app.jinja_env.add_extension("jinja2.ext.do") + @flask_app.errorhandler(403) + def forbidden_error(error): + return render_template("403.html"), 403 + return flask_app diff --git a/app/templates/403.html b/app/templates/403.html new file mode 100644 index 00000000..a355c258 --- /dev/null +++ b/app/templates/403.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

You do not have permission to access this page

+

If you believe you should have access, please contact your administrator.

+

+ Return to homepage +

+
+
+{% endblock %} diff --git a/config/envs/default.py b/config/envs/default.py index 6253fc94..549d2611 100644 --- a/config/envs/default.py +++ b/config/envs/default.py @@ -1,3 +1,4 @@ +import base64 import logging from os import environ from os import getenv @@ -12,6 +13,7 @@ class DefaultConfig(object): # Logging FSD_LOG_LEVEL = logging.WARNING + FLASK_ENV = CommonConfig.FLASK_ENV SECRET_KEY = CommonConfig.SECRET_KEY FAB_HOST = getenv("FAB_HOST", "fab:8080/") @@ -22,3 +24,11 @@ class DefaultConfig(object): TEMP_FILE_PATH = Path("/tmp") GENERATE_LOCAL_CONFIG = False + + FSD_USER_TOKEN_COOKIE_NAME = "fsd_user_token" + AUTHENTICATOR_HOST = getenv("AUTHENTICATOR_HOST", "http://authenticator.levellingup.gov.localhost:3004") # NOSONAR + + # RSA 256 Keys + RSA256_PUBLIC_KEY_BASE64 = getenv("RSA256_PUBLIC_KEY_BASE64") + if RSA256_PUBLIC_KEY_BASE64: + RSA256_PUBLIC_KEY: str = base64.b64decode(RSA256_PUBLIC_KEY_BASE64).decode() diff --git a/config/envs/development.py b/config/envs/development.py index 81aa652f..2e40584e 100644 --- a/config/envs/development.py +++ b/config/envs/development.py @@ -20,3 +20,12 @@ class DevelopmentConfig(Config): TEMP_FILE_PATH = Path("app") / "export_config" / "output" GENERATE_LOCAL_CONFIG = True + + DEBUG_USER_ON = True + DEBUG_USER = { + "full_name": "Development User", + "email": "dev@communities.gov.uk", + "roles": [], + "highest_role_map": {}, + } + DEBUG_USER_ACCOUNT_ID = "00000000-0000-0000-0000-000000000000" diff --git a/copilot/fsd-fund-application-builder/manifest.yml b/copilot/fsd-fund-application-builder/manifest.yml index be1dc85f..e54bc9d7 100644 --- a/copilot/fsd-fund-application-builder/manifest.yml +++ b/copilot/fsd-fund-application-builder/manifest.yml @@ -11,8 +11,7 @@ http: # Requests to this path will be forwarded to your service. # To match all requests you can use the "/" path. path: "/" - # You can specify a custom health check path. The default is "/". - #healthcheck: '/healthcheck' + healthcheck: "/healthcheck" alias: fund-application-builder.${COPILOT_ENVIRONMENT_NAME}.access-funding.test.levellingup.gov.uk # Configuration for your containers and service. diff --git a/pyproject.toml b/pyproject.toml index 395c5922..531e71d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "flask-talisman==1.1.0", "flask-wtf==1.2.1", "flask==3.0.3", - "funding-service-design-utils==5.1.5", + "funding-service-design-utils==5.2.0", "govuk-frontend-jinja==3.4.0", "jsmin==3.0.1", "jsonschema==4.23.0", diff --git a/tests/test_routes.py b/tests/test_routes.py index 53f4d7f2..d2adeef9 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,6 +1,8 @@ from unittest.mock import MagicMock +from unittest.mock import patch import pytest +from flask import current_app from wtforms.validators import ValidationError from app.blueprints.fund_builder.forms.round import validate_json_field @@ -12,6 +14,26 @@ from tests.helpers import submit_form +@pytest.fixture(autouse=True) +def patch_validate_token_rs256(): + # This fixture patches validate_token_rs256 for all tests automatically. + with patch("fsd_utils.authentication.decorators.validate_token_rs256") as mock_validate_token_rs256: + mock_validate_token_rs256.return_value = { + "accountId": "test-account-id", + "roles": [], + "email": "test@communities.gov.uk", + } + yield mock_validate_token_rs256 + + +@pytest.fixture(autouse=True) +def set_auth_cookie(flask_test_client): + # This fixture sets the authentication cookie on every test. + user_token_cookie_name = current_app.config.get("FSD_USER_TOKEN_COOKIE_NAME", "fsd_user_token") + flask_test_client.set_cookie(key=user_token_cookie_name, value="dummy_jwt_token") + yield + + def test_create_fund(flask_test_client, _db, clear_test_data): """ Tests that a fund can be successfully created using the /fund route diff --git a/uv.lock b/uv.lock index 6f648964..a604aaa3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -requires-python = "==3.10.*" +requires-python = ">=3.10.0, <3.11" resolution-markers = [ "(platform_machine == 'AMD64' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform == 'win32') or (platform_machine == 'WIN32' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform == 'win32') or (platform_machine == 'amd64' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform == 'win32') or (platform_machine == 'win32' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform == 'win32')", "(platform_machine == 'AMD64' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform != 'win32') or (platform_machine == 'WIN32' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform != 'win32') or (platform_machine == 'aarch64' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform != 'win32') or (platform_machine == 'amd64' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform != 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform != 'win32') or (platform_machine == 'win32' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform != 'win32') or (platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and platform_system == 'Windows' and sys_platform != 'win32')", @@ -616,7 +616,7 @@ requires-dist = [ { name = "flask-sqlalchemy", specifier = "==3.1.1" }, { name = "flask-talisman", specifier = "==1.1.0" }, { name = "flask-wtf", specifier = "==1.2.1" }, - { name = "funding-service-design-utils", specifier = "==5.1.5" }, + { name = "funding-service-design-utils", specifier = "==5.2.0" }, { name = "govuk-frontend-jinja", specifier = "==3.4.0" }, { name = "jsmin", specifier = "==3.0.1" }, { name = "jsonschema", specifier = "==4.23.0" }, @@ -650,7 +650,7 @@ dev = [ [[package]] name = "funding-service-design-utils" -version = "5.1.5" +version = "5.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -671,9 +671,9 @@ dependencies = [ { name = "sentry-sdk", extra = ["flask"] }, { name = "sqlalchemy-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/23/eeb6917cf0afbc55596a1d3f8f65ebcdbe843bf0092d1ad48e8be8b7c3a1/funding_service_design_utils-5.1.5.tar.gz", hash = "sha256:e5bdd17ed323316d1ab3ff4cd50e67f3998590e25da72d064d018dadf6eec6a9", size = 66862 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/f6/683e686a975db8074930e7c65203fafbe5e18c460e385e1072eae884301f/funding_service_design_utils-5.2.0.tar.gz", hash = "sha256:06bccd7814647ff1ee226eeaf323aaa1c284188bc03bbfdaa313ca0f0b8898ba", size = 67461 } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/f9/a252776eb791af4c2656b61e085a8a797b759b6158f70fc0f389687a4880/funding_service_design_utils-5.1.5-py3-none-any.whl", hash = "sha256:174692a111b35607baffb8630f361f71cb4cf32ac688903addbeb793e7a03f55", size = 80613 }, + { url = "https://files.pythonhosted.org/packages/41/6b/a6c4dc52793049e7bf13432881997f822a1ba13c9f30c35dc07be0e51296/funding_service_design_utils-5.2.0-py3-none-any.whl", hash = "sha256:773d64ec9a06b6c73f057871a5a16b048e513bdee770d0b7f5d757610e801c38", size = 81221 }, ] [[package]]