Feature/support multiple auth (#602)

* added some support for configs to have mutliple auths

* multiple openids services are mostly working - still needs some cleanup

* some cleanup for pyl and fixed login_return for internal openid server w/ burnettk

* if only one auth is returned from backend then just do that w/ burnettk

* login page has been formatted w/ burnettk

* some extra formatting on the login page w/ burnettk

* relabel test openid providers and add user

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
Co-authored-by: burnettk <burnettk@users.noreply.github.com>
This commit is contained in:
jasquat 2023-11-09 10:34:07 -05:00 committed by GitHub
parent a48bc8a885
commit d5b0330609
24 changed files with 537 additions and 118 deletions

View File

@ -29,8 +29,24 @@ 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_OPEN_ID_SERVER_URL="http://localhost:$port/openid"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="openid"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="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"
export SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME="example.yml"
else
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__identifier="keycloak_internal"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__label="I am a Core Contributor"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__uri="http://localhost:7002/realms/spiffworkflow"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_id="spiffworkflow-backend"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__0__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__identifier="openid"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__label="I am a vendor"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__uri="http://localhost:$port/openid"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__client_id="spiffworkflow-backend"
export SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS__1__client_secret="JXeQExm0JhQPLumgHtIIqf52bDalHz0q"
fi
if [[ -z "${SPIFFWORKFLOW_BACKEND_ENV:-}" ]]; then

View File

@ -10,8 +10,22 @@ servers:
security: []
paths:
/authentication-options:
get:
summary: redirect to open id authentication server
operationId: spiffworkflow_backend.routes.authentication_controller.authentication_options
tags:
- Authentication
responses:
"200":
description: Redirects to authentication server
/login:
parameters:
- name: authentication_identifier
in: query
required: true
schema:
type: string
- name: redirect_url
in: query
required: false
@ -71,6 +85,11 @@ paths:
required: false
schema:
type: string
- name: authentication_identifier
in: query
required: true
schema:
type: string
get:
operationId: spiffworkflow_backend.routes.authentication_controller.logout
summary: Logout authenticated user
@ -96,6 +115,11 @@ paths:
required: true
schema:
type: string
- name: authentication_identifier
in: query
required: true
schema:
type: string
post:
operationId: spiffworkflow_backend.routes.authentication_controller.login_with_access_token
summary: Authenticate user for API access with an openid token already posessed.
@ -110,6 +134,12 @@ paths:
$ref: "#/components/schemas/OkTrue"
/login_api:
parameters:
- name: authentication_identifier
in: query
required: true
schema:
type: string
get:
operationId: spiffworkflow_backend.routes.authentication_controller.login_api
summary: Authenticate user for API access

View File

@ -195,6 +195,17 @@ 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:
app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"] = [
{
"identifier": "default",
"label": "Default",
"uri": app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"),
"client_id": app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID"),
"client_secret": app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY"),
}
]
thread_local_data = threading.local()
app.config["THREAD_LOCAL_DATA"] = thread_local_data
_set_up_tenant_specific_fields_as_list_of_strings(app)

View File

@ -1,6 +1,8 @@
import re
from os import environ
from spiffworkflow_backend.config.normalized_environment import normalized_environment
# Consider: https://flask.palletsprojects.com/en/2.2.x/config/#configuring-from-environment-variables
# and from_prefixed_env(), though we want to ensure that these variables are all documented, so that
# is a benefit of the status quo and having them all in this file explicitly.
@ -30,6 +32,8 @@ def config_from_env(variable_name: str, *, default: str | bool | int | None = No
globals()[variable_name] = value_to_return
configs_with_structures = normalized_environment(environ)
### basic
config_from_env("FLASK_SESSION_SECRET_KEY")
config_from_env("SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR")
@ -75,24 +79,28 @@ config_from_env("SPIFFWORKFLOW_BACKEND_DATABASE_POOL_SIZE")
### open id
config_from_env("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED", default=False)
# Open ID server
# use "http://localhost:7000/openid" for running with simple openid
# server hosted by spiffworkflow-backend
config_from_env(
"SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL",
default="http://localhost:7002/realms/spiffworkflow",
)
config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID", default="spiffworkflow-backend")
config_from_env(
"SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY",
default="JXeQExm0JhQPLumgHtIIqf52bDalHz0q",
) # noqa: S105
config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_IS_AUTHORITY_FOR_USER_GROUPS", default=False)
# Tenant specific fields is a comma separated list of field names that we will be converted to list of strings
# and store in the user table's tenant_specific_field_n columns. You can have up to three items in this
# comma-separated list.
config_from_env("SPIFFWORKFLOW_BACKEND_OPEN_ID_TENANT_SPECIFIC_FIELDS")
# Open ID server
# use "http://localhost:7000/openid" for running with simple openid
# server hosted by spiffworkflow-backend
if "SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS" in configs_with_structures:
SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS = configs_with_structures["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"]
else:
SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS = [
{
"identifier": "default",
"label": "Default",
"uri": "http://localhost:7002/realms/spiffworkflow",
"client_id": "spiffworkflow-backend",
"client_secret": "JXeQExm0JhQPLumgHtIIqf52bDalHz0q",
}
]
### 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,108 @@
import itertools
import os
from collections.abc import ItemsView
from collections.abc import Iterable
def normalized_environment(key_values: os._Environ) -> dict:
results = _parse_environment(key_values)
if isinstance(results, dict):
return results
raise Exception(
f"results from parsing environment variables was not a dict. This is troubling. Results were: {results}"
)
# source originally from: https://charemza.name/blog/posts/software-engineering/devops/structured-data-in-environment-variables/
def _parse_environment(key_values: os._Environ | dict) -> list | dict:
"""Converts denormalised dict of (string -> string) pairs, where the first string
is treated as a path into a nested list/dictionary structure
{
"FOO__1__BAR": "setting-1",
"FOO__1__BAZ": "setting-2",
"FOO__2__FOO": "setting-3",
"FOO__2__BAR": "setting-4",
"FIZZ": "setting-5",
}
to the nested structure that this represents
{
"FOO": [{
"BAR": "setting-1",
"BAZ": "setting-2",
}, {
"FOO": "setting-3",
"BAR": "setting-4",
}],
"FIZZ": "setting-5",
}
If all the keys for that level parse as integers, then it's treated as a list
with the actual keys only used for sorting
This function is recursive, but it would be extremely difficult to hit a stack
limit, and this function would typically by called once at the start of a
program, so efficiency isn't too much of a concern.
Copyright (c) 2018 Department for International Trade. All rights reserved.
This work is licensed under the terms of the MIT license.
For a copy, see https://opensource.org/licenses/MIT.
"""
# Separator is chosen to
# - show the structure of variables fairly easily;
# - avoid problems, since underscores are usual in environment variables
separator = "__"
def get_first_component(key: str) -> str:
return key.split(separator)[0]
def get_later_components(key: str) -> str:
return separator.join(key.split(separator)[1:])
without_more_components = {key: value for key, value in key_values.items() if not get_later_components(key)}
with_more_components = {key: value for key, value in key_values.items() if get_later_components(key)}
def grouped_by_first_component(items: ItemsView[str, str]) -> Iterable:
def by_first_component(item: tuple) -> str:
return get_first_component(item[0])
# groupby requires the items to be sorted by the grouping key
return itertools.groupby(
sorted(items, key=by_first_component),
by_first_component,
)
def items_with_first_component(items: ItemsView, first_component: str) -> dict:
return {
get_later_components(key): value for key, value in items if get_first_component(key) == first_component
}
nested_structured_dict = {
**without_more_components,
**{
first_component: _parse_environment(items_with_first_component(items, first_component))
for first_component, items in grouped_by_first_component(with_more_components.items())
},
}
def all_keys_are_ints() -> bool:
def is_int(string: str) -> bool:
try:
int(string)
return True
except ValueError:
return False
return all(is_int(key) for key, value in nested_structured_dict.items())
def list_sorted_by_int_key() -> list:
return [
value for key, value in sorted(nested_structured_dict.items(), key=lambda key_value: int(key_value[0]))
]
return list_sorted_by_int_key() if all_keys_are_ints() else nested_structured_dict

View File

@ -1,7 +1,18 @@
users:
admin:
service: local_open_id
email: admin@spiffworkflow.org
password: admin
preferred_username: Admin
nelson:
service: local_open_id
email: nelson@spiffworkflow.org
password: nelson
preferred_username: Nelson
groups:
admin:
users: [admin@spiffworkflow.org]
users: [admin@spiffworkflow.org, nelson@spiffworkflow.org]
group1:
users: [jason@sartography.com, kevin@sartography.com]
group2:

View File

@ -35,6 +35,11 @@ from spiffworkflow_backend.services.user_service import UserService
"""
def authentication_options() -> Response:
response = AuthenticationService.authentication_options_for_api()
return make_response(jsonify(response), 200)
# authorization_exclusion_list = ['status']
def verify_token(token: str | None = None, force_run: bool | None = False) -> None:
"""Verify the token for the user (if provided).
@ -88,13 +93,19 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No
raise ApiError(error_code="invalid_token", message="Cannot validate token.", status_code=401)
def login(redirect_url: str = "/", process_instance_id: int | None = None, task_guid: str | None = None) -> Response:
def login(
authentication_identifier: str,
redirect_url: str = "/",
process_instance_id: int | None = None,
task_guid: str | None = None,
) -> Response:
if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"):
AuthenticationService.create_guest_token(
username=SPIFF_NO_AUTH_USER,
group_identifier=SPIFF_NO_AUTH_GROUP,
permission_target="/*",
auth_token_properties={"authentication_disabled": True},
authentication_identifier=authentication_identifier,
)
return redirect(redirect_url)
@ -103,23 +114,31 @@ def login(redirect_url: str = "/", process_instance_id: int | None = None, task_
username=SPIFF_GUEST_USER,
group_identifier=SPIFF_GUEST_GROUP,
auth_token_properties={"only_guest_task_completion": True},
authentication_identifier=authentication_identifier,
)
return redirect(redirect_url)
state = AuthenticationService.generate_state(redirect_url)
login_redirect_url = AuthenticationService().get_login_redirect_url(state.decode("UTF-8"))
state = AuthenticationService.generate_state(redirect_url, authentication_identifier)
login_redirect_url = AuthenticationService().get_login_redirect_url(
state.decode("UTF-8"), authentication_identifier=authentication_identifier
)
return redirect(login_redirect_url)
def login_return(code: str, state: str, session_state: str = "") -> Response | None:
state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8"))
state_redirect_url = state_dict["redirect_url"]
auth_token_object = AuthenticationService().get_auth_token_object(code)
authentication_identifier = state_dict["authentication_identifier"]
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)
if AuthenticationService.validate_id_or_access_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)
g.user = user_model.id
@ -130,6 +149,7 @@ def login_return(code: str, state: str, session_state: str = "") -> Response | N
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.new_access_token = auth_token_object["id_token"]
tld.new_id_token = auth_token_object["id_token"]
tld.new_authentication_identifier = authentication_identifier
return redirect(redirect_url)
raise ApiError(
@ -148,10 +168,12 @@ 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) -> Response:
def login_with_access_token(access_token: str, authentication_identifier: str) -> Response:
user_info = _parse_id_token(access_token)
if AuthenticationService.validate_id_or_access_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)
else:
@ -164,9 +186,9 @@ def login_with_access_token(access_token: str) -> Response:
return make_response(jsonify({"ok": True}))
def login_api() -> Response:
def login_api(authentication_identifier: str) -> Response:
redirect_url = "/v1.0/login_api_return"
state = AuthenticationService.generate_state(redirect_url)
state = AuthenticationService.generate_state(redirect_url, authentication_identifier)
login_redirect_url = AuthenticationService().get_login_redirect_url(state.decode("UTF-8"), redirect_url)
return redirect(login_redirect_url)
@ -183,12 +205,14 @@ def login_api_return(code: str, state: str, session_state: str) -> str:
return access_token
def logout(id_token: str, redirect_url: str | None) -> Response:
def logout(id_token: str, authentication_identifier: str, redirect_url: str | None) -> Response:
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)
return AuthenticationService().logout(
redirect_url=redirect_url, id_token=id_token, authentication_identifier=authentication_identifier
)
def logout_return() -> Response:
@ -229,9 +253,15 @@ def _set_new_access_token_in_cookie(
if hasattr(tld, "new_id_token") and tld.new_id_token:
response.set_cookie("id_token", tld.new_id_token, domain=domain_for_frontend_cookie)
if hasattr(tld, "new_authentication_identifier") and tld.new_authentication_identifier:
response.set_cookie(
"authentication_identifier", tld.new_authentication_identifier, domain=domain_for_frontend_cookie
)
if hasattr(tld, "user_has_logged_out") and tld.user_has_logged_out:
response.set_cookie("id_token", "", max_age=0, domain=domain_for_frontend_cookie)
response.set_cookie("access_token", "", max_age=0, domain=domain_for_frontend_cookie)
response.set_cookie("authentication_identifier", "", max_age=0, domain=domain_for_frontend_cookie)
_clear_auth_tokens_from_thread_local_data()
@ -244,6 +274,8 @@ def _clear_auth_tokens_from_thread_local_data() -> None:
delattr(tld, "new_access_token")
if hasattr(tld, "new_id_token"):
delattr(tld, "new_id_token")
if hasattr(tld, "new_authentication_identifier"):
delattr(tld, "new_authentication_identifier")
if hasattr(tld, "user_has_logged_out"):
delattr(tld, "user_has_logged_out")
@ -318,8 +350,11 @@ def _get_user_model_from_token(token: str) -> UserModel | None:
elif "iss" in decoded_token.keys():
user_info = None
authentication_identifier = request.headers["authentication_identifier"]
try:
if AuthenticationService.validate_id_or_access_token(token):
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
@ -327,7 +362,9 @@ def _get_user_model_from_token(token: str) -> UserModel | None:
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)
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"]

View File

@ -4,6 +4,7 @@ from flask import request
from flask.wrappers import Response
from spiffworkflow_backend import get_version_info_data
from spiffworkflow_backend.services.authentication_service import AuthenticationService
def test_raise_error() -> Response:
@ -15,4 +16,4 @@ def version_info() -> Response:
def url_info() -> Response:
return make_response({"url": request.url}, 200)
return make_response({"url": request.url, "cache": AuthenticationService.ENDPOINT_CACHE}, 200)

View File

@ -5,6 +5,7 @@ import time
from hashlib import sha256
from hmac import HMAC
from hmac import compare_digest
from typing import TypedDict
import jwt
import requests
@ -30,51 +31,94 @@ class AuthenticationProviderTypes(enum.Enum):
internal = "internal"
class AuthenticationOptionForApi(TypedDict):
identifier: str
label: str
uri: str
class AuthenticationOption(AuthenticationOptionForApi):
client_id: str
client_secret: str
class AuthenticationOptionNotFoundError(Exception):
pass
class AuthenticationService:
ENDPOINT_CACHE: dict = {} # We only need to find the openid endpoints once, then we can cache them.
ENDPOINT_CACHE: dict[str, dict[str, str]] = (
{}
) # We only need to find the openid endpoints once, then we can cache them.
@staticmethod
def client_id() -> str:
@classmethod
def authentication_options_for_api(cls) -> list[AuthenticationOptionForApi]:
# ensure we remove sensitive info such as client secret from the config before sending it back
configs: list[AuthenticationOptionForApi] = []
for config in current_app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"]:
configs.append(
{
"identifier": config["identifier"],
"label": config["label"],
"uri": config["uri"],
}
)
return configs
@classmethod
def authentication_option_for_identifier(cls, authentication_identifier: str) -> AuthenticationOption:
for config in current_app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"]:
if config["identifier"] == authentication_identifier:
return_config: AuthenticationOption = config
return return_config
raise AuthenticationOptionNotFoundError(
f"Could not find a config with identifier '{authentication_identifier}'"
)
@classmethod
def client_id(cls, authentication_identifier: str) -> str:
"""Returns the client id from the config."""
config: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_ID", "")
return config
@staticmethod
def server_url() -> str:
"""Returns the server url from the config."""
config: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL", "")
return config
@staticmethod
def secret_key() -> str:
"""Returns the secret key from the config."""
config: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_OPEN_ID_CLIENT_SECRET_KEY", "")
config: str = cls.authentication_option_for_identifier(authentication_identifier)["client_id"]
return config
@classmethod
def open_id_endpoint_for_name(cls, name: str) -> str:
def server_url(cls, authentication_identifier: str) -> str:
"""Returns the server url from the config."""
config: str = cls.authentication_option_for_identifier(authentication_identifier)["uri"]
return config
@classmethod
def secret_key(cls, authentication_identifier: str) -> str:
"""Returns the secret key from the config."""
config: str = cls.authentication_option_for_identifier(authentication_identifier)["client_secret"]
return config
@classmethod
def open_id_endpoint_for_name(cls, name: str, authentication_identifier: str) -> str:
"""All openid systems provide a mapping of static names to the full path of that endpoint."""
openid_config_url = f"{cls.server_url()}/.well-known/openid-configuration"
if name not in AuthenticationService.ENDPOINT_CACHE:
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 name not in AuthenticationService.ENDPOINT_CACHE[authentication_identifier]:
try:
response = requests.get(openid_config_url, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
AuthenticationService.ENDPOINT_CACHE = response.json()
AuthenticationService.ENDPOINT_CACHE[authentication_identifier] = response.json()
except requests.exceptions.ConnectionError as ce:
raise OpenIdConnectionError(f"Cannot connect to given open id url: {openid_config_url}") from ce
if name not in AuthenticationService.ENDPOINT_CACHE:
if name not in AuthenticationService.ENDPOINT_CACHE[authentication_identifier]:
raise Exception(f"Unknown OpenID Endpoint: {name}. Tried to get from {openid_config_url}")
config: str = AuthenticationService.ENDPOINT_CACHE.get(name, "")
config: str = AuthenticationService.ENDPOINT_CACHE[authentication_identifier].get(name, "")
return config
@staticmethod
def get_backend_url() -> str:
return str(current_app.config["SPIFFWORKFLOW_BACKEND_URL"])
def logout(self, id_token: str, redirect_url: str | None = None) -> Response:
def logout(self, id_token: str, authentication_identifier: str, redirect_url: str | None = None) -> Response:
if redirect_url is None:
redirect_url = f"{self.get_backend_url()}/v1.0/logout_return"
request_url = (
self.open_id_endpoint_for_name("end_session_endpoint")
self.open_id_endpoint_for_name("end_session_endpoint", authentication_identifier=authentication_identifier)
+ f"?post_logout_redirect_uri={redirect_url}&"
+ f"id_token_hint={id_token}"
)
@ -82,24 +126,35 @@ class AuthenticationService:
return redirect(request_url)
@staticmethod
def generate_state(redirect_url: str) -> bytes:
state = base64.b64encode(bytes(str({"redirect_url": redirect_url}), "UTF-8"))
def generate_state(redirect_url: str, authentication_identifier: str) -> bytes:
state = base64.b64encode(
bytes(str({"redirect_url": redirect_url, "authentication_identifier": authentication_identifier}), "UTF-8")
)
return state
def get_login_redirect_url(self, state: str, redirect_url: str = "/v1.0/login_return") -> str:
def get_login_redirect_url(
self, state: str, authentication_identifier: str, redirect_url: str = "/v1.0/login_return"
) -> str:
return_redirect_url = f"{self.get_backend_url()}{redirect_url}"
login_redirect_url = (
self.open_id_endpoint_for_name("authorization_endpoint")
self.open_id_endpoint_for_name(
"authorization_endpoint", authentication_identifier=authentication_identifier
)
+ f"?state={state}&"
+ "response_type=code&"
+ f"client_id={self.client_id()}&"
+ f"client_id={self.client_id(authentication_identifier)}&"
+ "scope=openid profile email&"
+ f"redirect_uri={return_redirect_url}"
)
print(f"login_redirect_url: {login_redirect_url}")
return login_redirect_url
def get_auth_token_object(self, code: str, redirect_url: str = "/v1.0/login_return") -> dict:
backend_basic_auth_string = f"{self.client_id()}:{self.secret_key()}"
def get_auth_token_object(
self, code: str, authentication_identifier: str, redirect_url: str = "/v1.0/login_return"
) -> dict:
backend_basic_auth_string = (
f"{self.client_id(authentication_identifier)}:{self.secret_key(authentication_identifier)}"
)
backend_basic_auth_bytes = bytes(backend_basic_auth_string, encoding="ascii")
backend_basic_auth = base64.b64encode(backend_basic_auth_bytes)
headers = {
@ -112,14 +167,17 @@ class AuthenticationService:
"redirect_uri": f"{self.get_backend_url()}{redirect_url}",
}
request_url = self.open_id_endpoint_for_name("token_endpoint")
request_url = self.open_id_endpoint_for_name(
"token_endpoint", authentication_identifier=authentication_identifier
)
response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
auth_token_object: dict = json.loads(response.text)
print(f"auth_token_object: {auth_token_object}")
return auth_token_object
@classmethod
def validate_id_or_access_token(cls, token: str) -> bool:
def validate_id_or_access_token(cls, token: str, authentication_identifier: str) -> bool:
"""Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation."""
valid = True
now = round(time.time())
@ -136,28 +194,33 @@ class AuthenticationService:
azp = decoded_token["azp"] if "azp" in decoded_token else None
iat = decoded_token["iat"]
valid_audience_values = (cls.client_id(), "account")
valid_audience_values = (cls.client_id(authentication_identifier), "account")
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():
if iss != cls.server_url(authentication_identifier):
current_app.logger.error(
f"TOKEN INVALID because ISS '{iss}' does not match server url '{cls.server_url()}'"
f"TOKEN INVALID because ISS '{iss}' does not match server url"
f" '{cls.server_url(authentication_identifier)}'"
)
valid = False
# aud could be an array or a string
elif len(overlapping_aud_values) < 1:
current_app.logger.error(
f"TOKEN INVALID because audience '{aud}' does not match client id '{cls.client_id()}'"
f"TOKEN INVALID because audience '{aud}' does not match client id"
f" '{cls.client_id(authentication_identifier)}'"
)
valid = False
elif azp and azp not in (
cls.client_id(),
cls.client_id(authentication_identifier),
"account",
):
current_app.logger.error(f"TOKEN INVALID because azp '{azp}' does not match client id '{cls.client_id()}'")
current_app.logger.error(
f"TOKEN INVALID because azp '{azp}' does not match client id"
f" '{cls.client_id(authentication_identifier)}'"
)
valid = False
# make sure issued at time is not in the future
elif now + iat_clock_skew_leeway < iat:
@ -175,8 +238,8 @@ class AuthenticationService:
f"AUD: {aud} "
f"AZP: {azp} "
f"IAT: {iat} "
f"SERVER_URL: {cls.server_url()} "
f"CLIENT_ID: {cls.client_id()} "
f"SERVER_URL: {cls.server_url(authentication_identifier)} "
f"CLIENT_ID: {cls.client_id(authentication_identifier)} "
f"NOW: {now}"
)
@ -208,9 +271,11 @@ class AuthenticationService:
return None
@classmethod
def get_auth_token_from_refresh_token(cls, refresh_token: str) -> dict:
def get_auth_token_from_refresh_token(cls, refresh_token: str, authentication_identifier: str) -> dict:
"""Converts a refresh token to an Auth Token by calling the openid's auth endpoint."""
backend_basic_auth_string = f"{cls.client_id()}:{cls.secret_key()}"
backend_basic_auth_string = (
f"{cls.client_id(authentication_identifier)}:{cls.secret_key(authentication_identifier)}"
)
backend_basic_auth_bytes = bytes(backend_basic_auth_string, encoding="ascii")
backend_basic_auth = base64.b64encode(backend_basic_auth_bytes)
headers = {
@ -221,11 +286,13 @@ class AuthenticationService:
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": cls.client_id(),
"client_secret": cls.secret_key(),
"client_id": cls.client_id(authentication_identifier),
"client_secret": cls.secret_key(authentication_identifier),
}
request_url = cls.open_id_endpoint_for_name("token_endpoint")
request_url = cls.open_id_endpoint_for_name(
"token_endpoint", authentication_identifier=authentication_identifier
)
response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
auth_token_object: dict = json.loads(response.text)
@ -270,6 +337,7 @@ class AuthenticationService:
cls,
username: str,
group_identifier: str,
authentication_identifier: str,
permission_target: str | None = None,
permission: str = "all",
auth_token_properties: dict | None = None,
@ -284,3 +352,4 @@ class AuthenticationService:
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.new_access_token = g.token
tld.new_id_token = g.token
tld.new_authentication_identifier = authentication_identifier

