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:
parent
a48bc8a885
commit
d5b0330609
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -460,3 +460,9 @@ export interface JsonSchemaExample {
|
|||
ui: any;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface AuthenticationOption {
|
||||
identifier: string;
|
||||
label: string;
|
||||
uri: string;
|
||||
}
|
||||
|
|
|
@ -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 />} />
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue