diff --git a/spiffworkflow-backend/bin/save_all_bpmn.py b/spiffworkflow-backend/bin/save_all_bpmn.py index b7b61628..fd44bb54 100644 --- a/spiffworkflow-backend/bin/save_all_bpmn.py +++ b/spiffworkflow-backend/bin/save_all_bpmn.py @@ -9,7 +9,6 @@ def main() -> None: """Main.""" app = create_app() with app.app_context(): - print("HEY") failing_process_models = DataSetupService.save_all_process_models() for bpmn_errors in failing_process_models: print(bpmn_errors) diff --git a/spiffworkflow-backend/bin/start_keycloak b/spiffworkflow-backend/bin/start_keycloak index f76347da..a44c0f51 100755 --- a/spiffworkflow-backend/bin/start_keycloak +++ b/spiffworkflow-backend/bin/start_keycloak @@ -39,7 +39,8 @@ docker run \ -e KEYCLOAK_LOGLEVEL=ALL \ -e ROOT_LOGLEVEL=ALL \ -e KEYCLOAK_ADMIN=admin \ - -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:20.0.1 start-dev \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:20.0.1 start-dev \ -Dkeycloak.profile.feature.token_exchange=enabled \ -Dkeycloak.profile.feature.admin_fine_grained_authz=enabled diff --git a/spiffworkflow-backend/poetry.lock b/spiffworkflow-backend/poetry.lock index b726c91a..2e001a93 100644 --- a/spiffworkflow-backend/poetry.lock +++ b/spiffworkflow-backend/poetry.lock @@ -462,21 +462,6 @@ toml = "*" conda = ["pyyaml"] pipenv = ["pipenv"] -[[package]] -name = "ecdsa" -version = "0.18.0" -description = "ECDSA cryptographic signature library (pure python)" -category = "main" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" - -[package.dependencies] -six = ">=1.9.0" - -[package.extras] -gmpy = ["gmpy"] -gmpy2 = ["gmpy2"] - [[package]] name = "exceptiongroup" version = "1.0.4" @@ -668,6 +653,22 @@ python-versions = "*" Flask = ">=0.9" Six = "*" +[[package]] +name = "flask-jwt-extended" +version = "4.4.4" +description = "Extended JWT integration with Flask" +category = "main" +optional = false +python-versions = ">=3.7,<4" + +[package.dependencies] +Flask = ">=2.0,<3.0" +PyJWT = ">=2.0,<3.0" +Werkzeug = ">=0.14" + +[package.extras] +asymmetric-crypto = ["cryptography (>=3.3.1)"] + [[package]] name = "Flask-Mail" version = "0.9.1" @@ -1223,14 +1224,6 @@ category = "main" optional = false python-versions = ">=3.6" -[[package]] -name = "pyasn1" -version = "0.4.8" -description = "ASN.1 types and codecs" -category = "main" -optional = false -python-versions = "*" - [[package]] name = "pycodestyle" version = "2.8.0" @@ -1384,41 +1377,6 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" [package.dependencies] six = ">=1.5" -[[package]] -name = "python-jose" -version = "3.3.0" -description = "JOSE implementation in Python" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -ecdsa = "!=0.15" -pyasn1 = "*" -rsa = "*" - -[package.extras] -cryptography = ["cryptography (>=3.4.0)"] -pycrypto = ["pyasn1", "pycrypto (>=2.6.0,<2.7.0)"] -pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"] - -[[package]] -name = "python-keycloak" -version = "2.6.0" -description = "python-keycloak is a Python package providing access to the Keycloak API." -category = "main" -optional = false -python-versions = ">=3.7,<4.0" - -[package.dependencies] -python-jose = ">=3.3.0,<4.0.0" -requests = ">=2.20.0,<3.0.0" -requests-toolbelt = ">=0.9.1,<0.10.0" -urllib3 = ">=1.26.0,<2.0.0" - -[package.extras] -docs = ["Sphinx (>=5.0.2,<6.0.0)", "alabaster (>=0.7.12,<0.8.0)", "commonmark (>=0.9.1,<0.10.0)", "m2r2 (>=0.3.2,<0.4.0)", "mock (>=4.0.3,<5.0.0)", "readthedocs-sphinx-ext (>=2.1.8,<3.0.0)", "recommonmark (>=0.7.1,<0.8.0)", "sphinx-autoapi (>=1.8.4,<2.0.0)", "sphinx-rtd-theme (>=1.0.0,<2.0.0)"] - [[package]] name = "pytz" version = "2022.6" @@ -1494,17 +1452,6 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "requests-toolbelt" -version = "0.9.1" -description = "A utility belt for advanced users of python-requests" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -requests = ">=2.0.1,<3.0.0" - [[package]] name = "restrictedpython" version = "6.0" @@ -1528,17 +1475,6 @@ python-versions = "*" [package.dependencies] docutils = ">=0.11,<1.0" -[[package]] -name = "rsa" -version = "4.9" -description = "Pure-Python RSA implementation" -category = "main" -optional = false -python-versions = ">=3.6,<4" - -[package.dependencies] -pyasn1 = ">=0.1.3" - [[package]] name = "ruamel.yaml" version = "0.17.21" @@ -1851,7 +1787,7 @@ lxml = "*" type = "git" url = "https://github.com/sartography/SpiffWorkflow" reference = "main" -resolved_reference = "5eed83ab12f67c01c7836424a22fc425a33fc55d" +resolved_reference = "be26100bcbef8026e26312c665dae42faf476485" [[package]] name = "SQLAlchemy" @@ -2222,7 +2158,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = ">=3.9,<3.12" -content-hash = "832c1b6cd8d9aebc8529fdce11167bddcb3634fd0767dd2e490b74ababcf2714" +content-hash = "8592e94ba80b7d0338a9c003ca4d0e189b5f470d97391438ddc1fc9050febedb" [metadata.files] alabaster = [ @@ -2443,10 +2379,6 @@ dparse = [ {file = "dparse-0.6.2-py3-none-any.whl", hash = "sha256:8097076f1dd26c377f30d4745e6ec18fef42f3bf493933b842ac5bafad8c345f"}, {file = "dparse-0.6.2.tar.gz", hash = "sha256:d45255bda21f998bc7ddf2afd5e62505ba6134756ba2d42a84c56b0826614dfe"}, ] -ecdsa = [ - {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, - {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, -] exceptiongroup = [ {file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"}, {file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"}, @@ -2494,6 +2426,10 @@ Flask-Cors = [ {file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"}, {file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"}, ] +flask-jwt-extended = [ + {file = "Flask-JWT-Extended-4.4.4.tar.gz", hash = "sha256:62b521d75494c290a646ae8acc77123721e4364790f1e64af0038d823961fbf0"}, + {file = "Flask_JWT_Extended-4.4.4-py2.py3-none-any.whl", hash = "sha256:a85eebfa17c339a7260c4643475af444784ba6de5588adda67406f0a75599553"}, +] Flask-Mail = [ {file = "Flask-Mail-0.9.1.tar.gz", hash = "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"}, ] @@ -2988,10 +2924,6 @@ psycopg2 = [ {file = "psycopg2-2.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:849bd868ae3369932127f0771c08d1109b254f08d48dc42493c3d1b87cb2d308"}, {file = "psycopg2-2.9.4.tar.gz", hash = "sha256:d529926254e093a1b669f692a3aa50069bc71faf5b0ecd91686a78f62767d52f"}, ] -pyasn1 = [ - {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, - {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, -] pycodestyle = [ {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, @@ -3059,14 +2991,6 @@ python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] -python-jose = [ - {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, - {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, -] -python-keycloak = [ - {file = "python-keycloak-2.6.0.tar.gz", hash = "sha256:08c530ff86f631faccb8033d9d9345cc3148cb2cf132ff7564f025292e4dbd96"}, - {file = "python_keycloak-2.6.0-py3-none-any.whl", hash = "sha256:a1ce102b978beb56d385319b3ca20992b915c2c12d15a2d0c23f1104882f3fb6"}, -] pytz = [ {file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"}, {file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"}, @@ -3205,10 +3129,6 @@ requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, ] -requests-toolbelt = [ - {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, - {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"}, -] restrictedpython = [ {file = "RestrictedPython-6.0-py3-none-any.whl", hash = "sha256:3479303f7bff48a7dedad76f96e7704993c5e86c5adbd67f607295d5352f0fb8"}, {file = "RestrictedPython-6.0.tar.gz", hash = "sha256:405cf0bd9eec2f19b1326b5f48228efe56d6590b4e91826b8cc3b2cd400a96ad"}, @@ -3216,10 +3136,6 @@ restrictedpython = [ restructuredtext-lint = [ {file = "restructuredtext_lint-1.4.0.tar.gz", hash = "sha256:1b235c0c922341ab6c530390892eb9e92f90b9b75046063e047cacfb0f050c45"}, ] -rsa = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, -] "ruamel.yaml" = [ {file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"}, {file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"}, diff --git a/spiffworkflow-backend/pyproject.toml b/spiffworkflow-backend/pyproject.toml index b43d1ec3..83a43482 100644 --- a/spiffworkflow-backend/pyproject.toml +++ b/spiffworkflow-backend/pyproject.toml @@ -44,7 +44,6 @@ marshmallow-enum = "^1.5.1" marshmallow-sqlalchemy = "^0.28.0" PyJWT = "^2.6.0" gunicorn = "^20.1.0" -python-keycloak = "^2.5.0" APScheduler = "*" Jinja2 = "^3.1.2" RestrictedPython = "^6.0" @@ -72,6 +71,7 @@ simplejson = "^3.17.6" pytz = "^2022.6" dateparser = "^1.1.2" types-dateparser = "^1.1.4.1" +flask-jwt-extended = "^4.4.4" [tool.poetry.dev-dependencies] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py index f1de793d..f67dccc0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py @@ -23,6 +23,7 @@ from spiffworkflow_backend.routes.admin_blueprint.admin_blueprint import admin_b from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import ( openid_blueprint, ) +from spiffworkflow_backend.routes.user import set_new_access_token_in_cookie from spiffworkflow_backend.routes.user import verify_token from spiffworkflow_backend.routes.user_blueprint import user_blueprint from spiffworkflow_backend.services.authorization_service import AuthorizationService @@ -115,7 +116,7 @@ def create_app() -> flask.app.Flask: r"^https?:\/\/%s(.*)" % o.replace(".", r"\.") for o in app.config["CORS_ALLOW_ORIGINS"] ] - CORS(app, origins=origins_re, max_age=3600) + CORS(app, origins=origins_re, max_age=3600, supports_credentials=True) connexion_app.add_api("api.yml", base_path=V1_API_PATH_PREFIX) @@ -131,6 +132,7 @@ def create_app() -> flask.app.Flask: app.before_request(verify_token) app.before_request(AuthorizationService.check_for_permission) + app.after_request(set_new_access_token_in_cookie) return app # type: ignore diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py index fb5901f0..7d915946 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py @@ -63,7 +63,6 @@ def setup_config(app: Flask) -> None: ) app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config.from_object("spiffworkflow_backend.config.default") - print("loaded config: default") env_config_prefix = "spiffworkflow_backend.config." if ( @@ -71,7 +70,6 @@ def setup_config(app: Flask) -> None: and os.environ.get("SPIFFWORKFLOW_BACKEND_ENV") is not None ): load_config_file(app, f"{env_config_prefix}terraform_deployed_environment") - print("loaded config: terraform_deployed_environment") env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"] load_config_file(app, env_config_module) @@ -90,14 +88,6 @@ def setup_config(app: Flask) -> None: "permissions", app.config["SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME"], ) - print( - "set permissions file name config:" - f" {app.config['SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME']}" - ) - print( - "set permissions file name full path:" - f" {app.config['PERMISSIONS_FILE_FULLPATH']}" - ) # unversioned (see .gitignore) config that can override everything and include secrets. # src/spiffworkflow_backend/config/secrets.py diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index d0d6a401..9027347c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -29,8 +29,11 @@ CONNECTOR_PROXY_URL = environ.get( # Open ID server OPEN_ID_SERVER_URL = environ.get( - "OPEN_ID_SERVER_URL", default="http://localhost:7002/realms/spiffworkflow" + "OPEN_ID_SERVER_URL", + default="http://localhost:7002/realms/spiffworkflow" + # "OPEN_ID_SERVER_URL", default="http://localhost:7000/openid" ) + # 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") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/health_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/health_controller.py index e9831110..2cb4092b 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/health_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/health_controller.py @@ -1,13 +1,11 @@ """APIs for dealing with process groups, process models, and process instances.""" -import json - -import flask.wrappers +from flask import make_response from flask.wrappers import Response from spiffworkflow_backend.models.process_instance import ProcessInstanceModel -def status() -> flask.wrappers.Response: +def status() -> Response: """Status.""" ProcessInstanceModel.query.filter().first() - return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") + return make_response({"ok": True}, 200) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_groups_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_groups_controller.py index 228be181..2a516f9d 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_groups_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_groups_controller.py @@ -88,7 +88,7 @@ def process_group_list( "pages": pages, }, } - return Response(json.dumps(response_json), status=200, mimetype="application/json") + return make_response(jsonify(response_json), 200) def process_group_show( diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py index 1ac6207c..cc7c9bd4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py @@ -7,6 +7,7 @@ from typing import Dict from typing import Optional from typing import Union +import flask import jwt from flask import current_app from flask import g @@ -20,6 +21,7 @@ from spiffworkflow_backend.services.authentication_service import Authentication from spiffworkflow_backend.services.authentication_service import ( MissingAccessTokenError, ) +from spiffworkflow_backend.services.authentication_service import TokenExpiredError from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.user_service import UserService @@ -55,6 +57,9 @@ def verify_token( if not token and "Authorization" in request.headers: token = request.headers["Authorization"].removeprefix("Bearer ") + # This should never be set here but just in case + _clear_auth_tokens_from_thread_local_data() + if token: user_model = None decoded_token = get_decoded_token(token) @@ -71,12 +76,11 @@ def verify_token( f" internal token. {e}" ) elif "iss" in decoded_token.keys(): + user_info = None try: - if AuthenticationService.validate_id_token(token): + if AuthenticationService.validate_id_or_access_token(token): user_info = decoded_token - except ( - ApiError - ) as ae: # API Error is only thrown in the token is outdated. + except TokenExpiredError as token_expired_error: # Try to refresh the token user = UserService.get_user_by_service_and_service_id( decoded_token["iss"], decoded_token["sub"] @@ -90,17 +94,24 @@ def verify_token( ) ) if auth_token and "error" not in auth_token: + tld = current_app.config["THREAD_LOCAL_DATA"] + tld.new_access_token = auth_token["access_token"] + tld.new_id_token = auth_token["id_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} - else: - raise ae - else: - raise ae - else: - raise ae + user_info = { + "sub": user.service_id, + "iss": user.service, + } + + if user_info is None: + raise ApiError( + error_code="invalid_token", + message="Your token is expired. Please Login", + status_code=401, + ) from token_expired_error + except Exception as e: - current_app.logger.error(f"Exception raised in get_token: {e}") raise ApiError( error_code="fail_get_user_info", message="Cannot get user info from token", @@ -150,8 +161,6 @@ def verify_token( g.token = token get_scope(token) return None - # return {"uid": g.user.id, "sub": g.user.id, "scope": scope} - # return validate_scope(token, user_info, user_model) else: raise ApiError(error_code="no_user_id", message="Cannot get a user id") @@ -160,16 +169,28 @@ def verify_token( ) -def validate_scope(token: Any) -> bool: - """Validate_scope.""" - print("validate_scope") - # token = AuthenticationService.refresh_token(token) - # user_info = AuthenticationService.get_user_info_from_public_access_token(token) - # bearer_token = AuthenticationService.get_bearer_token(token) - # permission = AuthenticationService.get_permission_by_basic_token(token) - # permissions = AuthenticationService.get_permissions_by_token_for_resource_and_scope(token) - # introspection = AuthenticationService.introspect_token(basic_token) - return True +def set_new_access_token_in_cookie( + response: flask.wrappers.Response, +) -> flask.wrappers.Response: + """Checks if a new token has been set in THREAD_LOCAL_DATA and sets cookies if appropriate. + + It will also delete the cookies if the user has logged out. + """ + tld = current_app.config["THREAD_LOCAL_DATA"] + if hasattr(tld, "new_access_token") and tld.new_access_token: + response.set_cookie("access_token", tld.new_access_token) + + # id_token is required for logging out since this gets passed back to the openid server + if hasattr(tld, "new_id_token") and tld.new_id_token: + response.set_cookie("id_token", tld.new_id_token) + + if hasattr(tld, "user_has_logged_out") and tld.user_has_logged_out: + response.set_cookie("id_token", "", max_age=0) + response.set_cookie("access_token", "", max_age=0) + + _clear_auth_tokens_from_thread_local_data() + + return response def encode_auth_token(sub: str, token_type: Optional[str] = None) -> str: @@ -226,7 +247,7 @@ def login_return(code: str, state: str, session_state: str) -> Optional[Response user_info = parse_id_token(id_token) - if AuthenticationService.validate_id_token(id_token): + if AuthenticationService.validate_id_or_access_token(id_token): if user_info and "error" not in user_info: user_model = AuthorizationService.create_user_from_sign_in(user_info) g.user = user_model.id @@ -234,11 +255,10 @@ def login_return(code: str, state: str, session_state: str) -> Optional[Response AuthenticationService.store_refresh_token( user_model.id, auth_token_object["refresh_token"] ) - redirect_url = ( - f"{state_redirect_url}?" - + f"access_token={auth_token_object['access_token']}&" - + f"id_token={id_token}" - ) + redirect_url = state_redirect_url + tld = current_app.config["THREAD_LOCAL_DATA"] + tld.new_access_token = auth_token_object["access_token"] + tld.new_id_token = auth_token_object["id_token"] return redirect(redirect_url) raise ApiError( @@ -284,6 +304,8 @@ def logout(id_token: str, redirect_url: Optional[str]) -> Response: """Logout.""" if redirect_url is None: redirect_url = "" + tld = current_app.config["THREAD_LOCAL_DATA"] + tld.user_has_logged_out = True return AuthenticationService().logout(redirect_url=redirect_url, id_token=id_token) @@ -312,15 +334,6 @@ def get_decoded_token(token: str) -> Optional[Dict]: error_code="unknown_token", message="Unknown token type in get_decoded_token", ) - # try: - # # see if we have an open_id token - # decoded_token = AuthorizationService.decode_auth_token(token) - # else: - # if 'sub' in decoded_token and 'iss' in decoded_token and 'aud' in decoded_token: - # token_type = 'id_token' - - # if 'token_type' in decoded_token and 'sub' in decoded_token: - # return True def get_scope(token: str) -> str: @@ -347,3 +360,14 @@ def get_user_from_decoded_internal_token(decoded_token: dict) -> Optional[UserMo return user user = UserService.create_user(service_id, service, service_id) return user + + +def _clear_auth_tokens_from_thread_local_data() -> None: + """_clear_auth_tokens_from_thread_local_data.""" + tld = current_app.config["THREAD_LOCAL_DATA"] + if hasattr(tld, "new_access_token"): + delattr(tld, "new_access_token") + if hasattr(tld, "new_id_token"): + delattr(tld, "new_id_token") + if hasattr(tld, "user_has_logged_out"): + delattr(tld, "user_has_logged_out") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py index f697904e..77082a92 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py @@ -20,6 +20,15 @@ class MissingAccessTokenError(Exception): """MissingAccessTokenError.""" +# These could be either 'id' OR 'access' tokens and we can't always know which +class TokenExpiredError(Exception): + """TokenExpiredError.""" + + +class TokenInvalidError(Exception): + """TokenInvalidError.""" + + class AuthenticationProviderTypes(enum.Enum): """AuthenticationServiceProviders.""" @@ -125,18 +134,15 @@ class AuthenticationService: return auth_token_object @classmethod - def validate_id_token(cls, id_token: str) -> bool: + def validate_id_or_access_token(cls, token: str) -> bool: """Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation.""" valid = True now = time.time() try: - decoded_token = jwt.decode(id_token, options={"verify_signature": False}) + decoded_token = jwt.decode(token, options={"verify_signature": False}) except Exception as e: - raise ApiError( - error_code="bad_id_token", - message="Cannot decode id_token", - status_code=401, - ) from e + raise TokenInvalidError("Cannot decode token") from e + if decoded_token["iss"] != cls.server_url(): valid = False elif ( @@ -153,15 +159,10 @@ class AuthenticationService: valid = False if not valid: - current_app.logger.error(f"Invalid token in validate_id_token: {id_token}") return False if now > decoded_token["exp"]: - raise ApiError( - error_code="invalid_token", - message="Your token is expired. Please Login", - status_code=401, - ) + raise TokenExpiredError("Your token is expired. Please Login") return True diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index 9abe2597..ba43439e 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -412,59 +412,6 @@ class AuthorizationService: status_code=403, ) - # def refresh_token(self, token: str) -> str: - # """Refresh_token.""" - # # if isinstance(token, str): - # # token = eval(token) - # ( - # open_id_server_url, - # open_id_client_id, - # open_id_realm_name, - # open_id_client_secret_key, - # ) = AuthorizationService.get_open_id_args() - # headers = {"Content-Type": "application/x-www-form-urlencoded"} - # request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token" - # data = { - # "grant_type": "refresh_token", - # "client_id": "spiffworkflow-frontend", - # "subject_token": token, - # "refresh_token": token, - # } - # refresh_response = requests.post(request_url, headers=headers, data=data) - # refresh_token = json.loads(refresh_response.text) - # return refresh_token - - # def get_bearer_token(self, basic_token: str) -> dict: - # """Get_bearer_token.""" - # ( - # open_id_server_url, - # open_id_client_id, - # open_id_realm_name, - # open_id_client_secret_key, - # ) = AuthorizationService.get_open_id_args() - # - # backend_basic_auth_string = f"{open_id_client_id}:{open_id_client_secret_key}" - # backend_basic_auth_bytes = bytes(backend_basic_auth_string, encoding="ascii") - # backend_basic_auth = base64.b64encode(backend_basic_auth_bytes) - # - # headers = { - # "Content-Type": "application/x-www-form-urlencoded", - # "Authorization": f"Basic {backend_basic_auth.decode('utf-8')}", - # } - # data = { - # "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - # "client_id": open_id_client_id, - # "subject_token": basic_token, - # "audience": open_id_client_id, - # } - # request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token" - # - # backend_response = requests.post(request_url, headers=headers, data=data) - # # json_data = json.loads(backend_response.text) - # # bearer_token = json_data['access_token'] - # bearer_token: dict = json.loads(backend_response.text) - # return bearer_token - @staticmethod def decode_auth_token(auth_token: str) -> dict[str, Union[str, None]]: """Decode the auth token. diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/error_handling_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/error_handling_service.py index 1e8b38f2..ffdfd26a 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/error_handling_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/error_handling_service.py @@ -3,6 +3,7 @@ from typing import Any from typing import List from typing import Union +from flask import current_app from flask_bpmn.api.api_error import ApiError from flask_bpmn.models.db import db @@ -57,7 +58,7 @@ class ErrorHandlingService: ) except Exception as e: # hmm... what to do if a notification method fails. Probably log, at least - print(e) + current_app.logger.error(e) @staticmethod def hanle_sentry_notification(_error: ApiError, _recipients: List) -> None: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py index b3d1e831..b659b13e 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py @@ -42,7 +42,6 @@ class MessageService: message_type="receive", status="ready" ).all() for message_instance_send in message_instances_send: - # print(f"message_instance_send.id: {message_instance_send.id}") # check again in case another background process picked up the message # while the previous one was running if message_instance_send.status != "ready": diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/service_task_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/service_task_service.py index 6fec8b79..674ad54d 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/service_task_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/service_task_service.py @@ -55,9 +55,6 @@ class ServiceTaskDelegate: f"{connector_proxy_url()}/v1/do/{name}", json=params ) - if proxied_response.status_code != 200: - print("got error from connector proxy") - parsed_response = json.loads(proxied_response.text) if "refreshed_token_set" not in parsed_response: @@ -86,7 +83,7 @@ class ServiceTaskService: parsed_response = json.loads(response.text) return parsed_response except Exception as e: - print(e) + current_app.logger.error(e) return [] @staticmethod diff --git a/spiffworkflow-frontend/package-lock.json b/spiffworkflow-frontend/package-lock.json index 0d174daf..8466f7a8 100644 --- a/spiffworkflow-frontend/package-lock.json +++ b/spiffworkflow-frontend/package-lock.json @@ -39,6 +39,7 @@ "bpmn-js": "^9.3.2", "bpmn-js-properties-panel": "^1.10.0", "bpmn-js-spiffworkflow": "sartography/bpmn-js-spiffworkflow#main", + "cookie": "^0.5.0", "craco": "^0.0.3", "date-fns": "^2.28.0", "diagram-js": "^8.5.0", @@ -66,6 +67,7 @@ }, "devDependencies": { "@cypress/grep": "^3.1.0", + "@types/cookie": "^0.5.1", "@typescript-eslint/eslint-plugin": "^5.30.5", "@typescript-eslint/parser": "^5.30.6", "cypress": "^12", @@ -5654,6 +5656,12 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz", + "integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==", + "dev": true + }, "node_modules/@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -35330,6 +35338,12 @@ "@types/node": "*" } }, + "@types/cookie": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz", + "integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==", + "dev": true + }, "@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", diff --git a/spiffworkflow-frontend/package.json b/spiffworkflow-frontend/package.json index 8b65b945..515d191a 100644 --- a/spiffworkflow-frontend/package.json +++ b/spiffworkflow-frontend/package.json @@ -34,6 +34,7 @@ "bpmn-js": "^9.3.2", "bpmn-js-properties-panel": "^1.10.0", "bpmn-js-spiffworkflow": "sartography/bpmn-js-spiffworkflow#main", + "cookie": "^0.5.0", "craco": "^0.0.3", "date-fns": "^2.28.0", "diagram-js": "^8.5.0", @@ -102,6 +103,7 @@ }, "devDependencies": { "@cypress/grep": "^3.1.0", + "@types/cookie": "^0.5.1", "@typescript-eslint/eslint-plugin": "^5.30.5", "@typescript-eslint/parser": "^5.30.6", "cypress": "^12", diff --git a/spiffworkflow-frontend/src/config.tsx b/spiffworkflow-frontend/src/config.tsx index abaadd5e..36e0ed4e 100644 --- a/spiffworkflow-frontend/src/config.tsx +++ b/spiffworkflow-frontend/src/config.tsx @@ -12,7 +12,6 @@ if (/^\d+\./.test(hostname) || hostname === 'localhost') { } let url = `${protocol}://${hostAndPort}/v1.0`; -// Allow overriding the backend base url with an environment variable at build time. if (process.env.REACT_APP_BACKEND_BASE_URL) { url = process.env.REACT_APP_BACKEND_BASE_URL; } diff --git a/spiffworkflow-frontend/src/index.tsx b/spiffworkflow-frontend/src/index.tsx index 7a602be6..42eddc02 100644 --- a/spiffworkflow-frontend/src/index.tsx +++ b/spiffworkflow-frontend/src/index.tsx @@ -20,7 +20,7 @@ const doRender = () => { ); }; -UserService.getAuthTokenFromParams(); +UserService.loginIfNeeded(); doRender(); // If you want to start measuring performance in your app, pass a function diff --git a/spiffworkflow-frontend/src/routes/AuthenticationList.tsx b/spiffworkflow-frontend/src/routes/AuthenticationList.tsx index a0d15101..3fdb748b 100644 --- a/spiffworkflow-frontend/src/routes/AuthenticationList.tsx +++ b/spiffworkflow-frontend/src/routes/AuthenticationList.tsx @@ -42,7 +42,7 @@ export default function AuthenticationList() { row.id }?redirect_url=${redirectUrl}/${ row.id - }?token=${UserService.getAuthToken()}`} + }?token=${UserService.getAccessToken()}`} > {row.id} diff --git a/spiffworkflow-frontend/src/routes/JsonSchemaFormBuilder.tsx b/spiffworkflow-frontend/src/routes/JsonSchemaFormBuilder.tsx index d4a9c2b4..b180ed2a 100644 --- a/spiffworkflow-frontend/src/routes/JsonSchemaFormBuilder.tsx +++ b/spiffworkflow-frontend/src/routes/JsonSchemaFormBuilder.tsx @@ -73,11 +73,6 @@ export default function JsonSchemaFormBuilder() { }; const onFormFieldTitleChange = (newFormFieldTitle: string) => { - console.log('newFormFieldTitle', newFormFieldTitle); - console.log( - 'setFormFieldIdHasBeenUpdatedByUser', - formFieldIdHasBeenUpdatedByUser - ); if (!formFieldIdHasBeenUpdatedByUser) { setFormFieldId(underscorizeString(newFormFieldTitle)); } diff --git a/spiffworkflow-frontend/src/routes/TaskShow.tsx b/spiffworkflow-frontend/src/routes/TaskShow.tsx index 0841da1b..51bf42e7 100644 --- a/spiffworkflow-frontend/src/routes/TaskShow.tsx +++ b/spiffworkflow-frontend/src/routes/TaskShow.tsx @@ -184,6 +184,13 @@ export default function TaskShow() { ); } + function customValidate(formData: any, errors: any) { + if (formData.pass1 !== formData.pass2) { + errors.pass2.addError("Passwords don't match"); + } + return errors; + } + return ( @@ -193,6 +200,7 @@ export default function TaskShow() { schema={jsonSchema} uiSchema={formUiSchema} validator={validator} + customValidate={customValidate} > {reactFragmentToHideSubmitButton} diff --git a/spiffworkflow-frontend/src/services/HttpService.ts b/spiffworkflow-frontend/src/services/HttpService.ts index 78a29d07..b6080248 100644 --- a/spiffworkflow-frontend/src/services/HttpService.ts +++ b/spiffworkflow-frontend/src/services/HttpService.ts @@ -11,7 +11,7 @@ const HttpMethods = { const getBasicHeaders = (): object => { if (UserService.isLoggedIn()) { return { - Authorization: `Bearer ${UserService.getAuthToken()}`, + Authorization: `Bearer ${UserService.getAccessToken()}`, }; } return {}; @@ -64,6 +64,7 @@ backendCallProps) => { Object.assign(httpArgs, { headers: new Headers(headers as any), method: httpMethod, + credentials: 'include', }); const updatedPath = path.replace(/^\/v1\.0/, ''); diff --git a/spiffworkflow-frontend/src/services/UserService.ts b/spiffworkflow-frontend/src/services/UserService.ts index f893b926..23266c3e 100644 --- a/spiffworkflow-frontend/src/services/UserService.ts +++ b/spiffworkflow-frontend/src/services/UserService.ts @@ -1,4 +1,5 @@ import jwt from 'jwt-decode'; +import cookie from 'cookie'; import { BACKEND_BASE_URL } from '../config'; // NOTE: this currently stores the jwt token in local storage @@ -10,37 +11,46 @@ import { BACKEND_BASE_URL } from '../config'; // Some explanation: // https://dev.to/nilanth/how-to-secure-jwt-in-a-single-page-application-cko -// const getCurrentLocation = (queryParams: string = window.location.search) => { -const getCurrentLocation = () => { - const queryParamString = ''; - // if (queryParams) { - // queryParamString = `?${queryParams}`; - // } - return `${window.location.origin}${window.location.pathname}${queryParamString}`; +const getCookie = (key: string) => { + const parsedCookies = cookie.parse(document.cookie); + if (key in parsedCookies) { + return parsedCookies[key]; + } + return null; +}; + +const getCurrentLocation = (queryParams: string = window.location.search) => { + let queryParamString = ''; + if (queryParams) { + queryParamString = `${queryParams}`; + } + return encodeURIComponent( + `${window.location.origin}${window.location.pathname}${queryParamString}` + ); }; const doLogin = () => { const url = `${BACKEND_BASE_URL}/login?redirect_url=${getCurrentLocation()}`; window.location.href = url; }; + +// required for logging out const getIdToken = () => { - return localStorage.getItem('jwtIdToken'); + return getCookie('id_token'); }; const doLogout = () => { const idToken = getIdToken(); - localStorage.removeItem('jwtAccessToken'); - localStorage.removeItem('jwtIdToken'); const redirectUrl = `${window.location.origin}`; const url = `${BACKEND_BASE_URL}/logout?redirect_url=${redirectUrl}&id_token=${idToken}`; window.location.href = url; }; -const getAuthToken = () => { - return localStorage.getItem('jwtAccessToken'); +const getAccessToken = () => { + return getCookie('access_token'); }; const isLoggedIn = () => { - return !!getAuthToken(); + return !!getAccessToken(); }; const getUserEmail = () => { @@ -61,24 +71,8 @@ const getPreferredUsername = () => { return null; }; -// FIXME: we could probably change this search to a hook -// and then could use useSearchParams here instead -const getAuthTokenFromParams = () => { - const queryParams = new URLSearchParams(window.location.search); - const accessToken = queryParams.get('access_token'); - const idToken = queryParams.get('id_token'); - - queryParams.delete('access_token'); - queryParams.delete('id_token'); - - if (accessToken) { - localStorage.setItem('jwtAccessToken', accessToken); - if (idToken) { - localStorage.setItem('jwtIdToken', idToken); - } - // window.location.href = `${getCurrentLocation(queryParams.toString())}`; - window.location.href = `${getCurrentLocation()}`; - } else if (!isLoggedIn()) { +const loginIfNeeded = () => { + if (!isLoggedIn()) { doLogin(); } }; @@ -91,8 +85,8 @@ const UserService = { doLogin, doLogout, isLoggedIn, - getAuthToken, - getAuthTokenFromParams, + getAccessToken, + loginIfNeeded, getPreferredUsername, getUserEmail, hasRole, diff --git a/spiffworkflow-frontend/src/themes/carbon/BaseInputTemplate/BaseInputTemplate.tsx b/spiffworkflow-frontend/src/themes/carbon/BaseInputTemplate/BaseInputTemplate.tsx index 9e71b921..aa1c6451 100644 --- a/spiffworkflow-frontend/src/themes/carbon/BaseInputTemplate/BaseInputTemplate.tsx +++ b/spiffworkflow-frontend/src/themes/carbon/BaseInputTemplate/BaseInputTemplate.tsx @@ -45,7 +45,6 @@ export default function BaseInputTemplate< // Note: since React 15.2.0 we can't forward unknown element attributes, so we // exclude the "options" and "schema" ones here. if (!id) { - console.log('No id for', props); throw new Error(`no id for props ${JSON.stringify(props)}`); } const inputProps = {