View File

@ -271,6 +271,7 @@ class AuthorizationService:
api_view_function
and api_view_function.__name__.startswith("login")
or api_view_function.__name__.startswith("logout")
or api_view_function.__name__.startswith("authentication_options")
or api_view_function.__name__.startswith("prom")
or api_view_function.__name__ == "url_info"
or api_view_function.__name__.startswith("metric")

View File

@ -18,7 +18,7 @@ from tests.spiffworkflow_backend.helpers.base_test import BaseTest
class TestAuthentication(BaseTest):
def test_get_login_state(self) -> None:
redirect_url = "http://example.com/"
state = AuthenticationService.generate_state(redirect_url)
state = AuthenticationService.generate_state(redirect_url, authentication_identifier="default")
state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8"))
assert isinstance(state_dict, dict)
@ -34,14 +34,14 @@ class TestAuthentication(BaseTest):
with self.app_config_mock(app, "SPIFFWORKFLOW_BACKEND_OPEN_ID_IS_AUTHORITY_FOR_USER_GROUPS", True):
user = self.find_or_create_user("testing@e.com")
user.email = "testing@e.com"
user.service = app.config["SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"]
user.service = app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"]
db.session.add(user)
db.session.commit()
access_token = user.encode_auth_token(
{
"groups": ["group_one", "group_two"],
"iss": app.config["SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"],
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
"aud": "spiffworkflow-backend",
"iat": round(time.time()),
"exp": round(time.time()) + 1000,
@ -49,7 +49,7 @@ class TestAuthentication(BaseTest):
)
response = None
response = client.post(
f"/v1.0/login_with_access_token?access_token={access_token}",
f"/v1.0/login_with_access_token?access_token={access_token}&authentication_identifier=default",
)
assert response.status_code == 200
assert len(user.groups) == 3
@ -59,14 +59,14 @@ class TestAuthentication(BaseTest):
access_token = user.encode_auth_token(
{
"groups": ["group_one"],
"iss": app.config["SPIFFWORKFLOW_BACKEND_OPEN_ID_SERVER_URL"],
"iss": app.config["SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS"][0]["uri"],
"aud": "spiffworkflow-backend",
"iat": round(time.time()),
"exp": round(time.time()) + 1000,
}
)
response = client.post(
f"/v1.0/login_with_access_token?access_token={access_token}",
f"/v1.0/login_with_access_token?access_token={access_token}&authentication_identifier=default",
)
assert response.status_code == 200
user = UserModel.query.filter_by(username=user.username).first()

View File

@ -489,7 +489,7 @@ class TestTasksController(BaseTest):
# log in a guest user to complete the tasks
redirect_url = "/test-redirect-dne"
response = client.get(
f"/v1.0/login?process_instance_id={process_instance_id}&task_guid={task_guid}&redirect_url={redirect_url}",
f"/v1.0/login?process_instance_id={process_instance_id}&task_guid={task_guid}&redirect_url={redirect_url}&authentication_identifier=DOES_NOT_MATTER",
)
assert response.status_code == 302
assert response.location == redirect_url

View File

@ -2,7 +2,6 @@ import { Content } from '@carbon/react';
import { Routes, Route } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import NavigationBar from './components/NavigationBar';
import ScrollToTop from './components/ScrollToTop';
import EditorRoutes from './routes/EditorRoutes';
@ -18,7 +17,8 @@ import HttpService from './services/HttpService';
import { ErrorBoundaryFallback } from './ErrorBoundaryFallack';
import BaseRoutes from './routes/BaseRoutes';
import BackendIsDown from './routes/BackendIsDown';
import UserService from './services/UserService';
import Login from './routes/Login';
import NavigationBar from './components/NavigationBar';
export default function ContainerForExtensions() {
const [backendIsUp, setBackendIsUp] = useState<boolean | null>(null);
@ -72,10 +72,10 @@ export default function ContainerForExtensions() {
};
const getExtensions = () => {
setBackendIsUp(true);
if (!permissionsLoaded) {
return;
}
setBackendIsUp(true);
if (ability.can('GET', targetUris.extensionListPath)) {
HttpService.makeCallToBackend({
path: targetUris.extensionListPath,
@ -97,7 +97,6 @@ export default function ContainerForExtensions() {
]);
const routeComponents = () => {
UserService.loginIfNeeded();
return (
<Routes>
<Route
@ -106,6 +105,7 @@ export default function ContainerForExtensions() {
/>
<Route path="/editor/*" element={<EditorRoutes />} />
<Route path="/extensions/:page_identifier" element={<Extension />} />
<Route path="/login" element={<Login />} />
</Routes>
);
};
@ -126,7 +126,7 @@ export default function ContainerForExtensions() {
return (
<>
<NavigationBar extensionUxElements={extensionUxElements} />
<NavigationBar extensionUxElements={extensionUxElements} />;
<Content className={contentClassName}>
<ScrollToTop />
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>

View File

@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import UserService from '../services/UserService';
export default function LoginHandler() {
const navigate = useNavigate();
useEffect(() => {
if (!UserService.isLoggedIn()) {
navigate(`/login?original_url=${UserService.getCurrentLocation()}`);
}
}, [navigate]);
return null;
}

View File

@ -16,7 +16,7 @@ import {
HeaderGlobalAction,
HeaderGlobalBar,
} from '@carbon/react';
import { Logout, Login } from '@carbon/icons-react';
import { Logout } from '@carbon/icons-react';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Can } from '@casl/react';
@ -41,10 +41,6 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
UserService.doLogout();
};
const handleLogin = () => {
UserService.doLogin();
};
const location = useLocation();
const [activeKey, setActiveKey] = useState<string>('');
@ -152,7 +148,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
);
};
const loginAndLogoutAction = () => {
const logoutAction = () => {
if (UserService.isLoggedIn()) {
return (
<>
@ -168,15 +164,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
</>
);
}
return (
<HeaderGlobalAction
data-qa="login-button"
aria-label="Login"
onClick={handleLogin}
>
<Login />
</HeaderGlobalAction>
);
return null;
};
const configurationElement = () => {
@ -290,7 +278,17 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
// App.jsx forces login (which redirects to keycloak) so we should never get here if we're not logged in.
if (!UserService.isLoggedIn()) {
return null;
return (
<HeaderContainer
render={() => (
<Header aria-label="IBM Platform Name" className="cds--g100">
<HeaderName href="/" prefix="" data-qa="spiffworkflow-logo">
<img src={logo} className="app-logo" alt="logo" />
</HeaderName>
</Header>
)}
/>
);
}
if (activeKey && ability && !UserService.onlyGuestTaskCompletion()) {
@ -324,7 +322,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
<HeaderSideNavItems>{headerMenuItems()}</HeaderSideNavItems>
</SideNavItems>
</SideNav>
<HeaderGlobalBar>{loginAndLogoutAction()}</HeaderGlobalBar>
<HeaderGlobalBar>{logoutAction()}</HeaderGlobalBar>
</Header>
)}
/>

View File

@ -1,8 +1,5 @@
import { useNavigate } from 'react-router-dom';
import {
Button,
// @ts-ignore
} from '@carbon/react';
import { Button } from '@carbon/react';
import { Can } from '@casl/react';
import { useState } from 'react';
import {

View File

@ -867,3 +867,19 @@ div.onboarding {
overflow: hidden;
text-overflow: ellipsis;
}
.login-button {
height: 48px;
width: 216px;
padding-right: 0px;
margin-right: 1rem;
margin-bottom: 1rem;
}
/*
* on the mockup, it looked like about 142px above the heading.
* we already have 32px, so adding the balance.
*/
.login-page-spacer {
padding-top: 110px;
}

View File

@ -460,3 +460,9 @@ export interface JsonSchemaExample {
ui: any;
data: any;
}
export interface AuthenticationOption {
identifier: string;
label: string;
uri: string;
}

View File

@ -13,6 +13,7 @@ import About from './About';
import Page404 from './Page404';
import AdminRedirect from './AdminRedirect';
import RootRoute from './RootRoute';
import LoginHandler from '../components/LoginHandler';
type OwnProps = {
extensionUxElements?: UiSchemaUxElement[] | null;
@ -22,6 +23,7 @@ export default function BaseRoutes({ extensionUxElements }: OwnProps) {
return (
<div className="fixed-width-container">
<ErrorDisplay />
<LoginHandler />
<Routes>
<Route path="/" element={<RootRoute />} />
<Route path="tasks/*" element={<HomeRoutes />} />

View File

@ -3,6 +3,7 @@ import { Routes, Route, useLocation } from 'react-router-dom';
import React, { useEffect } from 'react';
import ProcessModelEditDiagram from './ProcessModelEditDiagram';
import ErrorDisplay from '../components/ErrorDisplay';
import LoginHandler from '../components/LoginHandler';
export default function EditorRoutes() {
const location = useLocation();
@ -12,6 +13,7 @@ export default function EditorRoutes() {
return (
<div className="full-width-container no-center-stuff">
<ErrorDisplay />
<LoginHandler />
<Routes>
<Route
path="process-models/:process_model_id/files"

View File

@ -23,6 +23,7 @@ import ErrorDisplay from '../components/ErrorDisplay';
import FormattingService from '../services/FormattingService';
import ProcessInstanceRun from '../components/ProcessInstanceRun';
import MarkdownRenderer from '../components/MarkdownRenderer';
import LoginHandler from '../components/LoginHandler';
type OwnProps = {
displayErrors?: boolean;
@ -366,6 +367,7 @@ export default function Extension({ displayErrors = true }: OwnProps) {
return (
<div className="fixed-width-container">
{displayErrors ? <ErrorDisplay /> : null}
<LoginHandler />
{componentsToDisplay}
</div>
);

View File

@ -0,0 +1,76 @@
import { ArrowRight } from '@carbon/icons-react';
import { useEffect, useState } from 'react';
import { Button, Grid, Column } from '@carbon/react';
import { useSearchParams } from 'react-router-dom';
import { AuthenticationOption } from '../interfaces';
import HttpService from '../services/HttpService';
import UserService from '../services/UserService';
export default function Login() {
const [searchParams] = useSearchParams();
const [authenticationOptions, setAuthenticationOptions] = useState<
AuthenticationOption[] | null
>(null);
useEffect(() => {
HttpService.makeCallToBackend({
path: '/authentication-options',
successCallback: setAuthenticationOptions,
});
}, []);
const authenticationOptionButtons = () => {
if (!authenticationOptions) {
return null;
}
const buttons: any = [];
if (authenticationOptions.length === 1) {
UserService.doLogin(
authenticationOptions[0],
searchParams.get('original_url')
);
return null;
}
authenticationOptions.forEach((option: AuthenticationOption) => {
buttons.push(
<Button
data-qa={`login-button-${option.identifier}`}
size="lg"
className="login-button"
renderIcon={ArrowRight}
onClick={() =>
UserService.doLogin(option, searchParams.get('original_url'))
}
>
{option.label}
</Button>
);
});
return buttons;
};
if (authenticationOptions !== null) {
return (
<div className="fixed-width-container login-page-spacer">
<Grid>
<Column
lg={{ span: 5, offset: 6 }}
md={{ span: 4, offset: 2 }}
sm={{ span: 4, offset: 0 }}
>
<h1 className="with-large-bottom-margin">
Log in to SpiffWorkflow
</h1>
</Column>
<Column
lg={{ span: 8, offset: 5 }}
md={{ span: 5, offset: 2 }}
sm={{ span: 4, offset: 0 }}
>
{authenticationOptionButtons()}
</Column>
</Grid>
</div>
);
}
return null;
}

View File

@ -12,6 +12,8 @@ export const getBasicHeaders = (): Record<string, string> => {
if (UserService.isLoggedIn()) {
return {
Authorization: `Bearer ${UserService.getAccessToken()}`,
'Authentication-Identifier':
UserService.getAuthenticationIdentifier() || '',
};
}
return {};
@ -77,7 +79,6 @@ backendCallProps) => {
fetch(`${BACKEND_BASE_URL}${updatedPath}`, httpArgs)
.then((response) => {
if (response.status === 401) {
UserService.doLogin();
throw new UnauthenticatedError('You must be authenticated to do this.');
} else if (response.status === 403) {
is403 = true;

View File

@ -1,6 +1,7 @@
import jwt from 'jwt-decode';
import cookie from 'cookie';
import { BACKEND_BASE_URL } from '../config';
import { AuthenticationOption } from '../interfaces';
// NOTE: this currently stores the jwt token in local storage
// which is considered insecure. Server set cookies seem to be considered
@ -43,15 +44,23 @@ const checkPathForTaskShowParams = () => {
return null;
};
const doLogin = () => {
const doLogin = (
authenticationOption?: AuthenticationOption,
redirectUrl?: string | null
) => {
const taskShowParams = checkPathForTaskShowParams();
const loginParams = [`redirect_url=${getCurrentLocation()}`];
const loginParams = [`redirect_url=${redirectUrl || getCurrentLocation()}`];
if (taskShowParams) {
loginParams.push(
`process_instance_id=${taskShowParams.process_instance_id}`
);
loginParams.push(`task_guid=${taskShowParams.task_guid}`);
}
if (authenticationOption) {
loginParams.push(
`authentication_identifier=${authenticationOption.identifier}`
);
}
const url = `${BACKEND_BASE_URL}/login?${loginParams.join('&')}`;
window.location.href = url;
};
@ -60,12 +69,22 @@ const doLogin = () => {
const getIdToken = () => {
return getCookie('id_token');
};
const getAccessToken = () => {
return getCookie('access_token');
};
const getAuthenticationIdentifier = () => {
return getCookie('authentication_identifier');
};
const isLoggedIn = () => {
return !!getAccessToken();
};
const doLogout = () => {
const idToken = getIdToken();
const frontendBaseUrl = window.location.origin;
let logoutRedirectUrl = `${BACKEND_BASE_URL}/logout?redirect_url=${frontendBaseUrl}&id_token=${idToken}`;
let logoutRedirectUrl = `${BACKEND_BASE_URL}/logout?redirect_url=${frontendBaseUrl}&id_token=${idToken}&authentication_identifier=${getAuthenticationIdentifier()}`;
// edge case. if the user is already logged out, just take them somewhere that will force them to sign in.
if (idToken === null) {
@ -75,13 +94,6 @@ const doLogout = () => {
window.location.href = logoutRedirectUrl;
};
const getAccessToken = () => {
return getCookie('access_token');
};
const isLoggedIn = () => {
return !!getAccessToken();
};
const getUserEmail = () => {
const idToken = getIdToken();
if (idToken) {
@ -153,6 +165,8 @@ const UserService = {
doLogin,
doLogout,
getAccessToken,
getAuthenticationIdentifier,
getCurrentLocation,
getPreferredUsername,
getUserEmail,
isLoggedIn,