Feature/typeahead allow guest user (#897)

* decode tokens with jwt instead of with base64 w/ burnettk

* try to verify jwt token with keycloak when we decode it w/ burnettk

* make the token algorithm a constant w/ burnettk

* WIP: create more valid looking jwt from spiff w/ burnettk

* tests are passsing now w/ burnettk

* some pyl stuff w/ burnettk

* fixed mypy issues w/ burnettk

* fixed issues from mypy fixes w/ burnettk

* do not load openid blueprint if not using those configs w/ burnettk

* used the process instance to determine if guest user can use connector api w/ burnettk

* only check the db for process instance if the api call is for typeahead

* removed unused test code

* pyl

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2024-01-16 14:47:25 -05:00 committed by GitHub
parent 2d9fb4430e
commit 80bc2f2e42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 313 additions and 173 deletions

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash
function error_handler() {
>&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
echo >&2 "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
exit "$2"
}
trap 'error_handler ${LINENO} $?' ERR
@ -31,23 +31,16 @@ function get_python_dirs() {
}
function run_autofixers() {
# checking command -v ruff is not good enough, since the asdf shim may be installed, which will make command -v succeed,
# but ruff may not have been pip installed inside the correct version of python.
if ! ruff --help >/dev/null 2>&1; then
pip install ruff
asdf reshim python
fi
python_dirs="$(get_python_dirs) bin"
# shellcheck disable=2086
ruff --fix $python_dirs || echo ''
poetry run ruff --fix $python_dirs || echo ''
}
function run_pre_commmit() {
poetry run pre-commit run --verbose --all-files
}
for react_project in "${react_projects[@]}" ; do
for react_project in "${react_projects[@]}"; do
# if pre, only do stuff when there are changes
if [[ -n "$(git status --porcelain "$react_project")" ]]; then
pushd "$react_project"
@ -56,7 +49,7 @@ for react_project in "${react_projects[@]}" ; do
fi
done
for python_project in "${python_projects[@]}" ; do
for python_project in "${python_projects[@]}"; do
# if pre, only do stuff when there are changes
if [[ "$subcommand" != "pre" ]] || [[ -n "$(git status --porcelain "$python_project")" ]]; then
pushd "$python_project"
@ -73,7 +66,7 @@ fi
function clear_log_file() {
unit_testing_log_file="./log/unit_testing.log"
if [[ -f "$unit_testing_log_file" ]]; then
> "$unit_testing_log_file"
>"$unit_testing_log_file"
fi
}

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash
function error_handler() {
>&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
echo >&2 "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
exit "$2"
}
trap 'error_handler ${LINENO} $?' ERR
@ -19,7 +19,6 @@ if [[ -d "$process_model_dir" ]]; then
shift
fi
if [[ -z "${SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR:-}" ]]; then
if [[ -n "${process_model_dir}" ]] && [[ -d "${process_model_dir}" ]]; then
SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR="$process_model_dir"
@ -33,8 +32,8 @@ if [[ "$process_model_dir" == "acceptance" ]]; then
export SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=true
export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=acceptance_tests.yml
elif [[ "$process_model_dir" == "localopenid" ]]; then
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="openid"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="openid"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="default"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="inernal openid"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__uri="http://localhost:$port/openid"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_id="spiffworkflow-backend"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q"

View File

@ -1,13 +1,16 @@
#!/usr/bin/env bash
function error_handler() {
>&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
echo >&2 "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
exit "$2"
}
trap 'error_handler ${LINENO} $?' ERR
set -o errtrace -o errexit -o nounset -o pipefail
script_dir="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
script_dir="$(
cd -- "$(dirname "$0")" >/dev/null 2>&1
pwd -P
)"
. "${script_dir}/local_development_environment_setup"
server_type="${1:-api}"
@ -24,7 +27,7 @@ else
SPIFFWORKFLOW_BACKEND_RUN_BACKGROUND_SCHEDULER_IN_CREATE_APP=false SPIFFWORKFLOW_BACKEND_FAIL_ON_INVALID_PROCESS_MODELS=false poetry run python bin/save_all_bpmn.py
fi
# this line blocks
poetry run flask run -p "$port" --host=0.0.0.0
# this line blocks
poetry run flask run -p "$port" --host=0.0.0.0
fi
fi

View File

@ -1,20 +1,20 @@
#!/usr/bin/env bash
function error_handler() {
>&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
echo >&2 "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
exit "$2"
}
trap 'error_handler ${LINENO} $?' ERR
set -o errtrace -o errexit -o nounset -o pipefail
if [[ ! -f ./src/instance/db_unit_testing_gw0.sqlite3 ]] ; then
>&2 echo -e "ERROR: please run the following command first in order to set up and migrate the sqlite unit_testing database:\n\n\tSPIFFWORKFLOW_BACKEND_DATABASE_TYPE=sqlite ./bin/recreate_db clean"
if [[ ! -f ./src/instance/db_unit_testing_gw0.sqlite3 ]]; then
echo >&2 -e "ERROR: please run the following command first in order to set up and migrate the sqlite unit_testing database:\n\n\tSPIFFWORKFLOW_BACKEND_DATABASE_TYPE=sqlite ./bin/recreate_db clean"
exit 1
fi
# check if python package pytest-xdist is installed
if ! python -c "import xdist" &>/dev/null; then
>&2 echo -e "ERROR: please install the python package pytest-xdist by running poetry install"
if ! poetry run python -c "import xdist" &>/dev/null; then
echo >&2 -e "ERROR: please install the python package pytest-xdist by running poetry install"
exit 1
fi

View File

@ -23,12 +23,16 @@ if os.environ.get("RUN_TYPEGUARD") == "true":
from spiffworkflow_backend import create_app # noqa: E402
@pytest.fixture(scope="session")
def app() -> Flask: # noqa
def _set_unit_testing_env_variables() -> None:
os.environ["SPIFFWORKFLOW_BACKEND_ENV"] = "unit_testing"
os.environ["FLASK_SESSION_SECRET_KEY"] = (
"e7711a3ba96c46c68e084a86952de16f" # noqa: S105, do not care about security when running unit tests
)
@pytest.fixture(scope="session")
def app() -> Flask: # noqa
_set_unit_testing_env_variables()
app = create_app()
# to screw with this, poet add nplusone --group dev

View File

@ -19,10 +19,9 @@ from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.db import migrate
from spiffworkflow_backend.routes.authentication_controller import _set_new_access_token_in_cookie
from spiffworkflow_backend.routes.authentication_controller import verify_token
from spiffworkflow_backend.routes.authentication_controller import omni_auth
from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import openid_blueprint
from spiffworkflow_backend.routes.user_blueprint import user_blueprint
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.monitoring_service import configure_sentry
from spiffworkflow_backend.services.monitoring_service import setup_prometheus_metrics
@ -72,7 +71,11 @@ def create_app() -> flask.app.Flask:
app.register_blueprint(user_blueprint)
app.register_blueprint(api_error_blueprint)
app.register_blueprint(openid_blueprint, url_prefix="/openid")
# only register the backend openid server if the backend is configured to use it
backend_auths = app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"]
if len(backend_auths) == 1 and backend_auths[0]["uri"] == f"{app.config['SPIFFWORKFLOW_BACKEND_URL']}/openid":
app.register_blueprint(openid_blueprint, url_prefix="/openid")
# preflight options requests will be allowed if they meet the requirements of the url regex.
# we will add an Access-Control-Max-Age header to the response to tell the browser it doesn't
@ -89,8 +92,7 @@ def create_app() -> flask.app.Flask:
configure_sentry(app)
app.before_request(verify_token)
app.before_request(AuthorizationService.check_for_permission)
app.before_request(omni_auth)
app.after_request(_set_new_access_token_in_cookie)
# The default is true, but we want to preserve the order of keys in the json

View File

@ -714,8 +714,7 @@ paths:
minItems: 1
get:
operationId: spiffworkflow_backend.routes.process_api_blueprint.process_caller_list
summary:
Return a list of information about all processes that call the provided process id
summary: Return a list of information about all processes that call the provided process id
tags:
- Process Models
responses:
@ -728,7 +727,6 @@ paths:
items:
$ref: "#/components/schemas/Process"
/processes:
get:
operationId: spiffworkflow_backend.routes.process_api_blueprint.process_list
@ -2222,15 +2220,14 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
$ref: "#/components/schemas/Task"
responses:
"200":
description: Returns the same data structure provided, but after some replacements.
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
$ref: "#/components/schemas/Task"
/tasks/{process_instance_id}/instruction:
parameters:
@ -2827,16 +2824,8 @@ paths:
"200":
description: A list of the data stored in the requested data store.
components:
securitySchemes:
jwt:
type: http
scheme: bearer
bearerFormat: JWT
x-bearerInfoFunc: spiffworkflow_backend.routes.authentication_controller.verify_token
x-scopeValidateFunc: spiffworkflow_backend.routes.authentication_controller.validate_scope
oAuth2AuthCode:
type: oauth2
description: authenticate with openid server

View File

@ -181,6 +181,7 @@ def setup_config(app: Flask) -> None:
env_config_prefix = "spiffworkflow_backend.config."
env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"]
load_config_file(app, env_config_module)
# This allows config/testing.py or instance/config.py to override the default config
@ -226,7 +227,7 @@ def setup_config(app: Flask) -> None:
app.config["SPIFFWORKFLOW_BACKEND_MAX_INSTANCE_LOCK_DURATION_IN_SECONDS"]
)
if "SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS" not in app.config:
if app.config.get("SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS") is None:
app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"] = [
{
"identifier": "default",

View File

@ -127,6 +127,7 @@ else:
}
]
### logs
# loggers to use is a comma separated list of logger prefixes that we will be converted to list of strings
config_from_env("SPIFFWORKFLOW_BACKEND_LOGGERS_TO_USE")

View File

@ -0,0 +1,41 @@
# These configs are specifically for the open id server run from backend.
# This should only be used for development and demonstration. SHOULD NOT BE USED IN PROD.
class OpenIdConfigsForDevOnly:
private_key = """-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCB34SwG3IFKAzf
BGm2VCWrqhlhjxXgQjx42Wsp4Nh8UqhSCnBE+/3sMwSxgwmuwDANiV9pkifWDbAy
jLcc5i9YrrwqagyeMk1xjwVmCSB5RPgwJMP3NOyiciM8V5z8qQXE8+Xv9jOoDExC
DL5M/vDsGg4Xe02kdJleUuxmQI2r9PlLklY9p7NUvcFbh1qVEuMQ7ToYqMyuiNop
1cIhRdKzV7BcMhE8Bby+bmmhfRDNhq4zM2W4OHithsdREoeurzTebhUR/RwA9j4a
w6Niy/awBOomgMuqZ+uQXK5R9B2P21jSt+ljMTxcUzoYhJ/yIv/NvgQAvqs0Umqx
4gT7erRPAgMBAAECggEACGj4Hf7w/I3lSeEILW/rVPks/C9rv1V0UAkYRbwTMl3L
gfokeG5VbG25vWFnoTYKoCqUqXDDydQIfgWBuBcQQTuZ4MJ0TB9xLcwREYeNLEn/
xMvxfexV8VQ90HyfisPK2IziVEGXtidxt4pwULeAbdgA484uyKcD5Xx6/xYc3OIA
T+bfwfydJB5e09e6hZ8glTD50LX7b6Hy5b/iYsYCVzfXh8+DM1UQQQEWA++ZsYo0
qUBJMoB7L5AbMZaWWmTJilEi4MaBnjPw8JEoW9VH7ZjZiVCPlAV9DxSSeBuwfqUy
KZpbYcJEZNELiTgVcyaqjR3mt1Q4iCYuIkMUJ9/ZdQKBgQC1PPBQYeOcgAzx9o5N
gNYy/19xqL9lhoFle8Y9a28JbX2XpLtTzwj6WbvW4hb2AlBLLc/BAtr2TRn4b7qU
0MhAclvvmh6Zccwfp1tCNn3t6JpdTqIbG8eYgHDnBGK27xvhsyX22rmBcBpJWfMK
T7MpB9y7/r0iU84wAYvOcAS1awKBgQC3cloRPloKqXUQh3k8uWnZucm6/gGPHNir
FyFfHT45rlewlfuLPcN13vezb0vmKRjzHVDqo+1xrUmjFj857uLpQZ4lz7X5Xl9k
hSw0+1heqRmKumQIF12rg5elBfTSvys2N5CiH4EykO3P97jz0zTFij+orYdtbrA8
LqfyuMARrQKBgQCAlvse3UVkTcphhwESZl4UEvMCLquV+igm+/n8rBQ9SS06AcxT
u2pwTmijHwkRhTS5Eoj8Ne1roerSRVvJqJTcfQdT6jLZxk8BCnoKcaVJvqZ/m4IS
39PvFPqGPqtXhjFvIu/FxQynlQVhk+uIHmJMs2JfFG/XQkTh9MbgMsR0fwKBgQCS
67rG5LEoqN9hBZ9LyxPDlNDEOnr1K508KaJIkxsrBz6j5vs3YZgR5ylrRE/9Xhzl
WS1dPz0ENk0rmL26oGCLgEow7lJIDhVIZIArTsJPzg7u1KkY8d3LZ/Ej8clKoGDz
Yz0rGyBWZ0yPq08tuJIjQ74IUjKMqoHrMVLBSsZJYQJ/U6LpkmH6JrJfC81ql95y
iOtxcJaAH/38HVvhXsCn4/uQUu/CyxH6BLleh7ImdnmO5BMInZwkFHd9tS1AdjiA
M8V/du4022V11ivFv8mwyKbBjsODcwhfOarCm1phOi95ksFA8KJdQPYsEw/Gba0K
CQKKq+mlG5zUY1rU1uXxjA==
-----END PRIVATE KEY-----"""
public_key = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgd+EsBtyBSgM3wRptlQl
q6oZYY8V4EI8eNlrKeDYfFKoUgpwRPv97DMEsYMJrsAwDYlfaZIn1g2wMoy3HOYv
WK68KmoMnjJNcY8FZgkgeUT4MCTD9zTsonIjPFec/KkFxPPl7/YzqAxMQgy+TP7w
7BoOF3tNpHSZXlLsZkCNq/T5S5JWPaezVL3BW4dalRLjEO06GKjMrojaKdXCIUXS
s1ewXDIRPAW8vm5poX0QzYauMzNluDh4rYbHURKHrq803m4VEf0cAPY+GsOjYsv2
sATqJoDLqmfrkFyuUfQdj9tY0rfpYzE8XFM6GISf8iL/zb4EAL6rNFJqseIE+3q0
TwIDAQAB
-----END PUBLIC KEY-----"""

View File

@ -9,6 +9,11 @@ SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME = environ.get(
"SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME", default="unit_testing.yml"
)
SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL = "http://localhost:7000/openid"
SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID = "spiffworkflow-backend"
SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY = "JXeQExm0JhQPLumgHtIIqf52bDalHz0q" # noqa: S105
SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS = None
SPIFFWORKFLOW_BACKEND_LOG_LEVEL = environ.get("SPIFFWORKFLOW_BACKEND_LOG_LEVEL", default="debug")
SPIFFWORKFLOW_BACKEND_GIT_COMMIT_ON_SAVE = False

View File

@ -323,5 +323,4 @@ def handle_exception(exception: Exception) -> flask.wrappers.Response:
if api_exception.response_headers is not None:
for header, value in api_exception.response_headers.items():
error_response.headers[header] = value
return error_response

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import math
import time
from dataclasses import dataclass
from typing import Any
@ -13,6 +15,9 @@ from spiffworkflow_backend.models.group import GroupModel
SPIFF_NO_AUTH_USER = "spiff_no_auth_guest_user"
SPIFF_GUEST_USER = "spiff_guest_user"
SPIFF_GENERATED_JWT_KEY_ID = "spiff_backend"
SPIFF_GENERATED_JWT_ALGORITHM = "HS256"
SPIFF_GENERATED_JWT_AUDIENCE = "spiffworkflow-backend"
class UserNotFoundError(Exception):
@ -47,6 +52,10 @@ class UserModel(SpiffworkflowBaseDBModel):
)
principal = relationship("PrincipalModel", uselist=False, cascade="delete") # type: ignore
@classmethod
def spiff_generated_jwt_issuer(cls) -> str:
return str(current_app.config["SPIFFWORKFLOW_BACKEND_URL"])
def encode_auth_token(self, extra_payload: dict | None = None) -> str:
"""Generate the Auth Token.
@ -62,16 +71,17 @@ class UserModel(SpiffworkflowBaseDBModel):
"email": self.email,
"preferred_username": self.username,
"sub": f"service:{self.service}::service_id:{self.service_id}",
"token_type": "internal",
"iss": self.__class__.spiff_generated_jwt_issuer(),
"iat": math.floor(time.time()),
"exp": round(time.time()) + 3600,
"aud": SPIFF_GENERATED_JWT_AUDIENCE,
}
payload = base_payload
if extra_payload is not None:
payload = {**base_payload, **extra_payload}
return jwt.encode(
payload,
secret_key,
algorithm="HS256",
payload, secret_key, algorithm=SPIFF_GENERATED_JWT_ALGORITHM, headers={"kid": SPIFF_GENERATED_JWT_KEY_ID}
)
def as_dict(self) -> dict[str, Any]:

View File

@ -1,11 +1,8 @@
import ast
import base64
import json
import re
from typing import Any
import flask
import jwt
from flask import current_app
from flask import g
from flask import jsonify
@ -41,8 +38,14 @@ def authentication_options() -> Response:
return make_response(jsonify(response), 200)
# this does both authx and authn
def omni_auth() -> None:
decoded_token = verify_token()
AuthorizationService.check_for_permission(decoded_token)
# authorization_exclusion_list = ['status']
def verify_token(token: str | None = None, force_run: bool | None = False) -> None:
def verify_token(token: str | None = None, force_run: bool | None = False) -> dict | None:
"""Verify the token for the user (if provided).
If in production environment and token is not provided, gets user from the SSO headers and returns their token.
@ -67,8 +70,10 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No
_clear_auth_tokens_from_thread_local_data()
user_model = None
decoded_token = None
if token_info["token"] is not None:
user_model = _get_user_model_from_token(token_info["token"])
decoded_token = _get_decoded_token(token_info["token"])
user_model = _get_user_model_from_token(decoded_token)
elif token_info["api_key"] is not None:
user_model = _get_user_model_from_api_key(token_info["api_key"])
@ -89,7 +94,7 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No
get_scope(token_info["token"])
elif token_info["api_key"]:
g.authenticated = True
return None
return decoded_token
raise ApiError(error_code="invalid_token", message="Cannot validate token.", status_code=401)
@ -120,7 +125,7 @@ def login(
AuthenticationService.create_guest_token(
username=SPIFF_GUEST_USER,
group_identifier=SPIFF_GUEST_GROUP,
auth_token_properties={"only_guest_task_completion": True},
auth_token_properties={"only_guest_task_completion": True, "process_instance_id": process_instance_id},
authentication_identifier=authentication_identifier,
)
return redirect(redirect_url)
@ -139,11 +144,11 @@ def login_return(code: str, state: str, session_state: str = "") -> Response | N
auth_token_object = AuthenticationService().get_auth_token_object(code, authentication_identifier=authentication_identifier)
if "id_token" in auth_token_object:
id_token = auth_token_object["id_token"]
user_info = _parse_id_token(id_token)
decoded_token = _get_decoded_token(id_token)
if AuthenticationService.validate_id_or_access_token(id_token, authentication_identifier=authentication_identifier):
if user_info and "error" not in user_info:
user_model = AuthorizationService.create_user_from_sign_in(user_info)
if AuthenticationService.validate_decoded_token(decoded_token, authentication_identifier=authentication_identifier):
if decoded_token and "error" not in decoded_token:
user_model = AuthorizationService.create_user_from_sign_in(decoded_token)
g.user = user_model.id
g.token = auth_token_object["id_token"]
if "refresh_token" in auth_token_object:
@ -174,11 +179,11 @@ def login_return(code: str, state: str, session_state: str = "") -> Response | N
# FIXME: share more code with login_return and maybe attempt to get a refresh token
def login_with_access_token(access_token: str, authentication_identifier: str) -> Response:
user_info = _parse_id_token(access_token)
decoded_token = _get_decoded_token(access_token)
if AuthenticationService.validate_id_or_access_token(access_token, authentication_identifier=authentication_identifier):
if user_info and "error" not in user_info:
AuthorizationService.create_user_from_sign_in(user_info)
if AuthenticationService.validate_decoded_token(decoded_token, authentication_identifier=authentication_identifier):
if decoded_token and "error" not in decoded_token:
AuthorizationService.create_user_from_sign_in(decoded_token)
else:
raise ApiError(
error_code="invalid_login",
@ -224,7 +229,7 @@ def logout_return() -> Response:
def get_scope(token: str) -> str:
scope = ""
decoded_token = jwt.decode(token, options={"verify_signature": False})
decoded_token = _get_decoded_token(token)
if "scope" in decoded_token:
scope = decoded_token["scope"]
return scope
@ -280,7 +285,7 @@ def _clear_auth_tokens_from_thread_local_data() -> None:
delattr(tld, "user_has_logged_out")
def _force_logout_user_if_necessary(user_model: UserModel | None = None) -> bool:
def _force_logout_user_if_necessary(user_model: UserModel | None, decoded_token: dict) -> bool:
"""Logs out a guest user if certain criteria gets met.
* if the user is a no auth guest and we have auth enabled
@ -294,7 +299,7 @@ def _force_logout_user_if_necessary(user_model: UserModel | None = None) -> bool
) or (
user_model.username == SPIFF_GUEST_USER
and user_model.service_id == "spiff_guest_service_id"
and not AuthorizationService.request_allows_guest_access()
and not AuthorizationService.request_allows_guest_access(decoded_token)
):
AuthenticationService.set_user_has_logged_out()
return True
@ -328,88 +333,87 @@ def _get_user_model_from_api_key(api_key: str) -> UserModel | None:
return user_model
def _get_user_model_from_token(token: str) -> UserModel | None:
def _get_user_model_from_token(decoded_token: dict) -> UserModel | None:
user_model = None
decoded_token = _get_decoded_token(token)
if decoded_token is not None:
if "token_type" in decoded_token:
token_type = decoded_token["token_type"]
if token_type == "internal": # noqa: S105
if "iss" in decoded_token.keys():
if decoded_token["iss"] == UserModel.spiff_generated_jwt_issuer():
try:
user_model = _get_user_from_decoded_internal_token(decoded_token)
except Exception as e:
current_app.logger.error(f"Exception in verify_token getting user from decoded internal token. {e}")
# if the user is forced logged out then stop processing the token
if _force_logout_user_if_necessary(user_model):
return None
# if the user is forced logged out then stop processing the token
if _force_logout_user_if_necessary(user_model, decoded_token):
return None
else:
user_info = None
authentication_identifier = _get_authentication_identifier_from_request()
try:
if AuthenticationService.validate_decoded_token(
decoded_token, authentication_identifier=authentication_identifier
):
user_info = decoded_token
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"])
if user:
refresh_token = AuthenticationService.get_refresh_token(user.id)
if refresh_token:
auth_token: dict = AuthenticationService.get_auth_token_from_refresh_token(
refresh_token, authentication_identifier=authentication_identifier
)
if auth_token and "error" not in auth_token and "id_token" in auth_token:
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.new_access_token = auth_token["id_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,
"iss": user.service,
}
elif "iss" in decoded_token.keys():
user_info = None
authentication_identifier = _get_authentication_identifier_from_request()
try:
if AuthenticationService.validate_id_or_access_token(token, authentication_identifier=authentication_identifier):
user_info = decoded_token
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"])
if user:
refresh_token = AuthenticationService.get_refresh_token(user.id)
if refresh_token:
auth_token: dict = AuthenticationService.get_auth_token_from_refresh_token(
refresh_token, authentication_identifier=authentication_identifier
)
if auth_token and "error" not in auth_token and "id_token" in auth_token:
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.new_access_token = auth_token["id_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,
"iss": user.service,
}
if user_info is None:
AuthenticationService.set_user_has_logged_out()
raise ApiError(
error_code="invalid_token",
message="Your token is expired. Please Login",
status_code=401,
) from token_expired_error
if user_info is None:
except Exception as e:
AuthenticationService.set_user_has_logged_out()
raise ApiError(
error_code="invalid_token",
message="Your token is expired. Please Login",
error_code="fail_get_user_info",
message="Cannot get user info from token",
status_code=401,
) from token_expired_error
except Exception as e:
AuthenticationService.set_user_has_logged_out()
raise ApiError(
error_code="fail_get_user_info",
message="Cannot get user info from token",
status_code=401,
) from e
if user_info is not None and "error" not in user_info and "iss" in user_info: # not sure what to test yet
user_model = (
UserModel.query.filter(UserModel.service == user_info["iss"])
.filter(UserModel.service_id == user_info["sub"])
.first()
)
if user_model is None:
) from e
if user_info is not None and "error" not in user_info and "iss" in user_info: # not sure what to test yet
user_model = (
UserModel.query.filter(UserModel.service == user_info["iss"])
.filter(UserModel.service_id == user_info["sub"])
.first()
)
if user_model is None:
AuthenticationService.set_user_has_logged_out()
raise ApiError(
error_code="invalid_user",
message="Invalid user. Please log in.",
status_code=401,
)
# no user_info
else:
AuthenticationService.set_user_has_logged_out()
raise ApiError(
error_code="invalid_user",
message="Invalid user. Please log in.",
error_code="no_user_info",
message="Cannot retrieve user info",
status_code=401,
)
# no user_info
else:
AuthenticationService.set_user_has_logged_out()
raise ApiError(
error_code="no_user_info",
message="Cannot retrieve user info",
status_code=401,
)
else:
current_app.logger.debug("token_type not in decode_token in verify_token")
current_app.logger.debug("iss not in decode_token in verify_token")
AuthenticationService.set_user_has_logged_out()
raise ApiError(
error_code="invalid_token",
@ -432,13 +436,13 @@ def _get_user_from_decoded_internal_token(decoded_token: dict) -> UserModel | No
return user
def _get_decoded_token(token: str) -> dict | None:
def _get_decoded_token(token: str) -> dict:
try:
decoded_token: dict = jwt.decode(token, options={"verify_signature": False})
decoded_token: dict = AuthenticationService.parse_jwt_token(_get_authentication_identifier_from_request(), token)
except Exception as e:
raise ApiError(error_code="invalid_token", message="Cannot decode token.") from e
else:
if "token_type" in decoded_token or "iss" in decoded_token:
if "iss" in decoded_token:
return decoded_token
else:
current_app.logger.error(f"Unknown token type in get_decoded_token: token: {token}")
@ -448,20 +452,6 @@ def _get_decoded_token(token: str) -> dict | None:
)
def _parse_id_token(token: str) -> Any:
"""Parse the id token."""
parts = token.split(".")
if len(parts) != 3:
raise Exception("Incorrect id token format")
payload = parts[1]
padded = payload + "=" * (4 - len(payload) % 4)
# https://lists.jboss.org/pipermail/keycloak-user/2016-April/005758.html
decoded = base64.urlsafe_b64decode(padded)
return json.loads(decoded)
def _get_authentication_identifier_from_request() -> str:
if "authentication_identifier" in request.cookies:
return request.cookies["authentication_identifier"]

