From f01cd57d247dba131d525c8d33342615b37bf8f5 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 1 Dec 2022 14:12:25 -0500 Subject: [PATCH] Reorder config imports so that instance config is dead last - and can override everything else. Updated docker-compose for running a demo. run_pyl fixes --- docker-compose.yml | 32 +++---- .../src/spiffworkflow_backend/__init__.py | 4 +- .../spiffworkflow_backend/config/__init__.py | 12 +-- .../spiffworkflow_backend/config/default.py | 6 +- .../src/spiffworkflow_backend/models/user.py | 3 +- .../openid_blueprint/openid_blueprint.py | 89 +++++++++++-------- .../openid_blueprint/templates/login.html | 2 +- .../src/spiffworkflow_backend/routes/user.py | 11 ++- .../services/authentication_service.py | 7 +- .../services/authorization_service.py | 5 +- .../integration/test_openid_blueprint.py | 57 ++++++------ 11 files changed, 123 insertions(+), 105 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d6a86149..7e05ba2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,46 +6,46 @@ services: platform: linux/amd64 cap_add: - SYS_NICE - restart: "${SPIFFWORKFLOW_BACKEND_DATABASE_DOCKER_RESTART_POLICY:-no}" + restart: "no" environment: - - MYSQL_DATABASE=${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development} - - MYSQL_ROOT_PASSWORD=${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw} + - MYSQL_DATABASE=spiffworkflow_backend_development + - MYSQL_ROOT_PASSWORD=my-secret-pw - MYSQL_TCP_PORT=7003 ports: - "7003" - volumes: - - spiffworkflow_backend:/var/lib/mysql healthcheck: - test: mysql --user=root --password=${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw} -e 'select 1' ${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development} + test: mysql --user=root --password=my-secret-pw -e 'select 1' spiffworkflow_backend_development interval: 10s timeout: 5s retries: 10 spiffworkflow-backend: container_name: spiffworkflow-backend - image: ghcr.io/sartography/spiffworkflow-backend + image: ghcr.io/sartography/spiffworkflow-backend:latest depends_on: spiffworkflow-db: condition: service_healthy environment: - APPLICATION_ROOT=/ - - SPIFFWORKFLOW_BACKEND_ENV=${SPIFFWORKFLOW_BACKEND_ENV:-development} + - SPIFFWORKFLOW_BACKEND_ENV=development - FLASK_DEBUG=0 - - FLASK_SESSION_SECRET_KEY=${FLASK_SESSION_SECRET_KEY:-super_secret_key} - - OPEN_ID_SERVER_URL=${OPEN_ID_SERVER_URL:-http://spiffworkflow-openid:7002} - - SPIFFWORKFLOW_FRONTEND_URL=${SPIFFWORKFLOW_FRONTEND_URL:-http://spiffworkflow-frontend:7001} - - SPIFFWORKFLOW_BACKEND_URL=${SPIFFWORKFLOW_BACKEND_URL:-http://spiffworkflow-backend:7000} + - FLASK_SESSION_SECRET_KEY=super_secret_key + - OPEN_ID_SERVER_URL=http://localhost:7000/openid + - SPIFFWORKFLOW_FRONTEND_URL=http://localhost:7001 + - SPIFFWORKFLOW_BACKEND_URL=http://localhost:7000 - SPIFFWORKFLOW_BACKEND_PORT=7000 - SPIFFWORKFLOW_BACKEND_UPGRADE_DB=true - - SPIFFWORKFLOW_BACKEND_DATABASE_URI=mysql+mysqlconnector://root:${SPIFFWORKFLOW_BACKEND_MYSQL_ROOT_DATABASE:-my-secret-pw}@spiffworkflow-db:7003/${SPIFFWORKFLOW_BACKEND_DATABASE_NAME:-spiffworkflow_backend_development} + - SPIFFWORKFLOW_BACKEND_DATABASE_URI=mysql+mysqlconnector://root:my-secret-pw@spiffworkflow-db:7003/spiffworkflow_backend_development - BPMN_SPEC_ABSOLUTE_DIR=/app/process_models - - SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=${SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA:-false} - - SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=${SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME:-acceptance_tests.yml} + - SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=false + - SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=demo.yml - RUN_BACKGROUND_SCHEDULER=true + - OPEN_ID_CLIENT_ID=spiffworkflow-backend + - OPEN_ID_CLIENT_SECRET_KEY=my_open_id_secret_key ports: - "7000:7000" volumes: - - ${BPMN_SPEC_ABSOLUTE_DIR:-./../sample-process-models}:/app/process_models + - ./process_models:/app/process_models - ./log:/app/log healthcheck: test: curl localhost:7000/v1.0/status --fail diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py index 389c9370..c2baf9b6 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py @@ -19,7 +19,9 @@ from werkzeug.exceptions import NotFound import spiffworkflow_backend.load_database_models # noqa: F401 from spiffworkflow_backend.config import setup_config from spiffworkflow_backend.routes.admin_blueprint.admin_blueprint import admin_blueprint -from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import openid_blueprint +from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import ( + openid_blueprint, +) from spiffworkflow_backend.routes.process_api_blueprint import process_api_blueprint from spiffworkflow_backend.routes.user import verify_token from spiffworkflow_backend.routes.user_blueprint import user_blueprint diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py index b56683ca..e51d1697 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py @@ -52,12 +52,6 @@ def setup_config(app: Flask) -> None: app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config.from_object("spiffworkflow_backend.config.default") - # This allows config/testing.py or instance/config.py to override the default config - if "ENV_IDENTIFIER" in app.config and app.config["ENV_IDENTIFIER"] == "testing": - app.config.from_pyfile("config/testing.py", silent=True) - else: - app.config.from_pyfile(f"{app.instance_path}/config.py", silent=True) - env_config_prefix = "spiffworkflow_backend.config." env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"] try: @@ -73,6 +67,12 @@ def setup_config(app: Flask) -> None: f"Cannot find config module: {env_config_module}" ) from exception + # This allows config/testing.py or instance/config.py to override the default config + if "ENV_IDENTIFIER" in app.config and app.config["ENV_IDENTIFIER"] == "testing": + app.config.from_pyfile("config/testing.py", silent=True) + else: + app.config.from_pyfile(f"{app.instance_path}/config.py", silent=True) + setup_database_uri(app) setup_logger(app) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index c32c4882..bb2cab58 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -30,7 +30,11 @@ CONNECTOR_PROXY_URL = environ.get( GIT_COMMIT_ON_SAVE = environ.get("GIT_COMMIT_ON_SAVE", default="false") == "true" # Open ID server -OPEN_ID_SERVER_URL = environ.get("OPEN_ID_SERVER_URL", default="http://localhost:7002/realms/spiffworkflow") +OPEN_ID_SERVER_URL = environ.get( + "OPEN_ID_SERVER_URL", default="http://localhost:7002/realms/spiffworkflow" +) +# Replace above line with this to use the built-in Open ID Server. +# OPEN_ID_SERVER_URL = environ.get("OPEN_ID_SERVER_URL", default="http://localhost:7000/openid") OPEN_ID_CLIENT_ID = environ.get("OPEN_ID_CLIENT_ID", default="spiffworkflow-backend") OPEN_ID_CLIENT_SECRET_KEY = environ.get( "OPEN_ID_CLIENT_SECRET_KEY", default="JXeQExm0JhQPLumgHtIIqf52bDalHz0q" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py index ed6d10e6..b8c83d0f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py @@ -30,7 +30,8 @@ class UserModel(SpiffworkflowBaseDBModel): __table_args__ = (db.UniqueConstraint("service", "service_id", name="service_key"),) id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(255), nullable=False, unique=False) # server and service id must be unique, not username. + # server and service id must be unique, not username. + username = db.Column(db.String(255), nullable=False, unique=False) uid = db.Column(db.String(50), unique=True) service = db.Column(db.String(50), nullable=False, unique=False) service_id = db.Column(db.String(255), nullable=False, unique=False) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py index 5c96d62b..c0c55aec 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py @@ -9,7 +9,12 @@ from urllib.parse import urlencode import jwt import yaml -from flask import Blueprint, render_template, request, current_app, redirect, url_for +from flask import Blueprint +from flask import current_app +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for openid_blueprint = Blueprint( "openid", __name__, template_folder="templates", static_folder="static" @@ -21,8 +26,8 @@ MY_SECRET_CODE = ":this_is_not_secure_do_not_use_in_production" @openid_blueprint.route("/.well-known/openid-configuration", methods=["GET"]) def well_known(): """OpenID Discovery endpoint -- as these urls can be very different from system to system, - this is just a small subset.""" - host_url = request.host_url.strip('/') + this is just a small subset.""" + host_url = request.host_url.strip("/") return { "issuer": f"{host_url}/openid", "authorization_endpoint": f"{host_url}{url_for('openid.auth')}", @@ -34,82 +39,90 @@ def well_known(): @openid_blueprint.route("/auth", methods=["GET"]) def auth(): """Accepts a series of parameters""" - return render_template('login.html', - state=request.args.get('state'), - response_type=request.args.get('response_type'), - client_id=request.args.get('client_id'), - scope=request.args.get('scope'), - redirect_uri=request.args.get('redirect_uri'), - error_message=request.args.get('error_message', '')) + return render_template( + "login.html", + state=request.args.get("state"), + response_type=request.args.get("response_type"), + client_id=request.args.get("client_id"), + scope=request.args.get("scope"), + redirect_uri=request.args.get("redirect_uri"), + error_message=request.args.get("error_message", ""), + ) @openid_blueprint.route("/form_submit", methods=["POST"]) def form_submit(): users = get_users() - if request.values['Uname'] in users and request.values['Pass'] == users[request.values['Uname']]["password"]: + if ( + request.values["Uname"] in users + and request.values["Pass"] == users[request.values["Uname"]]["password"] + ): # Redirect back to the end user with some detailed information - state = request.values.get('state') + state = request.values.get("state") data = { "state": state, - "code": request.values['Uname'] + MY_SECRET_CODE, - "session_state": "" + "code": request.values["Uname"] + MY_SECRET_CODE, + "session_state": "", } - url = request.values.get('redirect_uri') + "?" + urlencode(data) + url = request.values.get("redirect_uri") + "?" + urlencode(data) return redirect(url) else: - return render_template('login.html', - state=request.values.get('state'), - response_type=request.values.get('response_type'), - client_id=request.values.get('client_id'), - scope=request.values.get('scope'), - redirect_uri=request.values.get('redirect_uri'), - error_message="Login failed. Please try again.") + return render_template( + "login.html", + state=request.values.get("state"), + response_type=request.values.get("response_type"), + client_id=request.values.get("client_id"), + scope=request.values.get("scope"), + redirect_uri=request.values.get("redirect_uri"), + error_message="Login failed. Please try again.", + ) @openid_blueprint.route("/token", methods=["POST"]) def token(): """Url that will return a valid token, given the super secret sauce""" - grant_type = request.values.get('grant_type') - code = request.values.get('code') - redirect_uri = request.values.get('redirect_uri') + request.values.get("grant_type") + code = request.values.get("code") + request.values.get("redirect_uri") """We just stuffed the user name on the front of the code, so grab it.""" user_name, secret_hash = code.split(":") user_details = get_users()[user_name] """Get authentication from headers.""" - authorization = request.headers.get('Authorization') + authorization = request.headers.get("Authorization") authorization = authorization[6:] # Remove "Basic" - authorization = base64.b64decode(authorization).decode('utf-8') + authorization = base64.b64decode(authorization).decode("utf-8") client_id, client_secret = authorization.split(":") base_url = request.host_url + "openid" access_token = user_name + ":" + "always_good_demo_access_token" refresh_token = user_name + ":" + "always_good_demo_refresh_token" - id_token = jwt.encode({ - "iss": base_url, - "aud": [client_id, "account"], - "iat": time.time(), - "exp": time.time() + 86400, # Expire after a day. - "sub": user_name, - "preferred_username": user_details.get('preferred_username', user_name) - }, + id_token = jwt.encode( + { + "iss": base_url, + "aud": [client_id, "account"], + "iat": time.time(), + "exp": time.time() + 86400, # Expire after a day. + "sub": user_name, + "preferred_username": user_details.get("preferred_username", user_name), + }, client_secret, algorithm="HS256", ) response = { "access_token": id_token, "id_token": id_token, - "refresh_token": id_token + "refresh_token": id_token, } return response @openid_blueprint.route("/end_session", methods=["GET"]) def end_session(): - redirect_url = request.args.get('post_logout_redirect_uri') - id_token_hint = request.args.get('id_token_hint') + redirect_url = request.args.get("post_logout_redirect_uri") + request.args.get("id_token_hint") return redirect(redirect_url) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/templates/login.html b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/templates/login.html index 6ef26457..d9b8b901 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/templates/login.html +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/templates/login.html @@ -33,4 +33,4 @@ - \ No newline at end of file + diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py index c9059427..24346771 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py @@ -15,12 +15,9 @@ from flask import request from flask_bpmn.api.api_error import ApiError from werkzeug.wrappers import Response -from spiffworkflow_backend.models.group import GroupModel -from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel -from spiffworkflow_backend.models.principal import PrincipalModel from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.authentication_service import ( - AuthenticationService, AuthenticationProviderTypes, + AuthenticationService, ) from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.user_service import UserService @@ -91,7 +88,7 @@ def verify_token( if auth_token and "error" not in auth_token: # We have the user, but this code is a bit convoluted, and will later demand # a user_info object so it can look up the user. Sorry to leave this crap here. - user_info = {"sub": user.service_id } + user_info = {"sub": user.service_id} else: raise ae else: @@ -200,16 +197,18 @@ def login(redirect_url: str = "/") -> Response: ) return redirect(login_redirect_url) + def parse_id_token(token: str) -> dict: parts = token.split(".") if len(parts) != 3: raise Exception("Incorrect id token format") payload = parts[1] - padded = payload + '=' * (4 - len(payload) % 4) + padded = payload + "=" * (4 - len(payload) % 4) decoded = base64.b64decode(padded) return json.loads(decoded) + def login_return(code: str, state: str, session_state: str) -> Optional[Response]: """Login_return.""" state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8")) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py index 5fdedf76..b17c1fa9 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py @@ -3,7 +3,6 @@ import base64 import enum import json import time -import typing from typing import Optional import jwt @@ -26,7 +25,10 @@ class AuthenticationProviderTypes(enum.Enum): class AuthenticationService: """AuthenticationService.""" - ENDPOINT_CACHE = {} # We only need to find the openid endpoints once, then we can cache them. + + ENDPOINT_CACHE = ( + {} + ) # We only need to find the openid endpoints once, then we can cache them. @staticmethod def client_id(): @@ -40,7 +42,6 @@ class AuthenticationService: def secret_key(): return current_app.config["OPEN_ID_CLIENT_SECRET_KEY"] - @classmethod def open_id_endpoint_for_name(cls, name: str) -> None: """All openid systems provide a mapping of static names to the full path of that endpoint.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index d7bd40a1..bde40830 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -6,9 +6,10 @@ from typing import Union import jwt import yaml -from flask import current_app, scaffold +from flask import current_app from flask import g from flask import request +from flask import scaffold from flask_bpmn.api.api_error import ApiError from flask_bpmn.models.db import db from SpiffWorkflow.task import Task as SpiffTask # type: ignore @@ -127,7 +128,6 @@ class AuthorizationService: db.session.add(user_group_assignemnt) db.session.commit() - @classmethod def import_permissions_from_yaml_file( cls, raise_if_missing_user: bool = False @@ -448,7 +448,6 @@ class AuthorizationService: email=email, ) - # this may eventually get too slow. # when it does, be careful about backgrounding, because # the user will immediately need permissions to use the site. diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py index 963b5585..af158cef 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py @@ -1,59 +1,58 @@ """Test_authentication.""" -import ast -import base64 - from flask import Flask from flask.testing import FlaskClient - from tests.spiffworkflow_backend.helpers.base_test import BaseTest -from spiffworkflow_backend.services.authentication_service import ( - AuthenticationService, -) - class TestFaskOpenId(BaseTest): """An integrated Open ID that responds to openID requests - by referencing a build in YAML file. Useful for - local development, testing, demos etc...""" + by referencing a build in YAML file. Useful for + local development, testing, demos etc...""" - def test_discovery_of_endpoints(self, - app: Flask, - client: FlaskClient, - with_db_and_bpmn_file_cleanup: None,) -> None: + def test_discovery_of_endpoints( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: response = client.get("/openid/.well-known/openid-configuration") discovered_urls = response.json assert "http://localhost/openid" == discovered_urls["issuer"] - assert "http://localhost/openid/auth" == discovered_urls["authorization_endpoint"] + assert ( + "http://localhost/openid/auth" == discovered_urls["authorization_endpoint"] + ) assert "http://localhost/openid/token" == discovered_urls["token_endpoint"] - def test_get_login_page(self, - app: Flask, - client: FlaskClient, - with_db_and_bpmn_file_cleanup: None,) -> None: + def test_get_login_page( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: # It should be possible to get to a login page - data = { - "state": {"bubblegum":1, "daydream":2} - } + data = {"state": {"bubblegum": 1, "daydream": 2}} response = client.get("/openid/auth", query_string=data) assert b"

Login to SpiffWorkflow

" in response.data assert b"bubblegum" in response.data - def test_get_token(self, + def test_get_token( + self, app: Flask, client: FlaskClient, - with_db_and_bpmn_file_cleanup: None,) -> None: + with_db_and_bpmn_file_cleanup: None, + ) -> None: - code = "c3BpZmZ3b3JrZmxvdy1iYWNrZW5kOkpYZVFFeG0wSmhRUEx1bWdIdElJcWY1MmJEYWxIejBx" + code = ( + "c3BpZmZ3b3JrZmxvdy1iYWNrZW5kOkpYZVFFeG0wSmhRUEx1bWdIdElJcWY1MmJEYWxIejBx" + ) headers = { "Content-Type": "application/x-www-form-urlencoded", "Authorization": f"Basic {code}", } - data = { - "grant_type": 'authorization_code', + data = { + "grant_type": "authorization_code", "code": code, - "redirect_url": 'http://localhost:7000/v1.0/login_return' + "redirect_url": "http://localhost:7000/v1.0/login_return", } response = client.post("/openid/token", data=data, headers=headers) assert response -