View File

@ -7,6 +7,7 @@ This is just here to make local development, testing, and demonstration easier.
"""
import base64
import json
import math
import time
from typing import Any
from urllib.parse import urlencode
@ -15,15 +16,21 @@ import jwt
import yaml
from flask import Blueprint
from flask import current_app
from flask import jsonify
from flask import make_response
from flask import redirect
from flask import render_template
from flask import request
from flask import url_for
from werkzeug.wrappers import Response
from spiffworkflow_backend.config.openid.rsa_keys import OpenIdConfigsForDevOnly
openid_blueprint = Blueprint("openid", __name__, template_folder="templates", static_folder="static")
OPEN_ID_CODE = ":this_is_not_secure_do_not_use_in_production"
SPIFF_OPEN_ID_KEY_ID = "spiffworkflow_backend_open_id"
SPIFF_OPEN_ID_ALGORITHM = "RS256"
@openid_blueprint.route("/.well-known/openid-configuration", methods=["GET"])
@ -38,6 +45,7 @@ def well_known() -> dict:
"authorization_endpoint": f"{host_url}{url_for('openid.auth')}",
"token_endpoint": f"{host_url}{url_for('openid.token')}",
"end_session_endpoint": f"{host_url}{url_for('openid.end_session')}",
"jwks_uri": f"{host_url}{url_for('openid.jwks')}",
}
@ -97,22 +105,24 @@ def token() -> Response | dict:
authorization = request.headers.get("Authorization", "Basic ")
authorization = authorization[6:] # Remove "Basic"
authorization = base64.b64decode(authorization).decode("utf-8")
client_id, client_secret = authorization.split(":")
client_id = authorization.split(":")
base_url = request.host_url + "openid"
private_key = OpenIdConfigsForDevOnly.private_key
id_token = jwt.encode(
{
"iss": base_url,
"aud": [client_id, "account"],
"iat": time.time(),
"exp": time.time() + 86400, # Expire after a day.
"aud": client_id,
"iat": math.floor(time.time()),
"exp": round(time.time()) + 3600,
"sub": user_name,
"email": user_details["email"],
"preferred_username": user_details.get("preferred_username", user_name),
},
client_secret,
algorithm="HS256",
private_key,
algorithm=SPIFF_OPEN_ID_ALGORITHM,
headers={"kid": SPIFF_OPEN_ID_KEY_ID},
)
response = {
"access_token": id_token,
@ -129,6 +139,21 @@ def end_session() -> Response:
return redirect(redirect_url)
@openid_blueprint.route("/jwks", methods=["GET"])
def jwks() -> Response:
public_key = base64.b64encode(OpenIdConfigsForDevOnly.public_key.encode()).decode("utf-8")
jwks_configs = {
"keys": [
{
"kid": SPIFF_OPEN_ID_KEY_ID,
"kty": "RSA",
"x5c": [public_key],
}
]
}
return make_response(jsonify(jwks_configs), 200)
@openid_blueprint.route("/refresh", methods=["POST"])
def refresh() -> str:
return ""

View File

@ -6,6 +6,16 @@ import time
from hashlib import sha256
from hmac import HMAC
from hmac import compare_digest
from typing import Any
from cryptography.hazmat.backends import default_backend
from cryptography.x509 import load_der_x509_certificate
from spiffworkflow_backend.models.user import SPIFF_GENERATED_JWT_ALGORITHM
from spiffworkflow_backend.models.user import SPIFF_GENERATED_JWT_AUDIENCE
from spiffworkflow_backend.models.user import SPIFF_GENERATED_JWT_KEY_ID
from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import SPIFF_OPEN_ID_KEY_ID
if sys.version_info < (3, 11):
from typing_extensions import NotRequired
@ -57,6 +67,7 @@ class AuthenticationOptionNotFoundError(Exception):
class AuthenticationService:
ENDPOINT_CACHE: dict[str, dict[str, str]] = {} # We only need to find the openid endpoints once, then we can cache them.
JSON_WEB_KEYSET_CACHE: dict[str, dict[str, str]] = {}
@classmethod
def authentication_options_for_api(cls) -> list[AuthenticationOptionForApi]:
@ -86,6 +97,10 @@ class AuthenticationService:
config: str = cls.authentication_option_for_identifier(authentication_identifier)["client_id"]
return config
@classmethod
def valid_audiences(cls, authentication_identifier: str) -> list[str]:
return [cls.client_id(authentication_identifier)]
@classmethod
def server_url(cls, authentication_identifier: str) -> str:
"""Returns the server url from the config."""
@ -104,6 +119,8 @@ class AuthenticationService:
openid_config_url = f"{cls.server_url(authentication_identifier)}/.well-known/openid-configuration"
if authentication_identifier not in cls.ENDPOINT_CACHE:
cls.ENDPOINT_CACHE[authentication_identifier] = {}
if authentication_identifier not in cls.JSON_WEB_KEYSET_CACHE:
cls.JSON_WEB_KEYSET_CACHE[authentication_identifier] = {}
if name not in AuthenticationService.ENDPOINT_CACHE[authentication_identifier]:
try:
response = requests.get(openid_config_url, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
@ -115,6 +132,63 @@ class AuthenticationService:
config: str = AuthenticationService.ENDPOINT_CACHE[authentication_identifier].get(name, "")
return config
@classmethod
def get_jwks_config_from_uri(cls, jwks_uri: str) -> dict:
if jwks_uri not in AuthenticationService.JSON_WEB_KEYSET_CACHE:
try:
jwt_ks_response = requests.get(jwks_uri, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
AuthenticationService.JSON_WEB_KEYSET_CACHE[jwks_uri] = jwt_ks_response.json()
except requests.exceptions.ConnectionError as ce:
raise OpenIdConnectionError(f"Cannot connect to given jwks url: {jwks_uri}") from ce
return AuthenticationService.JSON_WEB_KEYSET_CACHE[jwks_uri]
@classmethod
def jwks_public_key_for_key_id(cls, authentication_identifier: str, key_id: str) -> dict:
jwks_uri = cls.open_id_endpoint_for_name("jwks_uri", authentication_identifier)
jwks_configs = cls.get_jwks_config_from_uri(jwks_uri)
json_key_configs: dict = next(jk for jk in jwks_configs["keys"] if jk["kid"] == key_id)
return json_key_configs
@classmethod
def parse_jwt_token(cls, authentication_identifier: str, token: str) -> dict:
header = jwt.get_unverified_header(token)
key_id = str(header.get("kid"))
# if the token has our key id then we issued it and should verify to ensure it's valid
if key_id == SPIFF_GENERATED_JWT_KEY_ID:
return jwt.decode(
token,
str(current_app.secret_key),
algorithms=[SPIFF_GENERATED_JWT_ALGORITHM],
audience=SPIFF_GENERATED_JWT_AUDIENCE,
options={"verify_exp": False},
)
else:
json_key_configs = cls.jwks_public_key_for_key_id(authentication_identifier, key_id)
x5c = json_key_configs["x5c"][0]
algorithm = str(header.get("alg"))
decoded_certificate = base64.b64decode(x5c)
# our backend-based openid provider implementation (which you should never use in prod)
# uses a public/private key pair. we played around with adding an x509 cert so we could
# follow the exact same mechanism for getting the public key that we use for keycloak,
# but using an x509 cert for no reason seemed a little overboard for this toy-openid use case,
# when we already have the public key that can work hardcoded in our config.
public_key: Any = None
if key_id == SPIFF_OPEN_ID_KEY_ID:
public_key = decoded_certificate
else:
x509_cert = load_der_x509_certificate(decoded_certificate, default_backend())
public_key = x509_cert.public_key()
return jwt.decode(
token,
public_key,
algorithms=[algorithm],
audience=cls.valid_audiences(authentication_identifier)[0],
options={"verify_exp": False},
)
@staticmethod
def get_backend_url() -> str:
return str(current_app.config["SPIFFWORKFLOW_BACKEND_URL"])
@ -177,7 +251,7 @@ class AuthenticationService:
if azp is None:
return True
valid_client_ids = [cls.client_id(authentication_identifier), "account"]
valid_client_ids = [cls.client_id(authentication_identifier)]
if (
"additional_valid_client_ids" in cls.authentication_option_for_identifier(authentication_identifier)
and cls.authentication_option_for_identifier(authentication_identifier)["additional_valid_client_ids"] is not None
@ -190,30 +264,26 @@ class AuthenticationService:
return azp in valid_client_ids
@classmethod
def validate_id_or_access_token(cls, token: str, authentication_identifier: str) -> bool:
def validate_decoded_token(cls, decoded_token: dict, authentication_identifier: str) -> bool:
"""Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation."""
valid = True
now = round(time.time())
try:
decoded_token = jwt.decode(token, options={"verify_signature": False})
except Exception as e:
raise TokenInvalidError("Cannot decode token") from e
# give a 5 second leeway to iat in case keycloak server time doesn't match backend server
iat_clock_skew_leeway = 5
iss = decoded_token["iss"]
aud = decoded_token["aud"]
aud = decoded_token["aud"] if "aud" in decoded_token else None
azp = decoded_token["azp"] if "azp" in decoded_token else None
iat = decoded_token["iat"]
valid_audience_values = (cls.client_id(authentication_identifier), "account")
valid_audience_values = cls.valid_audiences(authentication_identifier)
audience_array_in_token = aud
if isinstance(aud, str):
audience_array_in_token = [aud]
overlapping_aud_values = [x for x in audience_array_in_token if x in valid_audience_values]
if iss != cls.server_url(authentication_identifier):
if iss not in [cls.server_url(authentication_identifier), UserModel.spiff_generated_jwt_issuer()]:
current_app.logger.error(
f"TOKEN INVALID because ISS '{iss}' does not match server url '{cls.server_url(authentication_identifier)}'"
)

View File

@ -30,6 +30,7 @@ from spiffworkflow_backend.models.human_task import HumanTaskModel
from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel
from spiffworkflow_backend.models.permission_target import PermissionTargetModel
from spiffworkflow_backend.models.principal import PrincipalModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
@ -281,7 +282,7 @@ class AuthorizationService:
return None
@classmethod
def check_for_permission(cls) -> None:
def check_for_permission(cls, decoded_token: dict | None) -> None:
if cls.should_disable_auth_for_request():
return None
@ -293,7 +294,7 @@ class AuthorizationService:
if cls.request_is_excluded_from_permission_check():
return None
if cls.request_allows_guest_access():
if cls.request_allows_guest_access(decoded_token):
return None
permission_string = cls.get_permission_from_http_method(request.method)
@ -319,7 +320,7 @@ class AuthorizationService:
return False
@classmethod
def request_allows_guest_access(cls) -> bool:
def request_allows_guest_access(cls, decoded_token: dict | None) -> bool:
if cls.request_is_excluded_from_permission_check():
return True
@ -329,6 +330,18 @@ class AuthorizationService:
task_guid = request.path.split("/")[4]
if TaskModel.task_guid_allows_guest(task_guid, process_instance_id):
return True
if (
decoded_token is not None
and "process_instance_id" in decoded_token
and "only_guest_task_completion" in decoded_token
and decoded_token["only_guest_task_completion"] is True
and api_view_function.__name__ == "typeahead"
and api_view_function.__module__ == "spiffworkflow_backend.routes.connector_proxy_controller"
):
process_instance = ProcessInstanceModel.query.filter_by(id=decoded_token["process_instance_id"]).first()
if process_instance is not None and not process_instance.has_terminal_status():
return True
return False
@staticmethod

View File

@ -1,7 +1,6 @@
import ast
import base64
import re
import time
from flask.app import Flask
from flask.testing import FlaskClient
@ -45,8 +44,6 @@ class TestAuthentication(BaseTest):
"groups": ["group_one", "group_two"],
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
"aud": "spiffworkflow-backend",
"iat": round(time.time()),
"exp": round(time.time()) + 1000,
}
)
response = None
@ -63,8 +60,6 @@ class TestAuthentication(BaseTest):
"groups": ["group_one"],
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
"aud": "spiffworkflow-backend",
"iat": round(time.time()),
"exp": round(time.time()) + 1000,
}
)
response = client.post(