diff --git a/.github/.gitleaks.toml b/.github/.gitleaks.toml index f4d48fd0c..944677e7e 100644 --- a/.github/.gitleaks.toml +++ b/.github/.gitleaks.toml @@ -49,6 +49,9 @@ title = "gitleaks config" description = "Asymmetric Private Key" regex = '''-----BEGIN ((EC|PGP|DSA|RSA|OPENSSH) )?PRIVATE KEY( BLOCK)?-----''' tags = ["key", "AsymmetricPrivateKey"] + [rules.allowlist] + description = "test for presence of private keys" + paths = ['''tests/test_magpie_cli.py'''] [[rules]] description = "Generic Credential" regex = '''(?i)(api_key|apikey|secret)(.{0,20})?['|"][0-9a-zA-Z]{16,45}['|"]''' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c3b1c64f..3b562b17e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,11 +62,18 @@ jobs: python-version: "3.10" allow-failure: false test-case: check - # remote test + # remote test (note that network tests are skipped since the remote server won't have network mode enabled) - os: ubuntu-latest python-version: "3.11" allow-failure: true test-case: start test-remote + # remote network tests using a remote server with network mode enabled + - os: ubuntu-latest + python-version: "3.12" + allow-failure: true + test-case: start test # the tests that are run are limited by the PYTEST_ADDOPTS in test-option below + test-option: >- + PYTEST_ADDOPTS='-m "remote and network"' MAGPIE_NETWORK_CREATE_MISSING_PEM_FILE=True MAGPIE_NETWORK_ENABLED=on MAGPIE_NETWORK_INSTANCE_NAME=example # coverage test - os: ubuntu-latest python-version: "3.11" @@ -105,6 +112,9 @@ jobs: run: | hash -r env | sort + - name: Create private key for network testing + if: ${{ matrix.test-case == 'test-local' }} + run: ${{ matrix.test-option }} make create-private-key # run '-only' test variations since dependencies are preinstalled, skip some resolution time - name: Run Tests run: ${{ matrix.test-option }} make stop ${{ matrix.test-case }}-only diff --git a/.gitignore b/.gitignore index 4d189a647..46fd41765 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ magpie_delete_users*.txt requirements-all.txt gunicorn.app.wsgiapp error_log.txt + +# Secrets +*.pem +*.key diff --git a/CHANGES.rst b/CHANGES.rst index d78751b59..661ae8e51 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -33,7 +33,11 @@ Features / Changes ------------------------------------------------------------------------------------ Features / Changes -~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~ + +* Introduce "Network Mode" which allows other Magpie instances to act as external authentication providers using access + tokens. This allows users registered across multiple Magpie instances in a network to more easily gain access to the + resources within the network, without requiring the duplication of user credentials across the network. * Add CLI helper ``batch_update_permissions`` that allows registering one or more `Permission` configuration files against a running `Magpie` instance. * Security fix: bump Docker base ``python:3.11-alpine3.19``. diff --git a/Makefile b/Makefile index 136e1f383..03f014243 100644 --- a/Makefile +++ b/Makefile @@ -802,3 +802,7 @@ conda-env: conda-base ## create conda environment if missing and required echo "Creating conda environment at '$(CONDA_ENV_PATH)'..." && \ "$(CONDA_BIN)" create -y -n "$(CONDA_ENV_NAME)" python=$(PYTHON_VERSION)) \ ) + +.PHONY: create-private-key +create-private-key: ## create a private key file according to the MAGPIE_NETWORK_PEM_FILES and MAGPIE_NETWORK_PEM_PASSWORDS settings + @bash -c '$(CONDA_CMD) magpie_create_private_key --config "$(APP_INI)"' diff --git a/ci/magpie.env b/ci/magpie.env index 44d514426..4c07a05d5 100644 --- a/ci/magpie.env +++ b/ci/magpie.env @@ -12,8 +12,11 @@ MAGPIE_ADMIN_PASSWORD=qwerty-ci-tests MAGPIE_LOG_LEVEL=INFO MAGPIE_LOG_REQUEST=false MAGPIE_LOG_EXCEPTION=false +MAGPIE_NETWORK_INSTANCE_NAME=node1 MAGPIE_TEST_VERSION=latest MAGPIE_TEST_REMOTE_SERVER_URL=http://localhost:2001 +MAGPIE_TEST_REMOTE_NODE_SERVER_HOST=localhost +MAGPIE_TEST_REMOTE_NODE_SERVER_PORT=2002 MAGPIE_TEST_ADMIN_USERNAME=unittest-admin # auto-generate password MAGPIE_TEST_ADMIN_PASSWORD= diff --git a/config/magpie.ini b/config/magpie.ini index e41b06853..4e6d46a5c 100644 --- a/config/magpie.ini +++ b/config/magpie.ini @@ -28,6 +28,12 @@ pyramid.includes = magpie.port = 2001 magpie.url = http://localhost:2001 +# Enable network mode which allows different instances of Magpie to authenticate users for each other. +# magpie.network_enabled = true +# magpie.network_default_token_expiry = 86400 +# magpie.network_instance_name = +# magpie.network_pem_files = key.pem + # magpie.config_path = # --- cookie definition --- (defaults below if omitted) diff --git a/docs/authentication.rst b/docs/authentication.rst index 05fb4ec22..2c50c043c 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -422,3 +422,116 @@ Furthermore, as described in the `procedure`_, :envvar:`MAGPIE_USER_REGISTRATION specify whether administrator approval is required or not. This additional step is purely up to the developers and server managers that use `Magpie` to decide if they desire more control over which individuals can join and access their services. + +.. _Network Mode: + +Network Mode +------------ + +If the :envvar:`MAGPIE_NETWORK_ENABLED` is enabled, `Magpie` instances can be linked in a network which allows them to +associate user accounts across the network and provide limited resource access to users who have accounts on other +`Magpie` instances in the network. Each `Magpie` instance is considered a node in the network. + +Users who have an account on one `Magpie` instance can request an access token from another instance in the network +which the user can use to access resources protected by the other `Magpie` instance. + +Users with accounts on multiple instances in the network can also choose to link their accounts. This allows users who +use access tokens to ensure that they have the same access to resources that they would have if they logged in to +`Magpie` using any other method. + + +Managing the Network +~~~~~~~~~~~~~~~~~~~~ + +Each `Magpie` instance must be made aware of the existence of the other instances in the network so that they know where +to send token requests and account linking requests. + +In order to register another `Magpie` instance as part of the same network, an admin user can create a +:term:`Network Node` with a request to ``POST /network/nodes``. The parameters given to that request includes + +* ``name``: + * the name of that other `Magpie` instance in the network and should correspond to the same value as the + :envvar:`MAGPIE_NETWORK_INSTANCE_NAME` value set by the other `Magpie` instance. +* ``jwks_url``: + * URL that provides the instance's public key in the form of a JSON Web Key Set. + * This is usually ``https://{hostname}/network/jwks`` where ``{hostname}`` is the hostname of the other instance +* ``authorization_url`` + * URL that provides the instance's Oauth authorize endpoint. + * This is usually ``https://{hostname}/ui/network/authorize`` where ``{hostname}`` is the hostname of the other + instance. +* ``token_url`` + * URL that provides the instances Oauth token endpoint. + * This is usually ``https://{hostname}/network/token`` where ``{hostname}`` is the hostname of the other instance. +* ``redirect_uris`` + * JSON array of valid redirect URIs for the instance. These are used by the instance's Oauth authorize + endpoint to safely redirect the user back once they have authorized `Magpie` to link their accounts on two + different instances. + * This is usually ``https://{hostname}/network/link`` where ``{hostname}`` is the hostname of the other + instance. + + +Once a :term:`Network Node` is registered, `Magpie` can treat the other instance as if they are in the same network as +long as: + +* Both instances have :envvar:`MAGPIE_NETWORK_ENABLED` enabled +* Both instances have :envvar:`MAGPIE_NETWORK_INSTANCE_NAME` set +* Both instances have :envvar:`MAGPIE_NETWORK_PEM_FILES` set in order to verify communication between nodes using an + asymmetric public/private key-pair. + + +Managing Personal Access Tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A :term:`User` can request a new access token from another node with a request to the +``GET /network/nodes/{node_name}/token`` route. + +Every time a :term:`User` makes a request to this route, `Magpie` send a request to the other instance, and provides it +to the user. A new token is generated every time. This effectively cancels all previously created tokens for that user. + +To cancel an existing token without generating a new one. A :term:`User` can make a request to the +``DELETE /network/nodes/{node_name}/token`` route. + +Authentication +~~~~~~~~~~~~~~ + +Once a :term:`User` gets an access token, they can use that token to authenticate with the instance that issued that +token. + +When a user makes a request, they should set the ``provider_name`` parameter to the value of +:envvar:`MAGPIE_NETWORK_PROVIDER` and provide the network token in the Authorization header in the following format: + +.. code-block:: http + + Authorization: Bearer + +When using the :ref:`Magpie Adapter `, the token can also be passed as a parameter to the request, +where the parameter name set by :envvar:`MAGPIE_NETWORK_TOKEN_NAME` and the value is the personal network token. + +Authorization +~~~~~~~~~~~~~ + +Managing authorization for :term:`Users` who authenticate using access tokens is complicated by the fact that +a :term:`User` is not required to have a full account on both `Magpie` instances in order to using this authentication +mechanism. This means that a :term:`User` may be logged in as a node-specific "anonymous" user. + +When another `Magpie` instance is registered as a :term:`Network Node`, a few additional entities are created: + +#. a group used to manage the permissions of all users who authenticate using the new :term:`Network Node`. + * this group's name will be the :envvar:`MAGPIE_NETWORK_NAME_PREFIX` followed by the :term:`Network Node` name +#. a group used to manage the permissions of all users who authenticate using *any* other instance in the network + * this group's name will be the :envvar:`MAGPIE_NETWORK_GROUP_NAME` + * this group will only be created once, when the first :term:`Network Node` is registered +#. an anonymous user that belongs to the two groups that were just created. + * this user name will be the :envvar:`MAGPIE_NETWORK_NAME_PREFIX` followed by the :term:`Network Node` name + +Here is an example to illustrate this point: + +* There are 3 `Magpie` instances in the network named A, B, and C +* There is a :term:`User` named ``"toto"`` registered on instance A +* There is no :term:`User` named ``"toto"`` who belongs to the ``"anonymous_network_A"`` group registered on instance B +* There is a :term:`User` named ``"toto"`` who belongs to the ``"anonymous_network_A"`` group registered on instance C +* Instance A is registered as a :term:`Network Node` on instances B and C +* when ``"toto"`` gets a personal network token from instance A and uses it to log in on instance B they log in as the + the temporary ``"anonymous_network_A"`` user. +* when ``"toto"`` gets a personal network token from instance A and uses it to log in on instance C they log in as the + ``"toto"`` user on instance C. diff --git a/docs/configuration.rst b/docs/configuration.rst index 7baa3e160..c971bfc5b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -986,6 +986,134 @@ remain available as described at the start of the :ref:`Configuration` section. Name of the :term:`Provider` used for login. This represents the identifier that is set to define how to differentiate between a local sign-in procedure and a dispatched one some known :ref:`authn_providers`. +Network Mode Settings +~~~~~~~~~~~~~~~~~~~~~ + +The following configuration parameters are related to Magpie's "Network Mode" which allows networked instances of Magpie +to authenticate users for each other. All variables defined in this section are only used if +:envvar:`MAGPIE_NETWORK_ENABLED` is enabled. + +.. envvar:: MAGPIE_NETWORK_ENABLED + + [:class:`bool`] + (Default: ``False``) + + .. versionadded:: 3.38 + + Enable "Network Mode" which enables all functionality to authenticate users using other Magpie instances as + external authentication providers. + +.. envvar:: MAGPIE_NETWORK_INSTANCE_NAME + + [:class:`str`] + + .. versionadded:: 3.38 + + The name of this Magpie instance in the network. This variable is used to determine if an authentication token was + issued by this instance of Magpie, or another instance in the network. + + This variable is required if :envvar:`MAGPIE_NETWORK_ENABLED` is ``True``. + +.. envvar:: MAGPIE_NETWORK_DEFAULT_TOKEN_EXPIRY + + [:class:`int`] + (Default: ``86400``) + + .. versionadded:: 3.38 + + The default expiry time (in seconds) for an access token issued for the purpose of network authentication. + +.. envvar:: MAGPIE_NETWORK_INTERNAL_TOKEN_EXPIRY + + [:class:`int`] + (Default: ``30``) + + .. versionadded:: 3.38 + + The default expiry time (in seconds) for an JSON Web Token issued for the purpose of communication between nodes in + the network. + +.. envvar:: MAGPIE_NETWORK_TOKEN_NAME + + [|constant|_] + (Value: ``"magpie_token"``) + + .. versionadded:: 3.38 + + The name of the request parameter key whose value is the authentication token issued for the purpose of network + authentication. + +.. envvar:: MAGPIE_NETWORK_PROVIDER + + [|constant|_] + (Value: ``"magpie_network"``) + + .. versionadded:: 3.38 + + The name of the external provider that authenticates users using other Magpie instances as external authentication + providers. + +.. envvar:: MAGPIE_NETWORK_NAME_PREFIX + + [|constant|_] + (Value: ``"anonymous_network_"``) + + .. versionadded:: 3.38 + + A prefix added to the anonymous network user and network group names. These names are constructed by prepending the + remote Magpie instance name with this prefix. For example, a Magpie instance named ``"example123"`` will have a + corresponding user and group named ``"anonymous_network_example123"``. + +.. envvar:: MAGPIE_NETWORK_GROUP_NAME + + [|constant|_] + (Value: ``"magpie_network"``) + + .. versionadded:: 3.38 + + The name of the group created to manage permissions for all users authenticated using Magpie instances as external + authentication providers. + +.. envvar:: MAGPIE_NETWORK_PEM_FILES + + [:class:`str`] + (Default: ``${MAGPIE_ROOT}/key.pem``) + + .. versionadded:: 3.38 + + Path to a PEM file containing a public/private key-pair. This is used to sign and verify communication sent between + nodes in the network. + + Multiple PEM files can be specified if key rotation is desired. To specify multiple PEM files, separate each file + path with a ``:`` character. The first file in the list will contain the primary key and will be used to sign all + outgoing communication. + +.. envvar:: MAGPIE_NETWORK_PEM_PASSWORDS + + [:class:`str`] + (Default: ``None``) + + .. versionadded:: 3.38 + + Password used to encrypt the PEM files in :envvar:`MAGPIE_NETWORK_PEM_FILES`. + + If multiple files require passwords, they can be listed as a JSON array. An empty string will be treated the same as + no password. + + For example, if you have four files specified in :envvar:`MAGPIE_NETWORK_PEM_FILES` and only the first and third + file require a password, set this variable to ``["pass1", "" ,"pass2", ""]`` where ``pass1`` and ``pass2`` are the + passwords. + +.. envvar:: MAGPIE_NETWORK_CREATE_MISSING_PEM_FILE + + [:class:`bool`] + (Default: ``False``) + + .. versionadded:: 3.38 + + If enabled *and* there is a single file specified in :envvar:`MAGPIE_NETWORK_PEM_FILES` *and* that file is missing, + `Magpie` will generate a new private key file when starting up. If a password is specified for that file in + :envvar:`MAGPIE_NETWORK_PEM_PASSWORDS` then the private key file will be encrypted with that password as well. .. _config_phoenix: diff --git a/docs/glossary.rst b/docs/glossary.rst index 1059109ef..405f047db 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -138,6 +138,16 @@ Glossary :py:data:`magpie.constants.MAGPIE_ANONYMOUS_USER`. Otherwise, it is whoever the :term:`Authentication` mechanism identifies with token extracted from request :term:`Cookies`. + Network Node + A reference to an instance of the Magpie software within a network of Magpie instances. Each Magpie instance + within the network is registered in the database as a row in the ``network_nodes`` table. Each node is + represented by a name that is unique across all nodes in the network, and a url that is used to send http + requests to that specific node. + + Network Token + A unique random string that can be used to authenticate a user as part of the :ref:`Network Mode` authentication + procedure. + OpenAPI OAS The |OpenAPI-spec|_ (`OAS`) defines a standard, programming language-agnostic interface description for diff --git a/env/magpie.env.example b/env/magpie.env.example index 70a428072..0b3f7521e 100644 --- a/env/magpie.env.example +++ b/env/magpie.env.example @@ -32,6 +32,9 @@ MAGPIE_TEST_VERSION=latest # this means you must have a separately running Magpie instance reachable at that endpoint to run 'remote' tests # to ignore this, consider setting an empty value or running 'make test-local' instead MAGPIE_TEST_REMOTE_SERVER_URL=http://localhost:2001 +# below URL specifies a host and port for a fake network node (used for network tests) +MAGPIE_TEST_REMOTE_NODE_SERVER_HOST=localhost +MAGPIE_TEST_REMOTE_NODE_SERVER_PORT=2002 # below are the credentials employed to run tests (especially if running on non-default remote server) MAGPIE_TEST_ADMIN_USERNAME=admin MAGPIE_TEST_ADMIN_PASSWORD=qwerty diff --git a/magpie-cron b/magpie-cron index e769dba72..93b91f547 100644 --- a/magpie-cron +++ b/magpie-cron @@ -1 +1,2 @@ 0 * * * * /bin/bash -c "set -a ; source <($MAGPIE_ENV_DIR/*.env) ; set +a ; magpie_sync_resources" +1 0 * * * /bin/bash -c "set -a ; source <($MAGPIE_ENV_DIR/*.env) ; set +a ; magpie_purge_expired_network_tokens" diff --git a/magpie/adapter/magpieowssecurity.py b/magpie/adapter/magpieowssecurity.py index f17f33f0e..bbfa6c19a 100644 --- a/magpie/adapter/magpieowssecurity.py +++ b/magpie/adapter/magpieowssecurity.py @@ -16,7 +16,7 @@ from magpie.api.exception import evaluate_call, verify_param from magpie.api.schemas import ProviderSigninAPI from magpie.compat import LooseVersion -from magpie.constants import get_constant +from magpie.constants import get_constant, network_enabled from magpie.db import get_connected_session from magpie.models import Service from magpie.permissions import Permission @@ -269,11 +269,17 @@ def update_request_cookies(self, request): """ settings = get_settings(request) token_name = get_constant("MAGPIE_COOKIE_NAME", settings_container=settings) + network_mode = network_enabled(settings_container=settings) + headers = dict(request.headers) + network_token_name = get_constant("MAGPIE_NETWORK_TOKEN_NAME", settings_container=settings) + if network_mode and "Authorization" not in headers and network_token_name in request.params: + headers["Authorization"] = "Bearer {}".format(request.params[network_token_name]) + request.params["provider_name"] = request.params.get("provider_name", + get_constant("MAGPIE_NETWORK_PROVIDER", settings)) if "Authorization" in request.headers and token_name not in request.cookies: magpie_prov = request.params.get("provider_name", get_constant("MAGPIE_DEFAULT_PROVIDER", settings)) magpie_path = ProviderSigninAPI.path.format(provider_name=magpie_prov) magpie_auth = "{}{}".format(self.magpie_url, magpie_path) - headers = dict(request.headers) headers.update({"Homepage-Route": "/session", "Accept": CONTENT_TYPE_JSON}) session_resp = requests.get(magpie_auth, headers=headers, verify=self.twitcher_ssl_verify, timeout=5) if session_resp.status_code != HTTPOk.code: diff --git a/magpie/alembic/versions/2023-08-25_2cfe144538e8_add_network_tables.py b/magpie/alembic/versions/2023-08-25_2cfe144538e8_add_network_tables.py new file mode 100644 index 000000000..56d68a757 --- /dev/null +++ b/magpie/alembic/versions/2023-08-25_2cfe144538e8_add_network_tables.py @@ -0,0 +1,61 @@ +""" +Add Network_Tokens Table + +Revision ID: 2cfe144538e8 +Revises: 5e5acc33adce +Create Date: 2023-08-25 13:36:16.930374 +""" +import datetime + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.orm.session import sessionmaker +from sqlalchemy_utils import URLType + +# Revision identifiers, used by Alembic. +# pylint: disable=C0103,invalid-name # revision control variables not uppercase +revision = "2cfe144538e8" +down_revision = "5e5acc33adce" +branch_labels = None +depends_on = None + +Session = sessionmaker() + + +def upgrade(): + op.create_table("network_tokens", + sa.Column("id", sa.Integer, primary_key=True, nullable=False, autoincrement=True), + sa.Column("token", sa.String, nullable=False, unique=True), + sa.Column("created", sa.DateTime, default=datetime.datetime.utcnow) + ) + op.create_table("network_nodes", + sa.Column("id", sa.Integer, primary_key=True, nullable=False, autoincrement=True), + sa.Column("name", sa.Unicode(128), nullable=False, unique=True), + sa.Column("jwks_url", URLType(), nullable=False), + sa.Column("token_url", URLType(), nullable=False), + sa.Column("authorization_url", URLType(), nullable=False), + sa.Column("redirect_uris", sa.JSON, nullable=False, server_default="[]") + ) + op.create_table("network_remote_users", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=True), + sa.Column("network_node_id", sa.Integer, + sa.ForeignKey("network_nodes.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False), + sa.Column("name", sa.Unicode(128)), + sa.Column("network_token_id", sa.Integer, + sa.ForeignKey("network_tokens.id", onupdate="CASCADE", ondelete="SET NULL"), unique=True) + ) + op.create_unique_constraint("uq_network_remote_users_user_id_network_node_id", "network_remote_users", + ["user_id", "network_node_id"]) + op.create_unique_constraint("uq_network_remote_users_name_network_node_id", "network_remote_users", + ["name", "network_node_id"]) + + +def downgrade(): + op.drop_constraint("uq_network_remote_users_user_id_network_node_id", "network_remote_users") + op.drop_constraint("uq_network_remote_users_name_network_node_id", "network_remote_users") + op.drop_table("network_tokens") + op.drop_table("network_nodes") + op.drop_table("network_remote_users") diff --git a/magpie/api/exception.py b/magpie/api/exception.py index 8381e65c4..176460e07 100644 --- a/magpie/api/exception.py +++ b/magpie/api/exception.py @@ -85,6 +85,7 @@ def verify_param( # noqa: E126 # pylint: disable=R0913,too-many-arguments is_equal=False, # type: bool is_type=False, # type: bool matches=False, # type: bool + not_matches=False, # type: bool ): # type: (...) -> None # noqa: E123,E126 # pylint: disable=R0912,R0914 """ @@ -123,12 +124,13 @@ def verify_param( # noqa: E126 # pylint: disable=R0913,too-many-arguments :param is_equal: test that :paramref:`param` equals :paramref:`param_compare` value :param is_type: test that :paramref:`param` is of same type as specified by :paramref:`param_compare` type :param matches: test that :paramref:`param` matches the regex specified by :paramref:`param_compare` value + :param not_matches: test that :paramref:`param` doesn't match the regex specified by :paramref:`param_compare` value :raises HTTPError: if tests fail, specified exception is raised (default: :class:`HTTPBadRequest`) :raises HTTPInternalServerError: for evaluation error :return: nothing if all tests passed """ content = {} if content is None else content - needs_compare = is_type or is_in or not_in or is_equal or not_equal or matches + needs_compare = is_type or is_in or not_in or is_equal or not_equal or matches or not_matches needs_iterable = is_in or not_in # precondition evaluation of input parameters @@ -159,9 +161,11 @@ def verify_param( # noqa: E126 # pylint: disable=R0913,too-many-arguments raise TypeError("'is_type' is not a 'bool'") if not isinstance(matches, bool): raise TypeError("'matches' is not a 'bool'") + if not isinstance(not_matches, bool): + raise TypeError("'not_matches' is not a 'bool'") # error if none of the flags specified if not any([not_none, not_empty, not_in, not_equal, - is_none, is_empty, is_in, is_equal, is_true, is_false, is_type, matches]): + is_none, is_empty, is_in, is_equal, is_true, is_false, is_type, matches, not_matches]): raise ValueError("no comparison flag specified for verification") if param_compare is None and needs_compare: raise TypeError("'param_compare' cannot be 'None' with specified test flags") @@ -178,11 +182,11 @@ def verify_param( # noqa: E126 # pylint: disable=R0913,too-many-arguments is_str_cmp = isinstance(param, six.string_types) ok_str_cmp = isinstance(param_compare, six.string_types) eq_typ_cmp = type(param) is type(param_compare) - is_pattern = matches and isinstance(param_compare, Pattern) + is_pattern = (matches or not_matches) and isinstance(param_compare, Pattern) if is_type and not (is_str_typ or is_cmp_typ): LOGGER.debug("[param: %s] invalid type compare with [param_compare: %s]", type(param), param_compare) raise TypeError("'param_compare' cannot be of non-type with specified verification flags") - if matches and not isinstance(param_compare, (six.string_types, Pattern)): + if (matches or not_matches) and not isinstance(param_compare, (six.string_types, Pattern)): LOGGER.debug("[param_compare: %s] invalid type is not a regex string or pattern", type(param_compare)) raise TypeError("'param_compare' for matching verification must be a string or compile regex pattern") if not is_type and not ((is_str_cmp and ok_str_cmp) or (not is_str_cmp and eq_typ_cmp) or is_pattern): @@ -252,6 +256,12 @@ def verify_param( # noqa: E126 # pylint: disable=R0913,too-many-arguments param_compare_regex = re.compile(param_compare, re.X) fail_conditions.update({"matches": bool(re.match(param_compare_regex, param))}) fail_verify = fail_verify or not fail_conditions["matches"] + if not_matches: + param_compare_regex = param_compare + if isinstance(param_compare, six.string_types): + param_compare_regex = re.compile(param_compare, re.I | re.X) + fail_conditions.update({"not_matches": not re.match(param_compare_regex, param)}) + fail_verify = fail_verify or not fail_conditions["not_matches"] if fail_verify: content = apply_param_content(content, param, param_compare, param_name, with_param, param_content, needs_compare, needs_iterable, is_type, fail_conditions) diff --git a/magpie/api/login/login.py b/magpie/api/login/login.py index 143a805ac..47a866b4b 100644 --- a/magpie/api/login/login.py +++ b/magpie/api/login/login.py @@ -34,7 +34,7 @@ from magpie.api import schemas as s from magpie.api.management.user.user_formats import format_user from magpie.api.management.user.user_utils import create_user -from magpie.constants import get_constant +from magpie.constants import get_constant, network_enabled, protected_user_email_regex, protected_user_name_regex from magpie.security import authomatic_setup, get_providers from magpie.utils import ( CONTENT_TYPE_JSON, @@ -46,15 +46,15 @@ ) if TYPE_CHECKING: - from magpie.typedefs import Session, Str + from magpie.typedefs import AnySettingsContainer, Session, Str LOGGER = get_logger(__name__) - # dictionaries of {'provider_id': 'provider_display_name'} MAGPIE_DEFAULT_PROVIDER = get_constant("MAGPIE_DEFAULT_PROVIDER") MAGPIE_INTERNAL_PROVIDERS = {MAGPIE_DEFAULT_PROVIDER: MAGPIE_DEFAULT_PROVIDER.capitalize()} MAGPIE_EXTERNAL_PROVIDERS = get_providers() + MAGPIE_PROVIDER_KEYS = frozenset(set(MAGPIE_INTERNAL_PROVIDERS) | set(MAGPIE_EXTERNAL_PROVIDERS)) @@ -77,14 +77,17 @@ def process_sign_in_external(request, username, provider): return HTTPTemporaryRedirect(location=external_login_route, headers=request.response.headers) -def verify_provider(provider_name): - # type: (Str) -> None +def verify_provider(provider_name, settings_container=None): + # type: (Str, AnySettingsContainer) -> None """ Verifies that the specified name is a valid external provider against the login configuration providers. :raises HTTPNotFound: if provider name is not one of known providers. """ - ax.verify_param(provider_name, param_name="provider_name", param_compare=list(MAGPIE_PROVIDER_KEYS), is_in=True, + provider_keys = list(MAGPIE_PROVIDER_KEYS) + if network_enabled(settings_container): + provider_keys.append(get_constant("MAGPIE_NETWORK_PROVIDER", settings_container)) + ax.verify_param(provider_name, param_name="provider_name", param_compare=provider_keys, is_in=True, http_error=HTTPNotFound, msg_on_fail=s.ProviderSignin_GET_NotFoundResponseSchema.description) @@ -130,12 +133,15 @@ def sign_in_view(request): # request handler. Catch that specific exception and return it to bypass the EXCVIEW tween that result in that # automatic convert to return 403 directly. try: - anonymous = get_constant("MAGPIE_ANONYMOUS_USER", request) - ax.verify_param(user_name, not_equal=True, param_compare=anonymous, param_name="user_name", + if pattern == ax.EMAIL_REGEX: + anonymous_regex = protected_user_email_regex(include_admin=False, settings_container=request) + else: + anonymous_regex = protected_user_name_regex(include_admin=False, settings_container=request) + ax.verify_param(user_name, not_matches=True, param_compare=anonymous_regex, param_name="user_name", http_error=HTTPForbidden, msg_on_fail=s.Signin_POST_ForbiddenResponseSchema.description) except HTTPForbidden as http_error: return http_error - verify_provider(provider_name) + verify_provider(provider_name, request) if provider_name in MAGPIE_INTERNAL_PROVIDERS: # password can be None for external login, validate only here as it is required for internal login @@ -239,17 +245,12 @@ def new_user_external(external_user_name, external_id, email, provider_name, db_ return user -def login_success_external(request, external_user_name, external_id, email, provider_name): - # type: (Request, Str, Str, Str, Str) -> HTTPException +def login_success_external(request, user): + # type: (Request, models.User) -> HTTPException """ Generates the login response in case of successful external provider identification. """ - # find possibly already registered user by external_id/provider - user = ExternalIdentityService.user_by_external_id_and_provider(external_id, provider_name, request.db) - if user is None: - # create new user with an External Identity - user = new_user_external(external_user_name=external_user_name, external_id=external_id, - email=email, provider_name=provider_name, db_session=request.db) + # set a header to remember user (set-cookie) headers = remember(request, user.id) @@ -271,10 +272,42 @@ def login_success_external(request, external_user_name, external_id, email, prov http_kwargs={"location": homepage_route, "headers": headers}) +def network_login(request): + # type: (Request) -> HTTPException + """ + Sign in a user authenticating using a `Magpie` network access token. + """ + if "Authorization" in request.headers: + token_list = request.headers.get("Authorization").split(maxsplit=1) + if len(token_list) != 2: + return ax.raise_http(http_error=HTTPUnauthorized, + detail=s.Signin_POST_UnauthorizedResponseSchema.description, nothrow=True) + token_type, token = token_list + if token_type != "Bearer": # nosec: B105 + return ax.raise_http(http_error=HTTPBadRequest, + detail=s.ProviderSignin_GET_BadRequestResponseSchema.description, nothrow=True) + network_token = models.NetworkToken.by_token(token, db_session=request.db) + if network_token is None or network_token.expired(): + return ax.raise_http(http_error=HTTPUnauthorized, + detail=s.Signin_POST_UnauthorizedResponseSchema.description, nothrow=True) + authenticated_user = network_token.network_remote_user.user + if authenticated_user is None: + authenticated_user = network_token.network_remote_user.network_node.anonymous_user(request.db) + # We should never create a token for protected users but just in case + # Note that we *should* create tokens for anonymous network users + anonymous_regex = protected_user_name_regex(include_admin=False, include_network=False, + settings_container=request) + ax.verify_param(authenticated_user.user_name, not_matches=True, param_compare=anonymous_regex, + http_error=HTTPForbidden, msg_on_fail=s.Signin_POST_ForbiddenResponseSchema.description) + return login_success_external(request, authenticated_user) + ax.raise_http(http_error=HTTPBadRequest, + detail=s.ProviderSignin_GET_BadRequestResponseSchema.description) + + @s.ProviderSigninAPI.get(schema=s.ProviderSignin_GET_RequestSchema, tags=[s.SessionTag], response_schemas=s.ProviderSignin_GET_responses) @view_config(route_name=s.ProviderSigninAPI.name, permission=NO_PERMISSION_REQUIRED) -def authomatic_login_view(request): +def external_login_view(request): """ Signs in a user session using an external provider. """ @@ -282,8 +315,9 @@ def authomatic_login_view(request): response = Response() verify_provider(provider_name) try: + if provider_name == get_constant("MAGPIE_NETWORK_PROVIDER", request): + return network_login(request) authomatic_handler = authomatic_setup(request) - # if we directly have the Authorization header, bypass authomatic login and retrieve 'userinfo' to signin if "Authorization" in request.headers and "authomatic" not in request.cookies: provider_config = authomatic_handler.config.get(provider_name, {}) @@ -292,7 +326,8 @@ def authomatic_login_view(request): # provide the token user data, let the external provider update it on login afterwards token_type, access_token = request.headers.get("Authorization").split() data = {"access_token": access_token, "token_type": token_type} - cred = Credentials(authomatic_handler.config, token=access_token, token_type=token_type, provider=provider) + cred = Credentials(authomatic_handler.config, token=access_token, token_type=token_type, + provider=provider) provider.credentials = cred result = LoginResult(provider) # pylint: disable=W0212 @@ -329,11 +364,16 @@ def authomatic_login_view(request): ax.raise_http(http_error=HTTPUnauthorized, detail=s.ProviderSignin_GET_UnauthorizedResponseSchema.description) # create/retrieve the user using found details from login provider - return login_success_external(request, - external_id=result.user.username or result.user.id, - email=result.user.email, - provider_name=result.provider.name, - external_user_name=result.user.name) + # find possibly already registered user by external_id/provider + external_id = result.user.username or result.user.id + user = ExternalIdentityService.user_by_external_id_and_provider(external_id, provider_name, + request.db) + if user is None: + # create new user with an External Identity + user = new_user_external(external_user_name=result.user.name, external_id=external_id, + email=result.user.email, provider_name=result.provider.name, + db_session=request.db) + return login_success_external(request, user) except Exception as exc: exc_msg = "Unhandled error during external provider '{}' login. [{!s}]".format(provider_name, exc) LOGGER.exception(exc_msg, exc_info=True) diff --git a/magpie/api/management/__init__.py b/magpie/api/management/__init__.py index fb09062db..39a799d3c 100644 --- a/magpie/api/management/__init__.py +++ b/magpie/api/management/__init__.py @@ -10,4 +10,5 @@ def includeme(config): config.include("magpie.api.management.service") config.include("magpie.api.management.resource") config.include("magpie.api.management.register") + config.include("magpie.api.management.network") config.scan() diff --git a/magpie/api/management/group/group_utils.py b/magpie/api/management/group/group_utils.py index 0776ed138..89d4aea4d 100644 --- a/magpie/api/management/group/group_utils.py +++ b/magpie/api/management/group/group_utils.py @@ -22,6 +22,7 @@ from magpie.api.management.resource.resource_utils import check_valid_service_or_resource_permission from magpie.api.management.service import service_formats as sf from magpie.api.webhooks import WebhookAction, get_permission_update_params, process_webhook_requests +from magpie.constants import network_enabled, protected_group_name_regex from magpie.permissions import PermissionSet, PermissionType, format_permissions from magpie.services import SERVICE_TYPE_DICT @@ -94,6 +95,11 @@ def create_group(group_name, description, discoverable, terms, db_session): ax.verify_param(group_name, matches=True, param_compare=ax.PARAM_REGEX, param_name="group_name", http_error=HTTPBadRequest, content=group_content_error, msg_on_fail=s.Groups_POST_BadRequestResponseSchema.description) + if network_enabled(): + anonymous_regex = protected_group_name_regex(include_admin=False) + ax.verify_param(group_name, not_matches=True, param_compare=anonymous_regex, param_name="group_name", + http_error=HTTPBadRequest, content=group_content_error, + msg_on_fail=s.Groups_POST_BadRequestResponseSchema.description) if description: ax.verify_param(description, matches=True, param_compare=ax.PARAM_REGEX, param_name="description", http_error=HTTPBadRequest, content=group_content_error, diff --git a/magpie/api/management/group/group_views.py b/magpie/api/management/group/group_views.py index c3749523c..b0bb5fe2e 100644 --- a/magpie/api/management/group/group_views.py +++ b/magpie/api/management/group/group_views.py @@ -9,7 +9,7 @@ from magpie.api.management.group import group_formats as gf from magpie.api.management.group import group_utils as gu from magpie.api.management.service import service_utils as su -from magpie.constants import get_constant +from magpie.constants import get_constant, network_enabled, protected_group_name_regex from magpie.models import TemporaryToken, TokenOperation, UserGroupStatus @@ -85,6 +85,11 @@ def edit_group_view(request): ax.verify_param(GroupService.by_group_name(new_group_name, db_session=request.db), is_none=True, http_error=HTTPConflict, with_param=False, # don't return group as value msg_on_fail=s.Group_PATCH_ConflictResponseSchema.description) + if network_enabled(): + anonymous_regex = protected_group_name_regex(include_admin=False) + ax.verify_param(new_group_name, not_matches=True, param_compare=anonymous_regex, + http_error=HTTPBadRequest, + msg_on_fail=s.Group_PATCH_Size_BadRequestResponseSchema.description) group.group_name = new_group_name if update_desc: group.description = new_description diff --git a/magpie/api/management/network/__init__.py b/magpie/api/management/network/__init__.py new file mode 100644 index 000000000..000c95069 --- /dev/null +++ b/magpie/api/management/network/__init__.py @@ -0,0 +1,24 @@ +from magpie.utils import get_logger + +LOGGER = get_logger(__name__) + + +def includeme(config): + from pyramid.exceptions import ConfigurationError + + from magpie import utils + from magpie.api import schemas as s + + LOGGER.info("Adding API network ...") + try: + utils.check_network_configured(config) + except ConfigurationError as exc: + LOGGER.error("API network failed with following configuration error: %s", exc) + raise + config.add_route(**s.service_api_route_info(s.NetworkTokenAPI)) + config.add_route(**s.service_api_route_info(s.NetworkJSONWebKeySetAPI)) + config.add_route(**s.service_api_route_info(s.NetworkDecodeJWTAPI)) + config.add_route(**s.service_api_route_info(s.NetworkTokensAPI)) + config.include("magpie.api.management.network.node") + config.include("magpie.api.management.network.remote_user") + config.scan() diff --git a/magpie/api/management/network/network_utils.py b/magpie/api/management/network/network_utils.py new file mode 100644 index 000000000..84f1c5771 --- /dev/null +++ b/magpie/api/management/network/network_utils.py @@ -0,0 +1,211 @@ +import json +import os +from datetime import datetime, timedelta +from itertools import zip_longest +from typing import TYPE_CHECKING + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from jwcrypto import jwk +from pyramid.httpexceptions import HTTPBadRequest, HTTPInternalServerError, HTTPNotFound + +from magpie import models +from magpie.api import exception as ax +from magpie.api import requests as ar +from magpie.api import schemas as s +from magpie.constants import get_constant +from magpie.utils import get_logger + +if TYPE_CHECKING: + from typing import Dict, List, Optional, Tuple + + from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes + from pyramid.request import Request + + from magpie.typedefs import JSON, AnySettingsContainer, Str + +LOGGER = get_logger(__name__) + + +def pem_files(settings_container=None): + # type: (Optional[AnySettingsContainer]) -> List[Str] + pem_files_ = get_constant("MAGPIE_NETWORK_PEM_FILES", settings_container=settings_container) + try: + return json.loads(pem_files_) + except json.decoder.JSONDecodeError: + return [pem_files_] + + +def _pem_file_content(primary=False, settings_container=None): + # type: (bool, Optional[AnySettingsContainer]) -> List[bytes] + """ + Return the content of all PEM files + """ + + content = [] + for pem_file in pem_files(settings_container=settings_container): + with open(pem_file, "rb") as f: + content.append(f.read()) + if primary: + break + return content + + +def _pem_file_passwords(primary=False, settings_container=None): + # type: (bool, Optional[AnySettingsContainer]) -> List[Optional[bytes]] + """ + Return the passwords used to encrypt the PEM files. + The passwords will be returned in the same order as the file content from `_pem_file_content`. + + If a file is not encrypted with a password, a ``None`` value will be returned in place of the password. + + For example: if there are 4 PEM files and the second and fourth are not encrypted, this will return + ``["password1", None, "password2"]`` + """ + pem_passwords = get_constant("MAGPIE_NETWORK_PEM_PASSWORDS", settings_container=settings_container, + raise_missing=False, raise_not_set=False) + try: + passwords = json.loads(pem_passwords or "") + except json.decoder.JSONDecodeError: + passwords = [pem_passwords] + passwords = [p.encode() if p else None for p in passwords] + if primary: + return passwords[:1] + return passwords + + +def create_private_key(filename, password=None, settings_container=None): + # type: (Str, Optional[bytes], Optional[AnySettingsContainer]) -> None + """ + Create a private key file at the specified filename. Encrypt it using the password if specified. + If password is None and the filename matches a file in MAGPIE_NETWORK_PEM_FILES, the associated + password specified in MAGPIE_NETWORK_PEM_PASSWORDS will be used instead. + + .. warning:: + This function should only be used to create a file if MAGPIE_NETWORK_CREATE_MISSING_PEM_FILE is + truthy. This is not enforced in this function. + """ + if password is None: + for pem_file, pem_password in zip_longest(pem_files(settings_container), + _pem_file_passwords(False, settings_container)): + if os.path.realpath(pem_file) == os.path.realpath(filename): + password = pem_password + + LOGGER.info("Creating a valid PEM file at '%s'.", filename) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + if password: + encryption_algorithm = serialization.BestAvailableEncryption(password) + else: + encryption_algorithm = serialization.NoEncryption() + private_bytes = private_key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm) + with open(filename, mode="wb") as f: + f.write(private_bytes) + + +def jwks(primary=False, settings_container=None): + # type: (bool, Optional[AnySettingsContainer]) -> jwk.JWKSet + """ + Return a JSON Web Key Set containing all JSON Web Keys loaded from the PEM files listed + in ``MAGPIE_NETWORK_PEM_FILES``. + """ + jwks_ = jwk.JWKSet() + for pem_content, pem_password in zip_longest(_pem_file_content(primary, settings_container), + _pem_file_passwords(primary, settings_container)): + jwks_["keys"].add(jwk.JWK.from_pem(pem_content, password=pem_password)) + return jwks_ + + +def _private_keys(primary=False): + # type: (bool) -> Dict[Str, PrivateKeyTypes] + """ + Return a dictionary containing key ids and private keys from the PEM files listed in ``MAGPIE_NETWORK_PEM_FILES``. + + If the ``primary`` argument is True, only the primary key will be included in the returned list. + """ + keys = {} + for pem_content, pem_password in zip_longest(_pem_file_content(primary), _pem_file_passwords(primary)): + kid = jwk.JWK.from_pem(pem_content, password=pem_password).export(as_dict=True)["kid"] + keys[kid] = serialization.load_pem_private_key(pem_content, password=pem_password) + return keys + + +def encode_jwt(claims, audience_name, settings_container=None): + # type: (JSON, Str, Optional[AnySettingsContainer]) -> Str + """ + Encode claims as a JSON web token. + + Unless overridden by a field in the ``claims`` argument, the ``"iss"`` claim will default to + `MAGPIE_NETWORK_INSTANCE_NAME`, the ``"exp"`` claim will default to `MAGPIE_NETWORK_INTERNAL_TOKEN_EXPIRY`, + and the ``"aud"`` claim will default to ``audience_name``. The JWT will be signed with `Magpie`'s primary private + key (see the `_private_keys` function for details) using the asymmetric RS256 algorithm. + """ + claims_override = {} + kid, secret = ax.evaluate_call(lambda: next(iter(_private_keys().items())), + http_error=HTTPInternalServerError, + msg_on_fail="No private key found. Cannot sign JWT.") + headers = {"kid": kid} + algorithm = "RS256" + if "exp" not in claims: + expiry = int(get_constant("MAGPIE_NETWORK_INTERNAL_TOKEN_EXPIRY", settings_container)) + expiry_time = datetime.utcnow() + timedelta(seconds=expiry) + claims_override["exp"] = expiry_time + if "iss" not in claims: + claims_override["iss"] = get_constant("MAGPIE_NETWORK_INSTANCE_NAME", settings_container) + if "aud" not in claims: + claims_override["aud"] = audience_name + return jwt.encode({**claims, **claims_override}, secret, algorithm=algorithm, headers=headers) + + +def decode_jwt(token, node, settings_container=None): + # type: (Str, models.NetworkNode, Optional[AnySettingsContainer]) -> JSON + """ + Decode a JSON Web Token issued by a node in the network. + + The token must include the ``"exp"``, ``"aud"``, and ``"iss"`` claims. + If the issuer is not the same as ``node.name``, or the audience is not this instance (i.e. the same as + ``MAGPIE_NETWORK_INSTANCE_NAME``), or the token is expired, an error will be raised. + An error will also be raised if the token cannot be verified with the issuer node's public key. + """ + jwks_client = jwt.PyJWKClient(node.jwks_url) + instance_name = get_constant("MAGPIE_NETWORK_INSTANCE_NAME", settings_container) + key = ax.evaluate_call(lambda: jwks_client.get_signing_key_from_jwt(token), + http_error=HTTPInternalServerError, + msg_on_fail="No valid public key found. Cannot decode JWT.") + return ax.evaluate_call(lambda: jwt.decode(token, key.key, + algorithms=["RS256"], + issuer=node.name, + audience=instance_name), + http_error=HTTPBadRequest, + msg_on_fail="Cannot verify JWT") + + +def get_network_models_from_request_token(request, create_network_remote_user=False): + # type: (Request, bool) -> Tuple[models.NetworkNode, Optional[models.NetworkRemoteUser]] + """ + Return a ``NetworkNode`` and associated ``NetworkRemoteUser`` determined by parsing the claims in the JWT included + in the ``request`` argument. + + If the ``NetworkRemoteUser`` does not exist and ``create_network_remote_user`` is ``True``, this creates a new + ``NetworkRemoteUser`` associated with the anonymous user for the given ``NetworkNode`` and adds it to the current + database transaction. + """ + token = ar.get_multiformat_body(request, "token", default=None) + if token is None: + ax.raise_http(http_error=HTTPBadRequest, detail=s.BadRequestResponseSchema.description) + node_name = jwt.decode(token, options={"verify_signature": False}).get("iss") + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == node_name).one(), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkNode_NotFoundResponseSchema.description) + decoded_token = decode_jwt(token, node, request) + user_name = decoded_token.get("user_name") + network_remote_user = (request.db.query(models.NetworkRemoteUser) + .filter(models.NetworkRemoteUser.name == user_name) + .filter(models.NetworkRemoteUser.network_node_id == node.id) + .first()) + if network_remote_user is None and create_network_remote_user: + network_remote_user = models.NetworkRemoteUser(network_node=node, name=user_name) + request.db.add(network_remote_user) + return node, network_remote_user diff --git a/magpie/api/management/network/network_views.py b/magpie/api/management/network/network_views.py new file mode 100644 index 000000000..9caf29a26 --- /dev/null +++ b/magpie/api/management/network/network_views.py @@ -0,0 +1,91 @@ +import jwt +import sqlalchemy +from pyramid.httpexceptions import HTTPBadRequest, HTTPCreated, HTTPNotFound, HTTPOk +from pyramid.security import NO_PERMISSION_REQUIRED +from pyramid.settings import asbool +from pyramid.view import view_config + +from magpie import models +from magpie.api import exception as ax +from magpie.api import schemas as s +from magpie.api.management.network.network_utils import decode_jwt, get_network_models_from_request_token, jwks +from magpie.api.requests import check_network_mode_enabled, get_multiformat_body +from magpie.models import NetworkNode, NetworkToken + + +@s.NetworkTokenAPI.post(schema=s.NetworkToken_POST_RequestSchema, tags=[s.NetworkTag], + response_schemas=s.NetworkToken_POST_responses) +@view_config(route_name=s.NetworkTokenAPI.name, request_method="POST", + decorator=check_network_mode_enabled, permission=NO_PERMISSION_REQUIRED) +def post_network_token_view(request): + _, network_remote_user = get_network_models_from_request_token(request, create_network_remote_user=True) + network_token = network_remote_user.network_token + if network_token: + token = network_token.refresh_token() + else: + network_token = models.NetworkToken() + token = network_token.refresh_token() + request.db.add(network_token) + network_remote_user.network_token = network_token + return ax.valid_http(http_success=HTTPCreated, content={"token": token}, + detail=s.NetworkToken_POST_CreatedResponseSchema.description) + + +@s.NetworkTokenAPI.delete(schema=s.NetworkToken_DELETE_RequestSchema, tags=[s.NetworkTag], + response_schemas=s.NetworkToken_DELETE_responses) +@view_config(route_name=s.NetworkTokenAPI.name, request_method="DELETE", + decorator=check_network_mode_enabled, permission=NO_PERMISSION_REQUIRED) +def delete_network_token_view(request): + _, network_remote_user = get_network_models_from_request_token(request) + if network_remote_user and network_remote_user.network_token: + request.db.delete(network_remote_user.network_token) + if network_remote_user.user is None and sqlalchemy.inspect(network_remote_user).persistent: + request.db.delete(network_remote_user) # clean up unused record in the database + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkToken_DELETE_OkResponseSchema.description) + ax.raise_http(http_error=HTTPNotFound, detail=s.NetworkNodeToken_DELETE_NotFoundResponseSchema.description) + + +@s.NetworkTokensAPI.delete(schema=s.NetworkTokens_DELETE_RequestSchema, tags=[s.NetworkTag], + response_schemas=s.NetworkTokens_DELETE_responses) +@view_config(route_name=s.NetworkTokensAPI.name, request_method="DELETE", decorator=check_network_mode_enabled) +def delete_network_tokens_view(request): + if asbool(get_multiformat_body(request, "expired_only", default=False)): + deleted = models.NetworkToken.delete_expired(request.db) + else: + deleted = request.db.query(NetworkToken).delete() + # clean up unused records in the database (no need to keep records associated with anonymous network users) + (request.db.query(models.NetworkRemoteUser) + .filter(models.NetworkRemoteUser.user_id == None) # noqa: E711 # pylint: disable=singleton-comparison + .filter(models.NetworkRemoteUser.network_token_id == None) # noqa: E711 # pylint: disable=singleton-comparison + .delete()) + return ax.valid_http(http_success=HTTPOk, + content={"deleted": deleted}, + detail=s.NetworkTokens_DELETE_OkResponseSchema.description) + + +@s.NetworkJSONWebKeySetAPI.get(tags=[s.NetworkTag], response_schemas=s.NetworkJSONWebKeySet_GET_responses) +@view_config(route_name=s.NetworkJSONWebKeySetAPI.name, request_method="GET", + decorator=check_network_mode_enabled, permission=NO_PERMISSION_REQUIRED) +def get_network_jwks_view(request): + return ax.valid_http(http_success=HTTPOk, + detail=s.NetworkJSONWebKeySet_GET_OkResponseSchema.description, + content=jwks(settings_container=request).export(private_keys=False, as_dict=True)) + + +@s.NetworkDecodeJWTAPI.get(tags=[s.NetworkTag], response_schemas=s.NetworkDecodeJWT_GET_Responses) +@view_config(route_name=s.NetworkDecodeJWTAPI.name, request_method="GET", decorator=check_network_mode_enabled) +def get_decode_jwt(request): + token = request.GET.get("token") + if token is None: + ax.raise_http(http_error=HTTPBadRequest, detail="Missing token") + try: + node_name = jwt.decode(token, options={"verify_signature": False}).get("iss") + except jwt.exceptions.DecodeError: + ax.raise_http(http_error=HTTPBadRequest, detail="Token is improperly formatted") + node = request.db.query(NetworkNode).filter(NetworkNode.name == node_name).first() + if node is None: + ax.raise_http(http_error=HTTPBadRequest, detail="Invalid token: invalid or missing issuer claim") + jwt_content = decode_jwt(token, node, request) + return ax.valid_http(http_success=HTTPOk, + content={"jwt_content": jwt_content}, + detail=s.NetworkDecodeJWT_GET_OkResponseSchema.description) diff --git a/magpie/api/management/network/node/__init__.py b/magpie/api/management/network/node/__init__.py new file mode 100644 index 000000000..d4dcac7a1 --- /dev/null +++ b/magpie/api/management/network/node/__init__.py @@ -0,0 +1,15 @@ +from magpie.utils import get_logger + +LOGGER = get_logger(__name__) + + +def includeme(config): + from magpie.api import schemas as s + LOGGER.info("Adding API network node...") + config.add_route(**s.service_api_route_info(s.NetworkNodesAPI)) + config.add_route(**s.service_api_route_info(s.NetworkNodeAPI)) + config.add_route(**s.service_api_route_info(s.NetworkNodeTokenAPI)) + config.add_route(**s.service_api_route_info(s.NetworkLinkAPI)) + config.add_route(**s.service_api_route_info(s.NetworkNodeLinkAPI)) + + config.scan() diff --git a/magpie/api/management/network/node/network_node_utils.py b/magpie/api/management/network/node/network_node_utils.py new file mode 100644 index 000000000..81bdf1fd2 --- /dev/null +++ b/magpie/api/management/network/node/network_node_utils.py @@ -0,0 +1,124 @@ +import json +from typing import TYPE_CHECKING + +import six +from pyramid.httpexceptions import HTTPBadRequest, HTTPConflict + +from magpie import models +from magpie.api import exception as ax +from magpie.api import schemas as s +from magpie.api.exception import URL_REGEX +from magpie.cli.register_defaults import register_user_with_group +from magpie.constants import get_constant + +if TYPE_CHECKING: + from pyramid.request import Request + + from magpie.typedefs import JSON, AnyRequestType, List, Optional, Session, Str + +NAME_REGEX = r"^[\w-]+$" + + +def create_associated_user_groups(new_node, request): + # type: (models.NetworkNode, Request) -> None + """ + Creates an associated anonymous user and group for the newly created ``new_node``. + + This will also create the network group (named ``MAGPIE_NETWORK_GROUP_NAME``) if it does not yet exist. + """ + name = new_node.anonymous_user_name() + + # create an anonymous user and group for this network node + register_user_with_group(user_name=name, + group_name=name, + email=get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT").format(new_node.name), + password=None, # autogen, value doesn't matter as no login applicable, just make it valid + db_session=request.db) + + group = models.GroupService.by_group_name(name, db_session=request.db) + group.description = "Group for users who have accounts on the networked Magpie instance named '{}'.".format( + new_node.name) + group.discoverable = False + + # add the anonymous user to a group for all users in the network (from nodes other than this one). + register_user_with_group(user_name=name, + group_name=get_constant("MAGPIE_NETWORK_GROUP_NAME"), + email=get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT").format(new_node.name), + password=None, + db_session=request.db) + + group = models.GroupService.by_group_name(get_constant("MAGPIE_NETWORK_GROUP_NAME"), db_session=request.db) + group.description = "Group for users who have accounts on a different Magpie instance on this network." + group.discoverable = False + + +def update_associated_user_groups(node, old_node_name, request): + # type: (models.NetworkNode, Str, Request) -> None + """ + If the ``NetworkNode`` name has changed, update the names of the associated anonymous user and group to match. + """ + if node.name != old_node_name: + old_anonymous_name = models.NetworkNode.anonymous_user_name_formatter(old_node_name) + anonymous_user = request.db.query(models.User).filter(models.User.user_name == old_anonymous_name).one() + anonymous_group = request.db.query(models.Group).filter(models.Group.group_name == old_anonymous_name).one() + anonymous_user.user_name = node.anonymous_user_name() + anonymous_user.email = get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT").format(node.name) + anonymous_group.group_name = node.anonymous_user_name() + + +def delete_network_node(request, node): + # type: (Request, Str) -> None + """ + Delete a NetworkNode and the associated anonymous user and group. + """ + request.db.delete(node.anonymous_user(request.db)) + request.db.delete(node.anonymous_group(request.db)) + request.db.delete(node) + + +def check_network_node_info(db_session=None, name=None, jwks_url=None, token_url=None, authorization_url=None, + redirect_uris=None): + # type: (Optional[Session], Optional[Str], Optional[Str], Optional[Str], Optional[Str], Optional[Str]) -> None + """ + Check that the parameters used to create a new ``NetworkNode`` or update an existing one are well-formed. + """ + if name is not None: + ax.verify_param(name, matches=True, param_name="name", param_compare=NAME_REGEX, + http_error=HTTPBadRequest, + msg_on_fail=s.NetworkNodes_CheckInfo_NameValue_BadRequestResponseSchema.description) + ax.verify_param(name, not_in=True, param_name="name", + param_compare=[n.name for n in db_session.query(models.NetworkNode)], + http_error=HTTPConflict, + msg_on_fail=s.NetworkNodes_CheckInfo_NameValue_ConflictResponseSchema.description) + if jwks_url is not None: + ax.verify_param(jwks_url, matches=True, param_name="jwks_url", param_compare=URL_REGEX, + http_error=HTTPBadRequest, + msg_on_fail=s.NetworkNodes_CheckInfo_JWKSURLValue_BadRequestResponseSchema.description) + if token_url is not None: + ax.verify_param(token_url, matches=True, param_name="token_url", param_compare=URL_REGEX, + http_error=HTTPBadRequest, + msg_on_fail=s.NetworkNodes_CheckInfo_TokenURLValue_BadRequestResponseSchema.description) + if authorization_url is not None: + ax.verify_param(authorization_url, matches=True, param_name="authorization_url", param_compare=URL_REGEX, + http_error=HTTPBadRequest, + msg_on_fail=s.NetworkNodes_CheckInfo_AuthorizationURLValue_BadRequestResponseSchema.description) + if redirect_uris is not None: + for uri in redirect_uris: + ax.verify_param(uri, matches=True, param_name="redirect_uris", param_compare=URL_REGEX, + http_error=HTTPBadRequest, + msg_on_fail=s.NetworkNodes_CheckInfo_RedirectURIsValue_BadRequestResponseSchema.description) + + +def load_redirect_uris(uris, request): + # type: (JSON, AnyRequestType) -> List[Str] + """ + If the uris are a string type, load them as a JSON into a list and return the list. + """ + if isinstance(uris, six.string_types): + return ax.evaluate_call( + lambda: json.loads(uris), + http_error=HTTPBadRequest, + fallback=lambda: request.db.rollback(), + msg_on_fail=s.NetworkNodes_CheckInfo_RedirectURIsValue_BadRequestResponseSchema.description + ) + return uris diff --git a/magpie/api/management/network/node/network_node_views.py b/magpie/api/management/network/node/network_node_views.py new file mode 100644 index 000000000..24a6237fc --- /dev/null +++ b/magpie/api/management/network/node/network_node_views.py @@ -0,0 +1,207 @@ +import jwt +import requests +from pyramid.httpexceptions import ( + HTTPBadRequest, + HTTPCreated, + HTTPForbidden, + HTTPFound, + HTTPInternalServerError, + HTTPNotFound, + HTTPOk +) +from pyramid.security import Authenticated +from pyramid.view import view_config +from six.moves.urllib import parse as up + +from magpie import models +from magpie.api import exception as ax +from magpie.api import requests as ar +from magpie.api import schemas as s +from magpie.api.management.network.network_utils import decode_jwt, encode_jwt +from magpie.api.management.network.node.network_node_utils import ( + check_network_node_info, + create_associated_user_groups, + delete_network_node, + load_redirect_uris, + update_associated_user_groups +) +from magpie.api.requests import check_network_mode_enabled +from magpie.constants import get_constant + + +@s.NetworkNodesAPI.get(tags=[s.NetworkTag], response_schemas=s.NetworkNodes_GET_responses) +@view_config(route_name=s.NetworkNodesAPI.name, request_method="GET", + decorator=check_network_mode_enabled, permission=Authenticated) +def get_network_nodes_view(request): + is_admin = False + if request.user is not None: + admin_group = get_constant("MAGPIE_ADMIN_GROUP", settings_container=request) + is_admin = admin_group in [group.group_name for group in request.user.groups] + nodes = [n.as_dict() for n in request.db.query(models.NetworkNode).all()] + if not is_admin: + nodes = [{"name": n["name"]} for n in nodes] + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkNodes_GET_OkResponseSchema.description, + content={"nodes": nodes}) + + +@s.NetworkNodeAPI.get(tags=[s.NetworkTag], response_schemas=s.NetworkNode_GET_responses) +@view_config(route_name=s.NetworkNodeAPI.name, request_method="GET", decorator=check_network_mode_enabled) +def get_network_node_view(request): + node_name = ar.get_value_matchdict_checked(request, "node_name") + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == node_name).one(), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkNode_NotFoundResponseSchema.description) + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkNode_GET_OkResponseSchema.description, + content=node.as_dict()) + + +@s.NetworkNodesAPI.post(schema=s.NetworkNode_POST_RequestSchema, tags=[s.NetworkTag], + response_schemas=s.NetworkNodes_POST_responses) +@view_config(route_name=s.NetworkNodesAPI.name, request_method="POST", decorator=check_network_mode_enabled) +def post_network_nodes_view(request): + required_params = ("name", "jwks_url", "token_url", "authorization_url") + kwargs = {} + for param in required_params: + value = ar.get_multiformat_body(request, param, default=None) + if value is None: + ax.raise_http(http_error=HTTPBadRequest, detail=s.BadRequestResponseSchema.description) + kwargs[param] = value + redirect_uris = ar.get_multiformat_body(request, "redirect_uris", default=None) + if redirect_uris is not None: + kwargs["redirect_uris"] = load_redirect_uris(redirect_uris, request) + check_network_node_info(request.db, **kwargs) + + node = models.NetworkNode(**kwargs) + request.db.add(node) + create_associated_user_groups(node, request) + return ax.valid_http(http_success=HTTPCreated, detail=s.NetworkNodes_POST_CreatedResponseSchema.description) + + +@s.NetworkNodeAPI.patch(schema=s.NetworkNode_PATCH_RequestSchema, + tags=[s.NetworkTag], response_schemas=s.NetworkNode_PATCH_responses) +@view_config(route_name=s.NetworkNodeAPI.name, request_method="PATCH", decorator=check_network_mode_enabled) +def patch_network_node_view(request): + node_name = ar.get_value_matchdict_checked(request, "node_name") + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == node_name).one(), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkNode_NotFoundResponseSchema.description) + params = ("name", "jwks_url", "token_url", "authorization_url", "redirect_uris") + kwargs = {} + for param in params: + param_value = ar.get_multiformat_body(request, param, default=None) + if param_value is not None: + if param == "redirect_uris": + kwargs[param] = load_redirect_uris(param_value, request) + else: + kwargs[param] = param_value + if not kwargs: + ax.raise_http(http_error=HTTPBadRequest, detail=s.NetworkNodes_PATCH_BadRequestResponseSchema.description) + + check_network_node_info(request.db, **kwargs) + + for attr, value in kwargs.items(): + setattr(node, attr, value) + + update_associated_user_groups(node, node_name, request) + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkNode_PATCH_OkResponseSchema.description) + + +@s.NetworkNodeAPI.delete(tags=[s.NetworkTag], response_schemas=s.NetworkNode_DELETE_responses) +@view_config(route_name=s.NetworkNodeAPI.name, request_method="DELETE", decorator=check_network_mode_enabled) +def delete_network_node_view(request): + node_name = ar.get_value_matchdict_checked(request, "node_name") + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == node_name).one(), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkNode_NotFoundResponseSchema.description) + ax.evaluate_call(lambda: delete_network_node(request, node), + http_error=HTTPInternalServerError, + fallback=lambda: request.db.rollback(), + msg_on_fail=s.InternalServerErrorResponseSchema.description) + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkNode_DELETE_OkResponseSchema.description) + + +@s.NetworkNodeTokenAPI.get(tags=[s.NetworkTag], response_schemas=s.NetworkNodeToken_GET_responses) +@view_config(route_name=s.NetworkNodeTokenAPI.name, request_method="GET", + decorator=check_network_mode_enabled, permission=Authenticated) +def get_network_node_token_view(request): + node_name = ar.get_value_matchdict_checked(request, "node_name") + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == node_name).one(), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkNode_NotFoundResponseSchema.description) + token = encode_jwt({"user_name": request.user.user_name}, node_name, request) + access_token = ax.evaluate_call(lambda: requests.post(node.token_url, + json={"token": token}, + timeout=5).json()["token"], + http_error=HTTPInternalServerError, + msg_on_fail=s.NetworkNodeToken_GET_InternalServerErrorResponseSchema.description) + return ax.valid_http(http_success=HTTPOk, content={"token": access_token}, + detail=s.NetworkNodeToken_GET_OkResponseSchema) + + +@s.NetworkNodeTokenAPI.delete(tags=[s.NetworkTag], response_schemas=s.NetworkNodeToken_DELETE_responses) +@view_config(route_name=s.NetworkNodeTokenAPI.name, request_method="DELETE", + decorator=check_network_mode_enabled, permission=Authenticated) +def delete_network_node_token_view(request): + node_name = ar.get_value_matchdict_checked(request, "node_name") + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == node_name).one(), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkNode_NotFoundResponseSchema.description) + token = encode_jwt({"user_name": request.user.user_name}, node_name, request) + ax.evaluate_call(lambda: requests.delete(node.token_url, json={"token": token}, timeout=5).raise_for_status(), + http_error=HTTPInternalServerError, + msg_on_fail=s.NetworkNodeToken_DELETE_InternalServerErrorResponseSchema.description) + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkNodeToken_DELETE_OkResponseSchema) + + +@s.NetworkLinkAPI.get(schema=s.NetworkLink_GET_RequestSchema, tags=[s.NetworkTag], + response_schemas=s.NetworkLink_GET_responses) +@view_config(route_name=s.NetworkLinkAPI.name, request_method="GET", + decorator=check_network_mode_enabled, permission=Authenticated) +def get_network_node_link_view(request): + token = request.GET.get("token") + node_name = jwt.decode(token, options={"verify_signature": False}).get("iss") + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == node_name).one(), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkNode_NotFoundResponseSchema.description) + decoded_token = decode_jwt(token, node, request) + remote_user_name = ax.evaluate_call(lambda: decoded_token["user_name"], + http_error=HTTPBadRequest, + msg_on_fail=s.NetworkNodeLink_GET_BadRequestResponseSchema.description) + requesting_user_name = ax.evaluate_call(lambda: decoded_token["requesting_user_name"], + http_error=HTTPBadRequest, + msg_on_fail=s.NetworkNodeLink_GET_BadRequestResponseSchema.description) + if requesting_user_name != request.user.user_name: + ax.raise_http(HTTPForbidden, detail=s.HTTPForbiddenResponseSchema.description) + new_remote_user = models.NetworkRemoteUser(user_id=request.user.id, network_node_id=node.id, + name=remote_user_name) + request.db.add(new_remote_user) + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkNodeLink_GET_OkResponseSchema.description) + + +@s.NetworkNodeLinkAPI.post(tags=[s.NetworkTag], response_schemas=s.NetworkNodeLink_POST_responses) +@view_config(route_name=s.NetworkNodeLinkAPI.name, request_method="POST", + decorator=check_network_mode_enabled, permission=Authenticated) +def post_network_node_link_view(request): + node_name = ar.get_value_matchdict_checked(request, "node_name") + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == node_name).one(), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkNode_NotFoundResponseSchema.description + ) + location_tuple = up.urlparse(node.authorization_url) + location_query_list = up.parse_qsl(location_tuple.query) + location_query_list.extend(( + ("token", encode_jwt({"user_name": request.user.user_name}, node.name, request)), + ("response_type", "id_token"), + ("redirect_uri", request.route_url(s.NetworkLinkAPI.name)) + )) + location = up.urlunparse(location_tuple._replace(query=up.urlencode(location_query_list, doseq=True))) + return ax.valid_http(http_success=HTTPFound, + detail=s.NetworkNodeLink_POST_FoundResponseSchema.description, + http_kwargs={"location": location}) diff --git a/magpie/api/management/network/remote_user/__init__.py b/magpie/api/management/network/remote_user/__init__.py new file mode 100644 index 000000000..084964248 --- /dev/null +++ b/magpie/api/management/network/remote_user/__init__.py @@ -0,0 +1,13 @@ +from magpie.utils import get_logger + +LOGGER = get_logger(__name__) + + +def includeme(config): + from magpie.api import schemas as s + LOGGER.info("Adding API network remote users ...") + config.add_route(**s.service_api_route_info(s.NetworkRemoteUsersAPI)) + config.add_route(**s.service_api_route_info(s.NetworkRemoteUserAPI)) + config.add_route(**s.service_api_route_info(s.NetworkRemoteUsersCurrentAPI)) + + config.scan() diff --git a/magpie/api/management/network/remote_user/remote_user_utils.py b/magpie/api/management/network/remote_user/remote_user_utils.py new file mode 100644 index 000000000..68cceb9ac --- /dev/null +++ b/magpie/api/management/network/remote_user/remote_user_utils.py @@ -0,0 +1,71 @@ +from typing import TYPE_CHECKING + +from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound + +from magpie import models +from magpie.api import exception as ax +from magpie.api import requests as ar +from magpie.api import schemas as s +from magpie.constants import get_constant + +if TYPE_CHECKING: + from typing import Optional + + from pyramid.request import Request + + from magpie.typedefs import Session, Str + + +def _remote_user_from_names(node_name, remote_user_name, db_session): + # type: (Str, Str, Session) -> models.NetworkRemoteUser + """ + Return the `NetworkRemoteUser` with the same name as ``remote_user_name`` associated + with the ``NetworkNode`` named ``node_name``. + """ + return (db_session.query(models.NetworkRemoteUser) + .join(models.NetworkNode) + .filter(models.NetworkRemoteUser.name == remote_user_name) + .filter(models.NetworkNode.name == node_name) + .one()) + + +def requested_remote_user(request): + # type: (Request) -> models.NetworkRemoteUser + """ + Return the ``NetworkRemoteUser`` identified by the request path. + + For example: if the current request contains the path ``/nodes/nodeA/remote_users/userB`` + this will return the ``NetworkRemoteUser`` with the name userB that is associated + with the ``NetworkNode`` with the name nodeA. + """ + node_name = ar.get_value_matchdict_checked(request, "node_name") + remote_user_name = ar.get_value_matchdict_checked(request, "remote_user_name") + remote_user = ax.evaluate_call( + lambda: _remote_user_from_names(node_name, remote_user_name, request.db), + http_error=HTTPNotFound, + msg_on_fail=s.NetworkRemoteUser_GET_NotFoundResponseSchema.description) + return remote_user + + +def check_remote_user_access_permissions(request, remote_user=None): + # type: (Request, Optional[models.NetworkRemoteUser]) -> None + """ + Raises an error if the currently logged-in user has permission to view/modify the ``remote_user`` model. + If ``remote_user`` is None, the requested remote user will be extracted from the request path. + + Admins are allowed to access any model. Other users are only allowed to access those that they are associated + with. + """ + if remote_user is None: + remote_user = requested_remote_user(request) + admin_group = get_constant("MAGPIE_ADMIN_GROUP", settings_container=request) + is_admin = admin_group in [group.group_name for group in request.user.groups] + if remote_user.user is None: + associated_user = remote_user.network_node.anonymous_user(request.db) + else: + associated_user = remote_user.user + is_logged_user = request.user.user_name == associated_user.user_name + if not (is_admin or is_logged_user): + # admins can access any remote user, other users can only delete remote users associated with themselves + ax.raise_http(http_error=HTTPForbidden, + detail=s.HTTPForbiddenResponseSchema.description) diff --git a/magpie/api/management/network/remote_user/remote_user_views.py b/magpie/api/management/network/remote_user/remote_user_views.py new file mode 100644 index 000000000..20cc9d636 --- /dev/null +++ b/magpie/api/management/network/remote_user/remote_user_views.py @@ -0,0 +1,142 @@ +from pyramid.httpexceptions import HTTPBadRequest, HTTPCreated, HTTPForbidden, HTTPNotFound, HTTPOk +from pyramid.security import Authenticated +from pyramid.settings import asbool +from pyramid.view import view_config + +from magpie import models +from magpie.api import exception as ax +from magpie.api import requests as ar +from magpie.api import schemas as s +from magpie.api.management.network.remote_user.remote_user_utils import ( + check_remote_user_access_permissions, + requested_remote_user +) +from magpie.api.requests import check_network_mode_enabled +from magpie.constants import protected_user_name_regex + + +@s.NetworkRemoteUsersAPI.get(tags=[s.NetworkTag], schema=s.NetworkRemoteUsers_GET_RequestSchema, + response_schemas=s.NetworkRemoteUsers_GET_responses) +@view_config(route_name=s.NetworkRemoteUsersAPI.name, request_method="GET", decorator=check_network_mode_enabled) +def get_network_remote_users_view(request): + query = request.db.query(models.NetworkRemoteUser) + user_name = request.GET.get("user_name") + if user_name is not None: + query = query.join(models.User).filter(models.User.user_name == user_name) + nodes = [n.as_dict() for n in query.all()] + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkRemoteUsers_GET_OkResponseSchema.description, + content={"remote_users": nodes}) + + +@s.NetworkRemoteUserAPI.get(tags=[s.NetworkTag], response_schemas=s.NetworkRemoteUser_GET_responses) +@view_config(route_name=s.NetworkRemoteUserAPI.name, request_method="GET", + decorator=check_network_mode_enabled, permission=Authenticated) +def get_network_remote_user_view(request): + remote_user = requested_remote_user(request) + check_remote_user_access_permissions(request, remote_user) + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkRemoteUser_GET_OkResponseSchema.description, + content=remote_user.as_dict()) + + +@s.NetworkRemoteUsersAPI.post(schema=s.NetworkRemoteUsers_POST_RequestSchema, tags=[s.NetworkTag], + response_schemas=s.NetworkRemoteUsers_POST_responses) +@view_config(route_name=s.NetworkRemoteUsersAPI.name, request_method="POST", decorator=check_network_mode_enabled) +def post_network_remote_users_view(request): + required_params = ("remote_user_name", "node_name") + kwargs = {"user_name": ar.get_multiformat_body(request, "user_name", default=None)} + for param in required_params: + value = ar.get_multiformat_body(request, param, default=None) + if value is None: + ax.raise_http(http_error=HTTPBadRequest, + detail=s.NetworkRemoteUsers_POST_BadRequestResponseSchema.description) + kwargs[param] = value + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter(models.NetworkNode.name == kwargs["node_name"]).one(), + http_error=HTTPNotFound, + msg_on_fail="No network node with name '{}' found".format(kwargs["node_name"]) + ) + anonymous_user = node.anonymous_user(request.db) + if kwargs["user_name"] is None: + user = anonymous_user + else: + anonymous_regex = protected_user_name_regex(include_admin=False) + ax.verify_param(kwargs["user_name"], not_matches=True, param_compare=anonymous_regex, param_name="user_name", + http_error=HTTPForbidden, + msg_on_fail="Cannot explicitly assign to an anonymous user.") + user = ax.evaluate_call( + lambda: request.db.query(models.User).filter(models.User.user_name == kwargs["user_name"]).one(), + http_error=HTTPNotFound, + msg_on_fail="No user with user_name '{}' found".format(kwargs["user_name"]) + ) + remote_user_name = kwargs["remote_user_name"] + ax.verify_param(remote_user_name, not_empty=True, + param_name="remote_user_name", + http_error=HTTPForbidden, + msg_on_fail="remote_user_name is empty") + if user.id == anonymous_user.id: + user_id = None + else: + user_id = user.id + remote_user = models.NetworkRemoteUser(user_id=user_id, network_node_id=node.id, name=remote_user_name) + request.db.add(remote_user) + return ax.valid_http(http_success=HTTPCreated, detail=s.NetworkRemoteUsers_POST_CreatedResponseSchema.description) + + +@s.NetworkRemoteUserAPI.patch(schema=s.NetworkRemoteUser_PATCH_RequestSchema, + tags=[s.NetworkTag], response_schemas=s.NetworkRemoteUser_PATCH_responses) +@view_config(route_name=s.NetworkRemoteUserAPI.name, request_method="PATCH", decorator=check_network_mode_enabled) +def patch_network_remote_user_view(request): + kwargs = {p: ar.get_multiformat_body(request, p, default=None) for p in + ("remote_user_name", "user_name", "node_name", "assign_anonymous")} + if not any(kwargs.values()): + ax.raise_http(http_error=HTTPBadRequest, detail=s.NetworkRemoteUser_PATCH_BadRequestResponseSchema.description) + remote_user = requested_remote_user(request) + if kwargs["remote_user_name"]: + remote_user_name = kwargs["remote_user_name"] + ax.verify_param(remote_user_name, not_empty=True, + param_name="remote_user_name", + http_error=HTTPBadRequest, + msg_on_fail="remote_user_name is empty") + remote_user.name = remote_user_name + if kwargs["node_name"]: + node = ax.evaluate_call( + lambda: request.db.query(models.NetworkNode).filter( + models.NetworkNode.name == kwargs["node_name"]).one(), + http_error=HTTPNotFound, + msg_on_fail="No network node with name '{}' found".format(kwargs["node_name"]) + ) + remote_user.network_node_id = node.id + if kwargs["user_name"]: + anonymous_regex = protected_user_name_regex(include_admin=False) + ax.verify_param(kwargs["user_name"], not_matches=True, param_compare=anonymous_regex, param_name="user_name", + http_error=HTTPForbidden, + msg_on_fail="Cannot explicitly assign to an anonymous user.") + user = ax.evaluate_call( + lambda: request.db.query(models.User).filter(models.User.user_name == kwargs["user_name"]).one(), + http_error=HTTPNotFound, + msg_on_fail="No user with user_name '{}' found".format(kwargs["user_name"]) + ) + remote_user.user_id = user.id + elif asbool(kwargs["assign_anonymous"]): + remote_user.user_id = None + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkRemoteUsers_PATCH_OkResponseSchema.description) + + +@s.NetworkRemoteUserAPI.delete(tags=[s.NetworkTag], response_schemas=s.NetworkRemoteUser_DELETE_responses) +@view_config(route_name=s.NetworkRemoteUserAPI.name, request_method="DELETE", + decorator=check_network_mode_enabled, permission=Authenticated) +def delete_network_remote_user_view(request): + remote_user = requested_remote_user(request) + check_remote_user_access_permissions(request, remote_user) + request.db.delete(remote_user) + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkRemoteUser_DELETE_OkResponseSchema.description) + + +@s.NetworkRemoteUsersCurrentAPI.get(tags=[s.NetworkTag], response_schemas=s.NetworkRemoteUsersCurrent_GET_responses) +@view_config(route_name=s.NetworkRemoteUsersCurrentAPI.name, request_method="GET", + decorator=check_network_mode_enabled, permission=Authenticated) +def get_network_remote_users_current_view(request): + nodes = [n.as_dict() for n in + request.db.query(models.NetworkRemoteUser).filter(models.NetworkRemoteUser.user_id == request.user.id)] + return ax.valid_http(http_success=HTTPOk, detail=s.NetworkRemoteUsers_GET_OkResponseSchema.description, + content={"remote_users": nodes}) diff --git a/magpie/api/management/user/user_formats.py b/magpie/api/management/user/user_formats.py index 08fdaed3b..7783468a9 100644 --- a/magpie/api/management/user/user_formats.py +++ b/magpie/api/management/user/user_formats.py @@ -1,9 +1,10 @@ +import re from typing import TYPE_CHECKING from pyramid.httpexceptions import HTTPInternalServerError from magpie.api.exception import evaluate_call -from magpie.constants import get_constant +from magpie.constants import protected_user_name_regex from magpie.models import UserGroupStatus, UserStatuses if TYPE_CHECKING: @@ -47,7 +48,8 @@ def fmt_usr(): user_info["has_pending_group"] = bool(user.get_groups_by_status(UserGroupStatus.PENDING)) # special users not meant to be used as valid "accounts" marked as without an ID - if user.user_name != get_constant("MAGPIE_ANONYMOUS_USER") and status != UserStatuses.Pending: + anonymous_regex = protected_user_name_regex(include_admin=False) + if not re.search(anonymous_regex, user.user_name) and status != UserStatuses.Pending: user_info["user{}id".format(sep)] = int(user.id) return user_info diff --git a/magpie/api/management/user/user_utils.py b/magpie/api/management/user/user_utils.py index 1d68fb0c9..048a4c934 100644 --- a/magpie/api/management/user/user_utils.py +++ b/magpie/api/management/user/user_utils.py @@ -34,7 +34,7 @@ get_permission_update_params, process_webhook_requests ) -from magpie.constants import get_constant +from magpie.constants import get_constant, protected_user_email_regex, protected_user_name_regex from magpie.models import TemporaryToken, TokenOperation from magpie.permissions import PermissionSet, PermissionType, format_permissions from magpie.services import SERVICE_TYPE_DICT, service_factory @@ -209,7 +209,6 @@ def update_user(user, request, new_user_name=None, new_password=None, new_email= with_param=False, # params are not useful in response for this case content={"user_name": user.user_name}, http_error=HTTPBadRequest, msg_on_fail=s.User_PATCH_BadRequestResponseSchema.description) - # FIXME: disable email edit when self-registration is enabled to avoid not having any confirmation of new email # (see https://github.com/Ouranosinc/Magpie/issues/436) update_email_admin_only = False @@ -217,7 +216,6 @@ def update_user(user, request, new_user_name=None, new_password=None, new_email= update_email_admin_only = asbool(get_constant("MAGPIE_USER_REGISTRATION_ENABLED", request, default_value=False, print_missing=True, raise_missing=False, raise_not_set=False)) - # user name/status change is admin-only operation if update_username or update_status or update_email_admin_only: err_msg = s.User_PATCH_ForbiddenResponseSchema.description @@ -229,14 +227,12 @@ def update_user(user, request, new_user_name=None, new_password=None, new_email= # logged user updating itself is forbidden if it corresponds to special users # cannot edit reserved keywords nor apply them to another user - forbidden_user_names = [ - get_constant("MAGPIE_ADMIN_USER", request), - get_constant("MAGPIE_ANONYMOUS_USER", request), - get_constant("MAGPIE_LOGGED_USER", request), - ] + forbidden_user_names_regex = protected_user_name_regex( + additional_patterns=(get_constant("MAGPIE_LOGGED_USER", request),), settings_container=request + ) check_user_name_cases = [user.user_name, new_user_name] if update_username else [user.user_name] for check_user_name in check_user_name_cases: - ax.verify_param(check_user_name, not_in=True, param_compare=forbidden_user_names, + ax.verify_param(check_user_name, not_matches=True, param_compare=forbidden_user_names_regex, param_name="user_name", with_param=False, # don't leak the user names http_error=HTTPForbidden, content={"user_name": str(check_user_name)}, msg_on_fail=s.User_PATCH_ForbiddenResponseSchema.description) @@ -858,8 +854,8 @@ def get_user_service_resources_permissions_dict(user, service, request, def check_user_info(user_name=None, email=None, password=None, group_name=None, # required unless disabled explicitly - check_name=True, check_email=True, check_password=True, check_group=True): - # type: (Str, Str, Str, Str, bool, bool, bool, bool) -> None + check_name=True, check_email=True, check_password=True, check_group=True, check_anonymous=True): + # type: (Str, Str, Str, Str, bool, bool, bool, bool, bool) -> None """ Validates provided user information to ensure they are adequate for user creation. @@ -891,6 +887,14 @@ def check_user_info(user_name=None, email=None, password=None, group_name=None, ax.verify_param(user_name, param_compare=name_logged, not_equal=True, param_name="user_name", http_error=HTTPBadRequest, msg_on_fail=s.Users_CheckInfo_ReservedKeyword_BadRequestResponseSchema.description) + if check_anonymous: + anonymous_user_name_regex = protected_user_name_regex() + ax.verify_param(user_name, + not_matches=True, + param_compare=anonymous_user_name_regex, + param_name="user_name", + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_Email_BadRequestResponseSchema.description) if check_email: ax.verify_param(email, not_none=True, not_empty=True, param_name="email", http_error=HTTPBadRequest, @@ -898,6 +902,11 @@ def check_user_info(user_name=None, email=None, password=None, group_name=None, ax.verify_param(email, matches=True, param_compare=ax.EMAIL_REGEX, param_name="email", http_error=HTTPBadRequest, msg_on_fail=s.Users_CheckInfo_Email_BadRequestResponseSchema.description) + if check_anonymous: + anonymous_email_regex = protected_user_email_regex() + ax.verify_param(email, not_matches=True, param_compare=anonymous_email_regex, param_name="email", + http_error=HTTPBadRequest, + msg_on_fail=s.Users_CheckInfo_Email_BadRequestResponseSchema.description) if check_password: ax.verify_param(password, not_none=True, not_empty=True, param_name="password", is_type=True, param_compare=six.string_types, # no match since it can be any character @@ -926,8 +935,9 @@ def check_user_editable(user, container): :raises HTTPForbidden: When user is not allowed to be edited. :return: Nothing if allowed edition. """ - ax.verify_param(user.user_name, not_equal=True, with_param=False, # avoid leaking username details - param_compare=get_constant("MAGPIE_ANONYMOUS_USER", container), + forbidden_user_names_regex = protected_user_name_regex(include_admin=False, settings_container=container) + ax.verify_param(user.user_name, not_matches=True, with_param=False, # avoid leaking username details + param_compare=forbidden_user_names_regex, http_error=HTTPForbidden, msg_on_fail=s.User_CheckAnonymous_ForbiddenResponseSchema.description) diff --git a/magpie/api/requests.py b/magpie/api/requests.py index 8815dbc35..201cbdee0 100644 --- a/magpie/api/requests.py +++ b/magpie/api/requests.py @@ -1,3 +1,4 @@ +import functools from typing import TYPE_CHECKING import six @@ -8,6 +9,7 @@ HTTPForbidden, HTTPInternalServerError, HTTPNotFound, + HTTPNotImplemented, HTTPUnprocessableEntity ) from ziggurat_foundations.models.services.group import GroupService @@ -17,13 +19,13 @@ from magpie import models from magpie.api import exception as ax from magpie.api import schemas as s -from magpie.constants import get_constant +from magpie.constants import get_constant, network_enabled from magpie.permissions import PermissionSet from magpie.utils import CONTENT_TYPE_JSON, get_logger if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from typing import Any, Dict, Iterable, List, Optional, Union + from typing import Any, Callable, Dict, Iterable, List, Optional, Union from pyramid.request import Request @@ -379,3 +381,27 @@ def get_query_param(request, case_insensitive_key, default=None): if param.lower() == key.lower(): return request.params.get(param) return default + + +def check_network_mode_enabled(view_func): + # type: (Callable) -> Callable + """ + Decorator for views that returns a :class:`HTTPNotImplemented` response if network mode is not enabled. + This is intended to be used for all views that should only be accessed if network mode is enabled. + + Instead of decorating a view function directly, pass this function to the ``decorator`` argument of the + ``view_config`` decorator. For example: + + .. code-block:: python + + @view_config(..., decorator=check_network_mode_enabled) + def get_some_view(request): + ... + """ + @functools.wraps(view_func) + def wrapper(context, request): + if not network_enabled(request): + return ax.raise_http(http_error=HTTPNotImplemented, + detail=s.NetworkMode_NotEnabledResponseSchema.description) + return view_func(context, request) + return wrapper diff --git a/magpie/api/schemas.py b/magpie/api/schemas.py index cd7fb8f59..af842c247 100644 --- a/magpie/api/schemas.py +++ b/magpie/api/schemas.py @@ -18,6 +18,7 @@ HTTPMethodNotAllowed, HTTPNotAcceptable, HTTPNotFound, + HTTPNotImplemented, HTTPOk, HTTPUnauthorized, HTTPUnprocessableEntity @@ -276,7 +277,42 @@ def service_api_route_info(service_api, **kwargs): TemporaryUrlAPI = Service( path="/tmp/{token}", # nosec: B108 name="temporary_url") - +NetworkNodeAPI = Service( + path="/network/nodes/{node_name}", + name="NetworkNode") +NetworkNodesAPI = Service( + path="/network/nodes", + name="NetworkNodes") +NetworkNodeTokenAPI = Service( + path="/network/nodes/{node_name}/token", + name="NetworkNodeToken") +NetworkLinkAPI = Service( + path="/network/link", + name="NetworkLink") +NetworkNodeLinkAPI = Service( + path="/network/nodes/{node_name}/link", + name="NetworkNodeLink") +NetworkRemoteUserAPI = Service( + path="/network/nodes/{node_name}/remote_users/{remote_user_name}", + name="NetworkRemoteUser") +NetworkRemoteUsersAPI = Service( + path="/network/remote_users", + name="NetworkRemoteUsers") +NetworkRemoteUsersCurrentAPI = Service( + path="/network/remote_users/current", + name="NetworkRemoteUserCurrent") +NetworkTokenAPI = Service( + path="/network/token", + name="NetworkToken") +NetworkTokensAPI = Service( + path="/network/tokens", + name="NetworkTokens") +NetworkJSONWebKeySetAPI = Service( + path="/network/jwks", + name="NetworkJSONWebKeySet") +NetworkDecodeJWTAPI = Service( + path="/network/decode_jwt", + name="NetworkDecodeJWT") # Path parameters GroupNameParameter = colander.SchemaNode( @@ -414,6 +450,7 @@ class TemporaryURL_RequestPathSchema(colander.MappingSchema): RegisterTag = "Register" ResourcesTag = "Resource" ServicesTag = "Service" +NetworkTag = "Network" TAG_DESCRIPTIONS = { APITag: "General information about the API.", @@ -433,6 +470,7 @@ class TemporaryURL_RequestPathSchema(colander.MappingSchema): RegisterTag: "Registration paths for operations available to users (including non-administrators).", ResourcesTag: "Management of resources that reside under a given service and their applicable permissions.", ServicesTag: "Management of service definitions, children resources and their applicable permissions.", + NetworkTag: "Management of references to other Magpie instances, user and access tokens in the network." } # Header definitions @@ -646,6 +684,7 @@ class ErrorVerifyParamConditions(colander.MappingSchema): is_false = colander.SchemaNode(colander.Boolean(), missing=colander.drop) is_type = colander.SchemaNode(colander.Boolean(), missing=colander.drop) matches = colander.SchemaNode(colander.Boolean(), missing=colander.drop) + not_matches = colander.SchemaNode(colander.Boolean(), missing=colander.drop) class ErrorVerifyParamBodySchema(colander.MappingSchema): @@ -3493,6 +3532,417 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): body = colander.SchemaNode(colander.String(), example="This page!") +class JWTRequestBodySchema(colander.MappingSchema): + token = colander.SchemaNode( + colander.String(), + description="JSON Web Token.", + example="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleGFtcGxlIjoiIn0.3DUg2Qivw_NF8v_LArZFFpsf1-Evv19ewhCVXbh6G2U" + ) + + +class NetworkToken_POST_RequestSchema(BaseRequestSchemaAPI): + body = JWTRequestBodySchema() + + +class NetworkToken_DELETE_RequestSchema(BaseRequestSchemaAPI): + body = JWTRequestBodySchema() + + +class NetworkTokens_DELETE_RequestBodySchema(colander.MappingSchema): + expired_only = colander.SchemaNode( + colander.Boolean(), + description="Boolean indicating whether to only delete expired tokens.", + missing=colander.drop, + example=True + ) + + +class NetworkTokens_DELETE_RequestSchema(BaseRequestSchemaAPI): + body = NetworkTokens_DELETE_RequestBodySchema() + + +class NetworkNode_PATCH_RequestBodySchema(colander.MappingSchema): + name = colander.SchemaNode( + colander.String(), + description="Name of another Magpie node (instance) in the network.", + example="NodeA", + missing=colander.drop + ) + jwks_url = colander.SchemaNode( + colander.String(), + description="URL that provides the JWKS data for another Magpie node (instance) in the network.", + example="https://nodea.example.com/jwks.json", + validator=colander.url, + missing=colander.drop + ) + authorization_url = colander.SchemaNode( + colander.String(), + description="URL that provides the Oauth authorize endpoint for another Magpie node (instance) in the network.", + example="https://nodea.example.com/ui/network/authorize", + validator=colander.url, + missing=colander.drop + ) + token_url = colander.SchemaNode( + colander.String(), + description="URL that provides the Oauth token endpoint for another Magpie node (instance) in the network.", + example="https://nodea.example.com/network/token", + validator=colander.url, + missing=colander.drop + ) + redirect_uris = colander.SchemaNode( + colander.String(), + description="JSON array of valid redirect URIs for another Magpie node (instance) in the network.", + example="https://node.example.com/network/link https://node.example.com/some/other/uri", + missing=colander.drop + ) + + +class NetworkNode_PATCH_RequestSchema(BaseRequestSchemaAPI): + body = NetworkNode_PATCH_RequestBodySchema() + + +class NetworkNode_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Network Node could not be found." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) + + +class NetworkNode_BodySchema(colander.MappingSchema): + name = colander.SchemaNode( + colander.String(), + description="Name of another Magpie node (instance) in the network.", + example="NodeA" + ) + jwks_url = colander.SchemaNode( + colander.String(), + description="URL that provides the JWKS data for another Magpie node (instance) in the network.", + example="https://nodea.example.com/jwks.json", + validator=colander.url + ) + authorization_url = colander.SchemaNode( + colander.String(), + description="URL that provides the Oauth authorize endpoint for another Magpie node (instance) in the network.", + example="https://nodea.example.com/ui/network/authorize", + validator=colander.url, + ) + token_url = colander.SchemaNode( + colander.String(), + description="URL that provides the Oauth token endpoint for another Magpie node (instance) in the network.", + example="https://nodea.example.com/network/token", + validator=colander.url, + ) + redirect_uris = colander.SchemaNode( + colander.String(), + description="JSON array of valid redirect URIs for another Magpie node (instance) in the network", + example="https://node.example.com/network/link https://node.example.com/some/other/uri", + ) + + +NetworkNode_GET_OkResponseBodySchema = NetworkNode_BodySchema + + +class NetworkNode_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "Network Node found." + body = NetworkNode_GET_OkResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkNodesSequence(colander.SequenceSchema): + node = NetworkNode_BodySchema() + + +class NetworkNodes_GET_OkResponseBodySchema(BaseResponseBodySchema): + nodes = NetworkNodesSequence() + + +class NetworkNodes_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "Network Nodes found." + body = NetworkNodes_GET_OkResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkNode_POST_RequestSchema(BaseRequestSchemaAPI): + body = NetworkNode_BodySchema() + + +class NetworkNodes_POST_CreatedResponseSchema(BaseResponseSchemaAPI): + description = "Network Node created." + body = BaseResponseBodySchema(code=HTTPCreated.code, description=description) + + +class NetworkNodes_PATCH_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Missing parameters to update network node." + body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkNodes_CheckInfo_NameValue_ConflictResponseSchema(BaseResponseSchemaAPI): + description = "Network Node already exists with the same name." + body = ErrorResponseBodySchema(code=HTTPConflict.code, description=description) + + +class NetworkNodes_CheckInfo_NameValue_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "name is not valid." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkNodes_CheckInfo_JWKSURLValue_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "jwks_url is not a valid URL." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkNodes_CheckInfo_TokenURLValue_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "token_url is not a valid URL." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkNodes_CheckInfo_AuthorizationURLValue_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "authorization_url is not a valid URL." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkNodes_CheckInfo_RedirectURIsValue_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "redirect_uris contains a URI that is not valid." + body = ErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkNode_PATCH_OkResponseSchema(BaseResponseSchemaAPI): + description = "Network Node updated." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkNode_DELETE_OkResponseSchema(BaseResponseSchemaAPI): + description = "Network Node deleted." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkLink_GET_RequestSchema(BaseRequestSchemaAPI): + body = JWTRequestBodySchema() + + +class NetworkNodeLink_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "User successfully linked with network node." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkRemoteUser_PATCH_RequestBodySchema(colander.MappingSchema): + remote_user_name = colander.SchemaNode( + colander.String(), + description="Name of the associated user from another remote Magpie node (instance) in the network.", + example="userAremote", + missing=colander.drop + ) + user_name = colander.SchemaNode( + colander.String(), + description="Name of the associated user on this Magpie node (instance).", + example="userAlocal", + missing=colander.drop + ) + node_name = colander.SchemaNode( + colander.String(), + description="Name of another Magpie node (instance) in the network.", + example="NodeA", + missing=colander.drop + ) + assign_anonymous = colander.SchemaNode( + colander.Boolean(), + description="Associate this network user with the anonymous user for the associated Magpie node (instance). " + "Only has an effect if the user_name parameter is not specified.", + example=True, + default=False + ) + + +class NetworkRemoteUser_PATCH_RequestSchema(BaseRequestSchemaAPI): + body = NetworkRemoteUser_PATCH_RequestBodySchema() + + +class NetworkRemoteUser_PATCH_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Missing parameters to update network user." + body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkRemoteUser_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Network User could not be found." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) + + +class NetworkRemoteUsers_GET_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Network User could not be found." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) + + +class NetworkRemoteUser_GET_BodySchema(colander.MappingSchema): + user_name = colander.SchemaNode( + colander.String(), + description="Name of the associated user on this Magpie node (instance).", + example="userAlocal" + ) + + +class NetworkRemoteUser_BodySchema(colander.MappingSchema): + remote_user_name = colander.SchemaNode( + colander.String(), + description="Name of the associated user a remote Magpie node (instance) in the network.", + example="userAremote" + ) + user_name = colander.SchemaNode( + colander.String(), + description="Name of the associated user on this Magpie node (instance).", + example="userAlocal" + ) + node_name = colander.SchemaNode( + colander.String(), + description="Name of another Magpie node (instance) in the network.", + example="NodeA" + ) + + +NetworkRemoteUser_GET_OkResponseBodySchema = NetworkRemoteUser_BodySchema + + +class NetworkRemoteUser_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "Network Node found." + body = NetworkRemoteUser_GET_OkResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkRemoteUsers_POST_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Missing required parameter." + body = BaseResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkRemoteUsersSequence(colander.SequenceSchema): + remote_user = NetworkRemoteUser_BodySchema() + + +class NetworkRemoteUsers_GET_OkResponseBodySchema(BaseResponseBodySchema): + remote_users = NetworkRemoteUsersSequence() + + +class NetworkRemoteUsers_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "Remote Users found." + body = NetworkRemoteUsers_GET_OkResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkRemoteUsers_GET_RequestSchema(BaseRequestSchemaAPI): + body = NetworkRemoteUser_GET_BodySchema() + + +class NetworkRemoteUsers_POST_RequestSchema(BaseRequestSchemaAPI): + body = NetworkRemoteUser_BodySchema() + + +class NetworkRemoteUsers_POST_CreatedResponseSchema(BaseResponseSchemaAPI): + description = "Remote user created." + body = BaseResponseBodySchema(code=HTTPCreated.code, description=description) + + +class NetworkRemoteUsers_PATCH_OkResponseSchema(BaseResponseSchemaAPI): + description = "Remote user updated." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkRemoteUser_DELETE_OkResponseSchema(BaseResponseSchemaAPI): + description = "Remote user deleted." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkRemoteUsers_POST_ConflictResponseSchema(BaseResponseSchemaAPI): + description = "Remote user already exists with conflicting attributes." + body = ErrorResponseBodySchema(code=HTTPNotFound.code, description=description) + + +NetworkRemoteUser_PATCH_ConflictResponseSchema = NetworkRemoteUsers_POST_ConflictResponseSchema + + +class NetworkRemoteUser_PATCH_OkResponseSchema(BaseResponseSchemaAPI): + description = "Network Node updated." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkNodeToken_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "Successfully obtained access token." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkNodeToken_GET_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): + description = "Unable to obtain an access token." + body = InternalServerErrorResponseBodySchema(code=HTTPInternalServerError.code, description=description) + + +class NetworkNodeToken_DELETE_OkResponseSchema(BaseResponseSchemaAPI): + description = "Successfully deleted access token." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkTokens_DELETE_OkResponseSchema(BaseResponseSchemaAPI): + description = "Successfully deleted access tokens." + body = BaseResponseBodySchema(code=HTTPOk.code, description=description) + + +class NetworkNodeToken_DELETE_InternalServerErrorResponseSchema(BaseResponseSchemaAPI): + description = "Unable to delete an access token." + body = InternalServerErrorResponseBodySchema(code=HTTPInternalServerError.code, description=description) + + +class NetworkNodeLink_GET_BadRequestResponseSchema(BaseResponseSchemaAPI): + description = "Unable to link user with network node. Missing parameters." + body = InternalServerErrorResponseBodySchema(code=HTTPBadRequest.code, description=description) + + +class NetworkNodeLink_POST_FoundResponseSchema(BaseResponseSchemaAPI): + description = "Redirecting to authorize endpoint on other network node." + body = BaseResponseBodySchema(code=HTTPFound.code, description=description) + + +class NetworkToken_POST_CreatedResponseSchema(BaseResponseSchemaAPI): + description = "Access token created or refreshed." + body = BaseResponseBodySchema(code=HTTPCreated.code, description=description) + + +NetworkToken_DELETE_OkResponseSchema = NetworkNodeToken_DELETE_OkResponseSchema + + +class NetworkNodeToken_DELETE_NotFoundResponseSchema(BaseResponseSchemaAPI): + description = "Unable to delete an access token. Does not exist." + body = BaseResponseBodySchema(code=HTTPNotFound.code, description=description) + + +class NetworkJSONWebKeySet_KeySchema(colander.MappingSchema): + kid = colander.SchemaNode(colander.String(), description="key id", example="keyidtext") + kty = colander.SchemaNode(colander.String(), description="key type", example="RSA") + e = colander.SchemaNode(colander.String(), description="public key exponent part", example="publickeyexponentpart") + n = colander.SchemaNode(colander.String(), description="public key modulus part", example="publickeymoduluspart") + + +class NetworkJSONWebKeySet_KeysSchema(colander.SequenceSchema): + key = NetworkJSONWebKeySet_KeySchema() + + +class NetworkJSONWebKeySet_GET_OkBodyResponseSchema(BaseResponseBodySchema): + keys = NetworkJSONWebKeySet_KeysSchema() + + +class NetworkJSONWebKeySet_GET_OkResponseSchema(BaseResponseSchemaAPI): + description = "JSON Web Key Set found" + body = NetworkJSONWebKeySet_GET_OkBodyResponseSchema(code=HTTPOk.code, description=description) + + +class NetworkDecodeJWT_GET_OkBodyResponseSchema(BaseResponseBodySchema): + jwt_content = colander.MappingSchema() + + +class NetworkDecodeJWT_GET_OkResponseSchema(BaseRequestSchemaAPI): + description = "JSON Web Token is Valid" + body = NetworkDecodeJWT_GET_OkBodyResponseSchema(code=HTTPOk.code, description=description) + + +class NetworkRemoteUsers_POST_ForbiddenResponseSchema(BaseResponseSchemaAPI): + description = "Targeted user update not allowed by requesting user." + body = ErrorResponseBodySchema(code=HTTPForbidden.code, description=description) + + +class NetworkMode_NotEnabledResponseSchema(BaseRequestSchemaAPI): + description = "Network Mode is not enabled." + body = ErrorResponseBodySchema(code=HTTPNotImplemented.code, description=description) + + # Responses for specific views Resource_GET_responses = { "200": Resource_GET_OkResponseSchema(), @@ -4239,6 +4689,106 @@ class SwaggerAPI_GET_OkResponseSchema(colander.MappingSchema): "200": SwaggerAPI_GET_OkResponseSchema(), "500": InternalServerErrorResponseSchema(), } +NetworkToken_POST_responses = { + "201": NetworkToken_POST_CreatedResponseSchema(), + "404": NetworkNode_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkToken_DELETE_responses = { + "200": NetworkToken_DELETE_OkResponseSchema(), + "404": NetworkNodeToken_DELETE_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkTokens_DELETE_responses = { + "200": NetworkTokens_DELETE_OkResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkJSONWebKeySet_GET_responses = { + "200": NetworkJSONWebKeySet_GET_OkResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkDecodeJWT_GET_Responses = { + "200": NetworkDecodeJWT_GET_OkResponseSchema(), + "400": BadRequestResponseSchema(), + "500": InternalServerErrorResponseSchema() +} +NetworkNode_GET_responses = { + "200": NetworkNode_GET_OkResponseSchema(), + "404": NetworkNode_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkNodes_GET_responses = { + "200": NetworkNodes_GET_OkResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkNodes_POST_responses = { + "201": NetworkNodes_POST_CreatedResponseSchema(), + "400": BadRequestResponseSchema(), + "409": NetworkNodes_CheckInfo_NameValue_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkNode_PATCH_responses = { + "200": NetworkNode_PATCH_OkResponseSchema(), + "400": BadRequestResponseSchema(), + "404": NetworkNode_NotFoundResponseSchema(), + "409": NetworkNodes_CheckInfo_NameValue_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkNode_DELETE_responses = { + "200": NetworkNode_DELETE_OkResponseSchema(), + "404": NetworkNode_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkNodeToken_GET_responses = { + "200": NetworkNodeToken_GET_OkResponseSchema(), + "404": NetworkNode_NotFoundResponseSchema(), + "500": NetworkNodeToken_GET_InternalServerErrorResponseSchema() +} +NetworkNodeToken_DELETE_responses = { + "200": NetworkNodeToken_DELETE_OkResponseSchema(), + "404": NetworkNode_NotFoundResponseSchema(), + "500": NetworkNodeToken_DELETE_InternalServerErrorResponseSchema() +} +NetworkLink_GET_responses = { + "200": NetworkNodeLink_GET_OkResponseSchema(), + "400": NetworkNodeLink_GET_BadRequestResponseSchema(), + "404": NetworkNode_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkNodeLink_POST_responses = { + "302": NetworkNodeLink_POST_FoundResponseSchema(), + "404": NetworkNode_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkRemoteUser_GET_responses = { + "200": NetworkRemoteUser_GET_OkResponseSchema(), + "404": NetworkRemoteUser_GET_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkRemoteUsers_GET_responses = { + "200": NetworkRemoteUsers_GET_OkResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkRemoteUsersCurrent_GET_responses = NetworkRemoteUsers_GET_responses +NetworkRemoteUsers_POST_responses = { + "201": NetworkRemoteUsers_POST_CreatedResponseSchema(), + "400": BadRequestResponseSchema(), + "409": NetworkRemoteUsers_POST_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkRemoteUser_PATCH_responses = { + "200": NetworkRemoteUser_PATCH_OkResponseSchema(), + "400": BadRequestResponseSchema(), + "404": NetworkRemoteUser_GET_NotFoundResponseSchema(), + "409": NetworkRemoteUser_PATCH_ConflictResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} +NetworkRemoteUser_DELETE_responses = { + "200": NetworkRemoteUser_DELETE_OkResponseSchema(), + "400": BadRequestResponseSchema(), + "404": NetworkRemoteUser_GET_NotFoundResponseSchema(), + "500": InternalServerErrorResponseSchema(), +} def generate_api_schema(swagger_base_spec): diff --git a/magpie/cli/create_private_key.py b/magpie/cli/create_private_key.py new file mode 100644 index 000000000..02f6a1dc0 --- /dev/null +++ b/magpie/cli/create_private_key.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +""" +Create a private key file used to generate a JSON Web Key. + +This file is required when network mode is enabled in order to sign JSON Web Tokens. +""" + +import argparse +import os.path +import sys +from typing import TYPE_CHECKING + +from magpie.api.management.network.network_utils import create_private_key, pem_files +from magpie.cli.utils import make_logging_options, setup_logger_from_options +from magpie.constants import get_constant +from magpie.utils import get_logger, get_settings_from_config_ini + +if TYPE_CHECKING: + from typing import Optional, Sequence + + from magpie.typedefs import Str + +LOGGER = get_logger(__name__, + message_format="%(asctime)s - %(levelname)s - %(message)s", + datetime_format="%d-%b-%y %H:%M:%S", force_stdout=False) + + +def make_parser(): + # type: () -> argparse.ArgumentParser + parser = argparse.ArgumentParser(description="Create a private key used to generate a JSON Web Key.") + parser.add_argument("--config", "--ini", metavar="CONFIG", dest="ini_config", + default=get_constant("MAGPIE_INI_FILE_PATH"), + help="Configuration INI file to retrieve database connection settings (default: %(default)s).") + parser.add_argument("--key-file", + help="Location to write key file to. Default is to use the first file listed in the " + "MAGPIE_NETWORK_PEM_FILES variable.") + parser.add_argument("--password", + help="Password used to encrypt the key file. Default is to not encrypt the key file unless the " + "the --key-file argument is not set and there is an associated password in the " + "MAGPIE_NETWORK_PEM_PASSWORDS variable.") + parser.add_argument("--force", action="store_true", help="Recreate the key file if it already exists.") + make_logging_options(parser) + return parser + + +def main(args=None, parser=None, namespace=None): + # type: (Optional[Sequence[Str]], Optional[argparse.ArgumentParser], Optional[argparse.Namespace]) -> int + if not parser: + parser = make_parser() + args = parser.parse_args(args=args, namespace=namespace) + setup_logger_from_options(LOGGER, args) + settings_container = get_settings_from_config_ini(args.ini_config) + + if args.key_file: + key_file = args.key_file + else: + pem_files_ = pem_files(settings_container) + if pem_files_: + key_file = pem_files_[0] + else: + LOGGER.error( + "No network PEM files specified. Either set MAGPIE_NETWORK_PEM_FILES or use the --key-file argument") + return 1 + + if os.path.isfile(key_file) and not args.force: + LOGGER.warning("File %s already exists. To overwrite this file use the --force option.", key_file) + return 2 + + password = args.password + if password is not None: + password = password.encode() + + create_private_key(key_file, password=password, settings_container=settings_container) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/magpie/cli/purge_expired_network_tokens.py b/magpie/cli/purge_expired_network_tokens.py new file mode 100644 index 000000000..63e49be9c --- /dev/null +++ b/magpie/cli/purge_expired_network_tokens.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 + +""" +Removes expired network tokens from the database. + +This ensures that the network_tokens table doesn't fill up with expired tokens. +Both an expired token and a non-existent token behave the same from an access perspective (user is denied) so +it is safe to automatically remove all expired tokens. +""" + +import argparse +from typing import TYPE_CHECKING + +import requests +import transaction + +from magpie import models +from magpie.cli.utils import make_logging_options, setup_logger_from_options +from magpie.constants import get_constant +from magpie.db import get_db_session_from_config_ini +from magpie.utils import get_logger, print_log, raise_log + +if TYPE_CHECKING: + from typing import Optional, Sequence + + from magpie.typedefs import Str + +LOGGER = get_logger(__name__, + message_format="%(asctime)s - %(levelname)s - %(message)s", + datetime_format="%d-%b-%y %H:%M:%S", force_stdout=False) + + +def make_parser(): + # type: () -> argparse.ArgumentParser + parser = argparse.ArgumentParser(description="Delete all expired network tokens.") + parser.add_argument("--config", "--ini", metavar="CONFIG", dest="ini_config", + default=get_constant("MAGPIE_INI_FILE_PATH"), + help="Configuration INI file to retrieve database connection settings (default: %(default)s).") + subparsers = parser.add_subparsers(help="run with API or directly access the database", dest="api_or_db") + api_parser = subparsers.add_parser("api") + subparsers.add_parser("db") + + api_parser.add_argument("url", help="URL used to access the magpie service.") + api_parser.add_argument("username", help="Admin username for magpie login.") + api_parser.add_argument("password", help="Admin password for magpie login.") + make_logging_options(parser) + return parser + + +def get_login_session(magpie_url, username, password): + session = requests.Session() + data = {"user_name": username, "password": password} + response = session.post(magpie_url + "/signin", json=data) + if response.status_code != 200: + LOGGER.error(response.content) + return None + return session + + +def main(args=None, parser=None, namespace=None): + # type: (Optional[Sequence[Str]], Optional[argparse.ArgumentParser], Optional[argparse.Namespace]) -> int + if not parser: + parser = make_parser() + args = parser.parse_args(args=args, namespace=namespace) + setup_logger_from_options(LOGGER, args) + if args.api_or_db == "api": + session = get_login_session(args.url, args.username, args.password) + if session is None: + raise_log("Failed to login, invalid username or password", logger=LOGGER) + response = session.delete("{}/network/tokens?expired_only=true".format(args.url)) + try: + response.raise_for_status() + except requests.HTTPError as exc: + raise_log("Failed to delete expired network tokens: {}".format(exc), exception=type(exc), logger=LOGGER) + data = response.json() + deleted = int(data["deleted"]) + else: + db_session = get_db_session_from_config_ini(args.ini_config) + deleted = models.NetworkToken.delete_expired(db_session) + # clean up unused records in the database (no need to keep records associated with anonymous network users) + (db_session.query(models.NetworkRemoteUser) + .filter(models.NetworkRemoteUser.user_id == None) # noqa: E711 # pylint: disable=singleton-comparison + .filter(models.NetworkRemoteUser.network_token_id == None) # noqa: E711 # pylint: disable=singleton-comparison + .delete()) + try: + transaction.commit() + db_session.close() + except Exception as exc: # noqa: W0703 # nosec: B110 # pragma: no cover + db_session.rollback() + raise_log("Failed to delete expired network tokens", exception=type(exc), logger=LOGGER) + if deleted: + print_log("{} expired network tokens deleted".format(deleted), logger=LOGGER) + else: + print_log("No expired network tokens found", logger=LOGGER) + return 0 + + +if __name__ == "__main__": + main() diff --git a/magpie/cli/register_defaults.py b/magpie/cli/register_defaults.py index 574d6183c..c017b6fcc 100644 --- a/magpie/cli/register_defaults.py +++ b/magpie/cli/register_defaults.py @@ -56,7 +56,8 @@ def register_user_with_group(user_name, group_name, email, password, db_session) if password is None: LOGGER.debug("No password provided for user [%s], auto-generating one.", user_name) password = pseudo_random_string(length=get_constant("MAGPIE_PASSWORD_MIN_LENGTH")) - uu.check_user_info(user_name=user_name, password=password, group_name=group_name, check_email=False) + uu.check_user_info(user_name=user_name, password=password, group_name=group_name, check_email=False, + check_anonymous=False) new_user = models.User(user_name=user_name, email=email) # noqa UserService.set_password(new_user, password) db_session.add(new_user) @@ -126,7 +127,8 @@ def init_admin(db_session, settings=None): # admin user already exist, update modified password LOGGER.warning("Detected password change for 'MAGPIE_ADMIN_USER'. Attempting to update...") try: - uu.check_user_info(password=admin_password, check_name=False, check_email=False, check_group=False) + uu.check_user_info(password=admin_password, check_name=False, check_email=False, check_group=False, + check_anonymous=False) UserService.set_password(admin_usr, admin_password) UserService.regenerate_security_code(admin_usr) except Exception as http_exc: # noqa # re-raised as value error # pragma: no cover diff --git a/magpie/constants.py b/magpie/constants.py index d05ae7e4d..0cb47d0d1 100644 --- a/magpie/constants.py +++ b/magpie/constants.py @@ -11,6 +11,7 @@ constant ``MAGPIE_INI_FILE_PATH`` (or any other `path variable` defined before it - see below) has to be defined by environment variable if the default location is not desired (ie: if you want to provide your own configuration). """ +import functools import logging import os import re @@ -23,7 +24,7 @@ if TYPE_CHECKING: # pylint: disable=W0611,unused-import - from typing import Optional + from typing import Optional, Tuple from magpie.typedefs import AnySettingsContainer, SettingValue, Str @@ -105,6 +106,13 @@ def _get_default_log_level(): MAGPIE_CRON_LOG = os.getenv("MAGPIE_CRON_LOG", "~/magpie-cron.log") MAGPIE_DB_MIGRATION = asbool(os.getenv("MAGPIE_DB_MIGRATION", True)) # run db migration on startup MAGPIE_DB_MIGRATION_ATTEMPTS = int(os.getenv("MAGPIE_DB_MIGRATION_ATTEMPTS", 5)) +MAGPIE_NETWORK_ENABLED = asbool(os.getenv("MAGPIE_NETWORK_ENABLED", False)) +MAGPIE_NETWORK_INSTANCE_NAME = os.getenv("MAGPIE_NETWORK_INSTANCE_NAME") +MAGPIE_NETWORK_DEFAULT_TOKEN_EXPIRY = int(os.getenv("MAGPIE_NETWORK_DEFAULT_TOKEN_EXPIRY", 86400)) +MAGPIE_NETWORK_INTERNAL_TOKEN_EXPIRY = int(os.getenv("MAGPIE_NETWORK_INTERNAL_TOKEN_EXPIRY", 30)) +MAGPIE_NETWORK_PEM_FILES = os.getenv("MAGPIE_NETWORK_PEM_FILES", os.path.join(MAGPIE_ROOT, "key.pem")) +MAGPIE_NETWORK_PEM_PASSWORDS = os.getenv("MAGPIE_NETWORK_PEM_PASSWORDS") +MAGPIE_NETWORK_CREATE_MISSING_PEM_FILE = asbool(os.getenv("MAGPIE_NETWORK_CREATE_MISSING_PEM_FILE", False)) MAGPIE_LOG_LEVEL = os.getenv("MAGPIE_LOG_LEVEL", _get_default_log_level()) # log level to apply to the loggers MAGPIE_LOG_PRINT = asbool(os.getenv("MAGPIE_LOG_PRINT", False)) # log also forces print to the console MAGPIE_LOG_REQUEST = asbool(os.getenv("MAGPIE_LOG_REQUEST", True)) # log detail of every incoming request @@ -146,6 +154,11 @@ def _get_default_log_level(): MAGPIE_CONTEXT_PERMISSION = "MAGPIE_CONTEXT_USER" # path user must be itself, MAGPIE_LOGGED_USER or unauthenticated MAGPIE_LOGGED_USER = "current" MAGPIE_DEFAULT_PROVIDER = "ziggurat" +MAGPIE_NETWORK_TOKEN_NAME = "magpie_token" # nosec: B105 +MAGPIE_NETWORK_PROVIDER = "magpie_network" +MAGPIE_NETWORK_NAME_PREFIX = "anonymous_network_" +MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT = "{}{}@mail.com".format(MAGPIE_NETWORK_NAME_PREFIX, "{}") +MAGPIE_NETWORK_GROUP_NAME = "magpie_network" # above this length is considered a token, # refuse longer username creation @@ -162,6 +175,10 @@ def _get_default_log_level(): "MAGPIE_DEFAULT_PROVIDER", "MAGPIE_USER_NAME_MAX_LENGTH", "MAGPIE_GROUP_NAME_MAX_LENGTH", + "MAGPIE_NETWORK_TOKEN_NAME", + "MAGPIE_NETWORK_PROVIDER", + "MAGPIE_NETWORK_NAME_PREFIX", + "MAGPIE_NETWORK_GROUP_NAME" ] # =========================== @@ -171,6 +188,81 @@ def _get_default_log_level(): _REGEX_ASCII_ONLY = re.compile(r"\W|^(?=\d)") +@functools.lru_cache(maxsize=128) +def protected_user_name_regex(include_admin=True, + include_anonymous=True, + include_network=True, + additional_patterns=None, + settings_container=None): + # type: (bool, bool, bool, Optional[Tuple[Str]], Optional[AnySettingsContainer]) -> re.Pattern + """ + Return a regular expression that matches all user names that are protected, meaning that they are generated + by Magpie itself and no regular user account should be created with these user names. + """ + patterns = list(additional_patterns or []) + if include_admin: + patterns.append(get_constant("MAGPIE_ADMIN_USER", settings_container=settings_container)) + if include_anonymous: + patterns.append(get_constant("MAGPIE_ANONYMOUS_USER", settings_container=settings_container)) + if include_network and network_enabled(settings_container=settings_container): + patterns.append( + "{}.*".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX", settings_container=settings_container)) + ) + return re.compile("^({})$".format("|".join(patterns))) + + +@functools.lru_cache(maxsize=128) +def protected_user_email_regex(include_admin=True, + include_anonymous=True, + include_network=True, + additional_patterns=None, + settings_container=None): + # type: (bool, bool, bool, Optional[Tuple[Str]], Optional[AnySettingsContainer]) -> re.Pattern + """ + Return a regular expression that matches all user emails that are protected, meaning that they are generated + by Magpie itself and no regular user account should be created with these user emails. + """ + patterns = list(additional_patterns or []) + if include_admin: + patterns.append(get_constant("MAGPIE_ADMIN_EMAIL", settings_container=settings_container)) + if include_anonymous: + patterns.append(get_constant("MAGPIE_ANONYMOUS_EMAIL", settings_container=settings_container)) + if include_network and network_enabled(settings_container=settings_container): + email_form = get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT", settings_container=settings_container) + patterns.append(email_form.format(".*")) + return re.compile("^({})$".format("|".join(patterns))) + + +@functools.lru_cache(maxsize=128) +def protected_group_name_regex(include_admin=True, + include_anonymous=True, + include_network=True, + settings_container=None): + # type: (bool, bool, bool, Optional[AnySettingsContainer]) -> re.Pattern + """ + Return a regular expression that matches all group names that are protected, meaning that they are generated + by Magpie itself and no regular user account should be created with these group names. + """ + patterns = [] + if include_admin: + patterns.append(get_constant("MAGPIE_ADMIN_GROUP", settings_container=settings_container)) + if include_anonymous: + patterns.append(get_constant("MAGPIE_ANONYMOUS_GROUP", settings_container=settings_container)) + if include_network and network_enabled(settings_container=settings_container): + patterns.append( + "{}.*".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX", settings_container=settings_container)) + ) + return re.compile("^({})$".format("|".join(patterns))) + + +def network_enabled(settings_container=None): + # type: (Optional[AnySettingsContainer]) -> bool + """ + Return whether network mode is enabled. + """ + return asbool(get_constant("MAGPIE_NETWORK_ENABLED", settings_container=settings_container)) + + def get_constant_setting_name(name): # type: (Str) -> Str """ diff --git a/magpie/models.py b/magpie/models.py index e15a5fe35..a1c426d0e 100644 --- a/magpie/models.py +++ b/magpie/models.py @@ -1,4 +1,5 @@ import datetime +import hashlib import math import uuid from typing import TYPE_CHECKING @@ -6,11 +7,13 @@ import sqlalchemy as sa from pyramid.httpexceptions import HTTPInternalServerError from pyramid.security import ALL_PERMISSIONS, Allow, Authenticated, Everyone +from sqlalchemy import UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship from sqlalchemy.sql import func +from sqlalchemy_utils import URLType from ziggurat_foundations import ziggurat_model_init from ziggurat_foundations.models.base import BaseModel, get_db_session from ziggurat_foundations.models.external_identity import ExternalIdentityMixin @@ -996,6 +999,137 @@ def json(self): return {"token": str(self.token), "operation": str(self.operation.value)} +class NetworkRemoteUser(BaseModel, Base): + """ + Model that defines a relationship between a :class:`User` and a :class:`User` that is authenticated on a different + :class:`NetworkNode`. + """ + __tablename__ = "network_remote_users" + + id = sa.Column(sa.Integer(), primary_key=True, nullable=False, autoincrement=True) + # Note: a null user_id indicates that this NetworkRemoteUser is associated with the anonymous user for the + # associated network node. This is to ensure that the unique constraint defined below allows for multiple + # NetworkRemoteUsers to be associated with the anonymous users. + user_id = sa.Column(sa.Integer, + sa.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"), nullable=True) + user = relationship("User", foreign_keys=[user_id]) + + network_node_id = sa.Column(sa.Integer, + sa.ForeignKey("network_nodes.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False) + network_token_id = sa.Column(sa.Integer, + sa.ForeignKey("network_tokens.id", onupdate="CASCADE", ondelete="SET NULL"), + unique=True) + name = sa.Column(sa.Unicode(128)) + network_node = relationship("NetworkNode", foreign_keys=[network_node_id]) + network_token = relationship("NetworkToken", foreign_keys=[network_token_id], back_populates="network_remote_user") + + __table_args__ = (UniqueConstraint("user_id", "network_node_id"), + UniqueConstraint("name", "network_node_id")) + + def as_dict(self): + # type: () -> Dict[Str, Str] + return { + "user_name": getattr(self.user, "user_name", self.network_node.anonymous_user_name()), + "remote_user_name": self.name, + "node_name": self.network_node.name + } + + +class NetworkToken(BaseModel, Base): + """ + Model that defines a token for authentication across a network of Magpie instances. + """ + __tablename__ = "network_tokens" + + def __init__(self, *_, **__): + super(NetworkToken, self).__init__(*_, **__) + if self.token: + # The token should not be created with the object, instead call refresh_token after the object is created + # Otherwise, the unhashed token will never be available to the user + self.token = None + + @staticmethod + def _hash_token(token): + # type: (Str) -> Str + if isinstance(token, str): + token = uuid.UUID(token) + h = hashlib.sha256() + h.update(token.bytes) + return h.hexdigest() + + def refresh_token(self): + # type: () -> str + unhashed_token = str(uuid.uuid4()) + self.token = self._hash_token(unhashed_token) + self.created = datetime.datetime.utcnow() + return unhashed_token + + def expired(self): + # type: () -> bool + expire = int(get_constant("MAGPIE_NETWORK_DEFAULT_TOKEN_EXPIRY")) + return (datetime.datetime.utcnow() - self.created) > datetime.timedelta(seconds=expire) + + @classmethod + def delete_expired(cls, db_session): + # type: (Session) -> int + token_expiry = int(get_constant("MAGPIE_NETWORK_DEFAULT_TOKEN_EXPIRY")) + expiry_date_time = datetime.datetime.utcnow() - datetime.timedelta(seconds=token_expiry) + return db_session.query(cls).filter(cls.created < expiry_date_time).delete() + + @classmethod + def by_token(cls, token, db_session=None): + # type: (Str, Optional[Session]) -> Optional[NetworkToken] + db_session = get_db_session(db_session) + try: + hashed_token = cls._hash_token(token) + except ValueError: + return + return db_session.query(cls).filter(cls.token == hashed_token).first() + + id = sa.Column(sa.Integer(), primary_key=True, nullable=False, autoincrement=True) + token = sa.Column(sa.String, nullable=False, unique=True) + created = sa.Column(sa.DateTime, default=datetime.datetime.utcnow) + network_remote_user = relationship("NetworkRemoteUser", back_populates="network_token", + single_parent=True, + uselist=False) + + +class NetworkNode(BaseModel, Base): + """ + Model that defines a node in a network of Magpie instances. + """ + __tablename__ = "network_nodes" + + id = sa.Column(sa.Integer(), primary_key=True, nullable=False, autoincrement=True) + name = sa.Column(sa.Unicode(128), nullable=False, unique=True) + jwks_url = sa.Column(URLType(), nullable=False) + token_url = sa.Column(URLType(), nullable=False) + authorization_url = sa.Column(URLType(), nullable=False) + redirect_uris = sa.Column(sa.JSON, nullable=False, server_default="[]") + + def anonymous_user_name(self): + # type: () -> Str + return self.anonymous_user_name_formatter(self.name) + + @staticmethod + def anonymous_user_name_formatter(name): + # type: (Str) -> Str + return "{}{}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), name) + + def anonymous_user(self, db_session): + # type: (Optional[Session]) -> User + return db_session.query(User).filter(User.user_name == self.anonymous_user_name()).one() + + def anonymous_group(self, db_session): + # type: (Optional[Session]) -> User + return db_session.query(Group).filter(Group.group_name == self.anonymous_user_name()).one() + + def as_dict(self): + # type: () -> Dict[Str, Any] + return {attr.key: getattr(self, attr.key) for attr in sa.inspect(NetworkNode).columns if attr.key != "id"} + + ziggurat_model_init(User, Group, UserGroup, GroupPermission, UserPermission, UserResourcePermission, GroupResourcePermission, Resource, ExternalIdentity, passwordmanager=None) diff --git a/magpie/ui/__init__.py b/magpie/ui/__init__.py index 58c39b439..a9b9ffc5f 100644 --- a/magpie/ui/__init__.py +++ b/magpie/ui/__init__.py @@ -11,3 +11,4 @@ def includeme(config): config.include("magpie.ui.login") config.include("magpie.ui.management") config.include("magpie.ui.user") + config.include("magpie.ui.network") diff --git a/magpie/ui/home/static/style.css b/magpie/ui/home/static/style.css index b8523d9ff..d1a56a1ca 100644 --- a/magpie/ui/home/static/style.css +++ b/magpie/ui/home/static/style.css @@ -1436,3 +1436,18 @@ div.input-container { height: 100%; -webkit-box-sizing: border-box; } + +/* authorization form */ + +table.authorization-table { + width: auto; + border: 1px solid; + border-radius: 4px; + margin-top: 10px; + padding: 1px; +} + +table.authorization-table td { + border: 0; + text-align: center; +} diff --git a/magpie/ui/management/templates/edit_user.mako b/magpie/ui/management/templates/edit_user.mako index 399919b77..2bd382159 100644 --- a/magpie/ui/management/templates/edit_user.mako +++ b/magpie/ui/management/templates/edit_user.mako @@ -383,3 +383,54 @@ ${membership_alerts.edit_membership_alerts()} >${perm_type.lower()} + +%if network_enabled and user_name not in MAGPIE_FIXED_USERS_REFS: +

Network Account Links

+ + + + + + + %for node_name, remote_user_name in network_nodes: + + + %if remote_user_name is None: + + + %else: + + + %endif + + %endfor +
Network Node NameUsernameAction
${node_name} + + + ${remote_user_name} + +
+%endif diff --git a/magpie/ui/management/views.py b/magpie/ui/management/views.py index de9dedec8..df0b80bb2 100644 --- a/magpie/ui/management/views.py +++ b/magpie/ui/management/views.py @@ -22,7 +22,7 @@ from magpie.api import schemas from magpie.cli.sync_resources import OUT_OF_SYNC, fetch_single_service, get_last_sync, merge_local_and_remote_resources from magpie.cli.sync_services import SYNC_SERVICES_TYPES -from magpie.constants import get_constant +from magpie.constants import get_constant, network_enabled # FIXME: remove (REMOTE_RESOURCE_TREE_SERVICE, RESOURCE_TYPE_DICT), implement getters via API from magpie.models import REMOTE_RESOURCE_TREE_SERVICE, RESOURCE_TYPE_DICT, UserGroupStatus, UserStatuses from magpie.permissions import Permission, PermissionSet @@ -161,6 +161,17 @@ def edit_user(self): user_info["invalid_{}".format(field)] = False user_info["reason_{}".format(field)] = "" + # add network information + if network_enabled(self.request): + request_uri = "{}?user_name={}".format(schemas.NetworkRemoteUsersAPI.path, user_name) + resp = request_api(self.request, request_uri, "GET") + check_response(resp) + user_info["network_enabled"] = True + user_info["network_nodes"] = [(n["node_name"], n["remote_user_name"]) for n in + get_json(resp)["remote_users"]] + user_info["network_routes"] = {"create": schemas.NetworkRemoteUsersAPI.name, + "delete": schemas.NetworkRemoteUserAPI.name} + if self.request.method == "POST": res_id = self.request.POST.get("resource_id") is_edit_group_membership = False diff --git a/magpie/ui/network/__init__.py b/magpie/ui/network/__init__.py new file mode 100644 index 000000000..6908ea479 --- /dev/null +++ b/magpie/ui/network/__init__.py @@ -0,0 +1,11 @@ +from magpie.utils import fully_qualified_name, get_logger + +LOGGER = get_logger(__name__) + + +def includeme(config): + from magpie.ui.network.views import NetworkViews + LOGGER.info("Adding UI network...") + path = "/ui/network/authorize" + config.add_route(fully_qualified_name(NetworkViews.authorize), path) + config.scan() diff --git a/magpie/ui/network/templates/authorize.mako b/magpie/ui/network/templates/authorize.mako new file mode 100644 index 000000000..548487164 --- /dev/null +++ b/magpie/ui/network/templates/authorize.mako @@ -0,0 +1,37 @@ +<%inherit file="magpie.ui.home:templates/template.mako"/> + + + + diff --git a/magpie/ui/network/views.py b/magpie/ui/network/views.py new file mode 100644 index 000000000..200c2d1ec --- /dev/null +++ b/magpie/ui/network/views.py @@ -0,0 +1,53 @@ +from urllib.parse import urlparse + +from pyramid.authentication import Authenticated +from pyramid.httpexceptions import HTTPBadRequest +from pyramid.view import view_config + +from magpie.api import schemas +from magpie.api.management.network.network_utils import encode_jwt +from magpie.api.requests import check_network_mode_enabled +from magpie.ui.utils import AdminRequests, check_response, request_api +from magpie.utils import get_json, get_logger + +LOGGER = get_logger(__name__) + + +class NetworkViews(AdminRequests): + @view_config(route_name="magpie.ui.network.views.NetworkViews.authorize", + decorator=check_network_mode_enabled, + renderer="templates/authorize.mako", permission=Authenticated) + def authorize(self): + token = self.request.GET.get("token") + response_type = self.request.GET.get("response_type") + redirect_uri = self.request.GET.get("redirect_uri") + + # Extend this to other response types later if needed + if response_type != "id_token": + raise HTTPBadRequest("Invalid response type") + if token is None: + raise HTTPBadRequest("Missing token") + admin_cookies = self.get_admin_session() + jwt_path = "{}?token={}".format(schemas.NetworkDecodeJWTAPI.path, token) + jwt_resp = request_api(self.request, jwt_path, "GET", cookies=admin_cookies) + check_response(jwt_resp) + token_content = get_json(jwt_resp)["jwt_content"] + + node_name = token_content["iss"] + node_path = schemas.NetworkNodeAPI.path.format(node_name=node_name) + node_resp = request_api(self.request, node_path, "GET", cookies=admin_cookies) + check_response(node_resp) + node_details = get_json(node_resp) + + if redirect_uri not in node_details["redirect_uris"]: + raise HTTPBadRequest("Invalid redirect URI") + + requesting_user_name = token_content.get("user_name") + token_claims = {"requesting_user_name": requesting_user_name, "user_name": self.request.user.user_name} + response_token = encode_jwt(token_claims, node_name, self.request) + + return self.add_template_data(data={"authorize_uri": redirect_uri, + "token": response_token, + "requesting_user_name": requesting_user_name, + "node_name": node_name, + "referrer": urlparse(self.request.referrer).hostname}) diff --git a/magpie/ui/user/templates/edit_current_user.mako b/magpie/ui/user/templates/edit_current_user.mako index f08cad82d..9f9582cae 100644 --- a/magpie/ui/user/templates/edit_current_user.mako +++ b/magpie/ui/user/templates/edit_current_user.mako @@ -272,3 +272,48 @@ ${membership_alerts.edit_membership_alerts()} %endfor + +%if network_enabled: +

Network Account Links

+ + + + + + + %for node_name, remote_user_name in network_nodes: + + + %if remote_user_name is None: + + + %else: + + + %endif + + %endfor +
Network Node NameUsernameAction
${node_name} + + ${remote_user_name} + +
+%endif diff --git a/magpie/ui/user/views.py b/magpie/ui/user/views.py index 45cb9cac1..0290a85a3 100644 --- a/magpie/ui/user/views.py +++ b/magpie/ui/user/views.py @@ -6,7 +6,7 @@ from pyramid.view import view_config from magpie.api import schemas -from magpie.constants import get_constant +from magpie.constants import get_constant, network_enabled from magpie.models import UserGroupStatus from magpie.ui.utils import BaseViews, check_response, handle_errors, request_api from magpie.utils import get_json, get_logger @@ -89,6 +89,19 @@ def edit_current_user(self): default_value=False, print_missing=True, raise_missing=False, raise_not_set=False)) user_info["user_with_error"] = schemas.UserStatuses.get(user_info["status"]) != schemas.UserStatuses.OK + # add network information + if network_enabled(self.request): + node_resp = request_api(self.request, schemas.NetworkNodesAPI.path, "GET") + check_response(node_resp) + nodes = {node["name"]: None for node in get_json(node_resp)["nodes"]} + user_resp = request_api(self.request, schemas.NetworkRemoteUsersCurrentAPI.path, "GET") + check_response(user_resp) + for info in get_json(user_resp)["remote_users"]: + nodes[info["node_name"]] = info["remote_user_name"] + user_info["network_enabled"] = True + user_info["network_nodes"] = list(nodes.items()) + user_info["network_routes"] = {"create": schemas.NetworkNodeLinkAPI.name, + "delete": schemas.NetworkRemoteUserAPI.name} # reset error messages/flags user_info["error_message"] = "" for field in ["password", "user_email", "user_name"]: diff --git a/magpie/ui/utils.py b/magpie/ui/utils.py index 123687c90..0f8d32d3b 100644 --- a/magpie/ui/utils.py +++ b/magpie/ui/utils.py @@ -1,4 +1,5 @@ import json +import re from secrets import compare_digest # noqa 'python2-secrets' from typing import TYPE_CHECKING @@ -20,7 +21,7 @@ from magpie.api import schemas from magpie.api.generic import get_exception_info, get_request_info from magpie.api.requests import get_logged_user -from magpie.constants import get_constant +from magpie.constants import get_constant, network_enabled, protected_group_name_regex, protected_user_name_regex from magpie.models import UserGroupStatus from magpie.security import mask_credentials from magpie.utils import CONTENT_TYPE_JSON, get_header, get_json, get_logger, get_magpie_url @@ -175,39 +176,61 @@ def wrap(*args, **kwargs): return wrap +class _ReContainsWrapper(object): + """ + Class that wraps the re.search function with the __contains__ method. + + Used in BaseViews so that code in mako templates can continue to use syntax like: + + .. code-block:: python + + user_name in MAGPIE_FIXED_USERS + """ + + def __init__(self, regex="x^"): # Note that the default "x^" matches nothing + self.regex = regex + + def __contains__(self, item): + # type: (Any) -> bool + try: + return bool(re.search(self.regex, item)) + except TypeError: + return False + + @view_defaults(decorator=handle_errors) class BaseViews(object): """ Base methods for Magpie UI pages. """ - MAGPIE_FIXED_GROUP_MEMBERSHIPS = [] + MAGPIE_FIXED_GROUP_MEMBERSHIPS = _ReContainsWrapper() """ Special :term:`Group` memberships that cannot be edited. """ - MAGPIE_FIXED_GROUP_EDITS = [] + MAGPIE_FIXED_GROUP_EDITS = _ReContainsWrapper() """ Special :term:`Group` details that cannot be edited. """ - MAGPIE_FIXED_USERS = [] + MAGPIE_FIXED_USERS = _ReContainsWrapper() """ Special :term:`User` details that cannot be edited. """ - MAGPIE_FIXED_USERS_REFS = [] + MAGPIE_FIXED_USERS_REFS = _ReContainsWrapper() """ Special :term:`User` that cannot have any relationship edited. This includes both :term:`Group` memberships and :term:`Permission` references. """ - MAGPIE_USER_PWD_LOCKED = [] + MAGPIE_USER_PWD_LOCKED = _ReContainsWrapper() """ Special :term:`User` that *could* self-edit themselves, but is disabled since conflicting with other policies. """ - MAGPIE_USER_PWD_DISABLED = [] + MAGPIE_USER_PWD_DISABLED = _ReContainsWrapper() """ Special :term:`User` where password cannot be edited (managed by `Magpie` configuration settings). """ @@ -223,22 +246,29 @@ def __init__(self, request): self.ui_theme = get_constant("MAGPIE_UI_THEME", self.request) self.logged_user = get_logged_user(self.request) - anonym_grp = get_constant("MAGPIE_ANONYMOUS_GROUP", settings_container=self.request) - admin_grp = get_constant("MAGPIE_ADMIN_GROUP", settings_container=self.request) - self.__class__.MAGPIE_FIXED_GROUP_MEMBERSHIPS = [anonym_grp] - self.__class__.MAGPIE_FIXED_GROUP_EDITS = [anonym_grp, admin_grp] - # special users that cannot be deleted - anonym_usr = get_constant("MAGPIE_ANONYMOUS_USER", self.request) - admin_usr = get_constant("MAGPIE_ADMIN_USER", self.request) - self.__class__.MAGPIE_FIXED_USERS_REFS = [anonym_usr] - self.__class__.MAGPIE_FIXED_USERS = [admin_usr, anonym_usr] - self.__class__.MAGPIE_USER_PWD_LOCKED = [admin_usr] - self.__class__.MAGPIE_USER_PWD_DISABLED = [anonym_usr, admin_usr] + self.__class__.MAGPIE_FIXED_GROUP_MEMBERSHIPS = _ReContainsWrapper( + protected_group_name_regex(include_admin=False, include_network=False, settings_container=self.request) + ) + self.__class__.MAGPIE_FIXED_GROUP_EDITS = _ReContainsWrapper( + protected_group_name_regex(settings_container=self.request) + ) + self.__class__.MAGPIE_FIXED_USERS_REFS = _ReContainsWrapper( + protected_user_name_regex(include_admin=False, settings_container=self.request) + ) + self.__class__.MAGPIE_FIXED_USERS = _ReContainsWrapper( + protected_user_name_regex(settings_container=self.request) + ) + self.__class__.MAGPIE_USER_PWD_LOCKED = _ReContainsWrapper( + protected_user_name_regex(include_anonymous=False, include_network=False, settings_container=self.request) + ) + self.__class__.MAGPIE_USER_PWD_DISABLED = _ReContainsWrapper( + protected_user_name_regex(settings_container=self.request) + ) self.__class__.MAGPIE_USER_REGISTRATION_ENABLED = asbool( get_constant("MAGPIE_USER_REGISTRATION_ENABLED", self.request, default_value=False, print_missing=True, raise_missing=False, raise_not_set=False) ) - self.__class__.MAGPIE_ANONYMOUS_GROUP = anonym_grp + self.__class__.MAGPIE_ANONYMOUS_GROUP = get_constant("MAGPIE_ANONYMOUS_GROUP", settings_container=self.request) def add_template_data(self, data=None): # type: (Optional[Dict[Str, Any]]) -> Dict[Str, Any] @@ -562,8 +592,11 @@ def create_user(self, data): if (user_email or "").lower() in [usr["email"].lower() for usr in user_details]: data["invalid_user_email"] = True data["reason_user_email"] = "Conflict" - if user_email == "": - data["invalid_user_email"] = True + if network_enabled(self.request): + anonymous_regex = protected_user_name_regex(include_admin=False, settings_container=self.request) + if re.match(anonymous_regex, user_name): + data["invalid_user_name"] = True + data["reason_user_name"] = "Conflict" if len(user_name) > get_constant("MAGPIE_USER_NAME_MAX_LENGTH", self.request): data["invalid_user_name"] = True data["reason_user_name"] = "Too Long" diff --git a/magpie/utils.py b/magpie/utils.py index bf36e0fe9..34dc02d46 100644 --- a/magpie/utils.py +++ b/magpie/utils.py @@ -35,7 +35,7 @@ from zope.interface import implementer from magpie import __meta__ -from magpie.constants import get_constant +from magpie.constants import get_constant, network_enabled if sys.version_info >= (3, 6): from enum import Enum @@ -1176,3 +1176,36 @@ class classproperty(property): # pylint: disable=C0103,invalid-name """ def __get__(self, cls, owner): # noqa return classmethod(self.fget).__get__(None, owner)() + + +def check_network_configured(settings_container=None): + # type: (Optional[AnySettingsContainer]) -> None + """ + Check that the required variables are set and configured properly to support network mode if network mode is + enabled. If not, raises a :class:`ConfigurationError`. + + .. note:: + This should be called when the application starts up to detect a misconfigured application right away. + """ + if network_enabled(settings_container): + try: + get_constant("MAGPIE_NETWORK_INSTANCE_NAME", settings_container=settings_container, empty_missing=True) + except (ValueError, LookupError) as exc: + raise ConfigurationError("MAGPIE_NETWORK_INSTANCE_NAME is required when network mode is enabled.") from exc + + # import here to avoid a potential cyclical import + from magpie.api.management.network.network_utils import create_private_key, jwks, pem_files + try: + jwks(settings_container=settings_container) + except Exception as exc: + create_missing = asbool( + get_constant("MAGPIE_NETWORK_CREATE_MISSING_PEM_FILE", settings_container=settings_container)) + pem_files_ = pem_files(settings_container) + if isinstance(exc, FileNotFoundError) and create_missing and len(pem_files_) == 1: + LOGGER.warning("No network PEM files found") + create_private_key(pem_files_[0], settings_container=settings_container) + else: + msg = ("Error occurred when loading PEM keys which are required when network mode is enabled. " + "Check that the MAGPIE_NETWORK_PEM_FILES and MAGPIE_NETWORK_PEM_PASSWORDS are set properly. " + "Original error message: '{}'".format(exc)) + raise ConfigurationError(msg) from exc diff --git a/requirements-dev.txt b/requirements-dev.txt index b28f4250f..b4124c135 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -16,6 +16,7 @@ pylint-quotes # bird-house/twticher, must match version in Dockerfile.adapater pyramid-twitcher>=0.10.0 pytest +pytest-httpserver==1.0.10 safety tox>=3.0 webtest diff --git a/requirements.txt b/requirements.txt index 196be30e9..6276d5c65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,12 +11,17 @@ dicttoxml gunicorn>=22 humanize jsonschema>=4 +jwcrypto==1.5.6 lxml>=3.7 mako # controlled by pyramid_mako paste pastedeploy pluggy psycopg2-binary>=2.7.1 +PyJWT[crypto]==2.8.0; python_version >= "3.8" +PyJWT[crypto]==2.7.0; python_version == "3.7" # pyup: ignore +PyJWT[crypto]==2.4.0; python_version == "3.6" # pyup: ignore +PyJWT[crypto]==2.0.0a1; python_version < "3.6" # pyup: ignore pyramid>=1.10.2,<2 pyramid_beaker==0.8 pyramid_chameleon>=0.3 diff --git a/setup.cfg b/setup.cfg index 285d64e1e..4783ba04d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,11 +51,12 @@ exclude = eggs, parts, share, + magpieenv, [pylint] [bandit] -exclude = *.egg-info,build,dist,env,./tests,test_* +exclude = *.egg-info,build,dist,env,./tests,test_*,./magpieenv targets = . [tool:isort] @@ -69,6 +70,7 @@ known_first_party = magpie known_third_party = mock forced_separate = twitcher combine_as_imports = false +skip=magpieenv [coverage:run] branch = true @@ -130,3 +132,4 @@ markers = auth_admin: magpie operations that require admin-level access auth_users: magpie operations that require user-level access (non admin) auth_public: magpie operations that are publicly accessible (no auth) + network: magpie operations that are enabled in network mode \ No newline at end of file diff --git a/setup.py b/setup.py index aafb3b8e3..c90c787e2 100644 --- a/setup.py +++ b/setup.py @@ -254,6 +254,8 @@ def _extra_requirements(base_requirements, other_requirements): "magpie_run_db_migration = magpie.cli.run_db_migration:main", "magpie_send_email = magpie.cli.send_email:main", "magpie_sync_resources = magpie.cli.sync_resources:main", + "magpie_purge_expired_network_tokens = magpie.cli.purge_expired_network_tokens:main", + "magpie_create_private_key = magpie.cli.create_private_key:main" ], } ) diff --git a/tests/interfaces.py b/tests/interfaces.py index e09597095..976b53f02 100644 --- a/tests/interfaces.py +++ b/tests/interfaces.py @@ -1,5 +1,7 @@ +import datetime import html import itertools +import json import os import secrets import string @@ -9,12 +11,13 @@ from copy import deepcopy from typing import TYPE_CHECKING +import jwt import pyramid.testing import pytest import six import yaml from pyramid.interfaces import IRequestExtensions -from six.moves.urllib.parse import urlparse +from six.moves.urllib.parse import parse_qs, urlparse from webtest.app import TestApp from magpie import __meta__ @@ -48,7 +51,7 @@ CONTENT_TYPE_TXT_XML ) from tests import runner, utils -from tests.utils import TestVersion +from tests.utils import TestVersion, check_network_mode if six.PY3: # WARNING: Twitcher does not support Python 2 since 0.4.0, adapter cannot work without it @@ -58,6 +61,7 @@ # pylint: disable=W0611,unused-import from typing import Dict, List, Optional, Set, Tuple, Union + from pytest_httpserver import HTTPServer from sqlalchemy.orm.session import Session from magpie.typedefs import JSON, CookiesType, HeadersType, PermissionDict, SettingsType, Str @@ -99,6 +103,15 @@ class ConfigTestCase(object): test_admin = None # type: Optional[Str] test_user_name = None # type: Optional[Str] # also used as password when creating test user test_group_name = None # type: Optional[Str] + test_node_host = None # type: Optional[Str] + test_node_port = None # type: Optional[int] + test_node_name = None # type: Optional[Str] + test_node_jwks_url = None # type: Optional[Str] + test_node_token_url = None # type: Optional[Str] + test_authorization_url = None # type: Optional[Str] + test_redirect_uris = None # type: Optional[JSON] + test_remote_user_name = None # type: Optional[Str] + # extra parameters to indicate cleanup on final tear down # add new test values on test case startup before they *potentially* get interrupted because of error reserved_users = None # type: Optional[List[Str]] @@ -107,6 +120,10 @@ class ConfigTestCase(object): extra_group_names = set() # type: Set[Str] extra_resource_ids = set() # type: Set[int] extra_service_names = set() # type: Set[Str] + extra_node_names = set() # type: Set[Str] + extra_remote_user_names = set() # type: Set[Tuple[Str, Str]] + extra_network_tokens = set() # type: Set[Tuple[Str, Str]] + extra_remote_servers = {} # type: Dict[Tuple[Str, int], HTTPServer] @six.add_metaclass(ABCMeta) @@ -187,6 +204,27 @@ def cleanup(cls): for res in list(cls.extra_resource_ids): # copy to update removed ones utils.TestSetup.delete_TestResource(cls, res) cls.extra_resource_ids.discard(res) + for node in list(cls.extra_node_names): + check_network_mode(utils.TestSetup.delete_TestNetworkNode, + enable=True)(cls, override_name=node, allow_missing=True) + cls.extra_node_names.discard(node) + for remote_user_name, node_name in list(cls.extra_network_tokens): + check_network_mode(utils.TestSetup.delete_TestNetworkToken, + enable=True)(cls, + override_remote_user_name=remote_user_name, + override_node_name=node_name, + allow_missing=True) # should already be deleted with associated models + cls.extra_network_tokens.discard((remote_user_name, node_name)) + for remote_user_name, node_name in list(cls.extra_remote_user_names): + check_network_mode(utils.TestSetup.delete_TestNetworkRemoteUser, + enable=True)(cls, override_remote_user_name=remote_user_name, + override_node_name=node_name, + allow_missing=True) # should already be deleted with associated models + cls.extra_remote_user_names.discard((remote_user_name, node_name)) + for host_port, server in list(cls.extra_remote_servers.items()): + if server.is_running(): + server.stop() + cls.extra_remote_servers.pop(host_port) @property def update_method(self): @@ -238,6 +276,22 @@ def setup_admin(cls): override_headers=admin_headers, override_cookies=admin_cookies) utils.check_or_try_logout_user(cls) + @classmethod + def setup_network_attrs(cls): + """ + Sets initial default attributes and values for use in network tests + """ + cls.test_node_name = "node2" + cls.test_remote_user_name = "remote_user_1" + cls.test_node_host = get_constant("MAGPIE_TEST_REMOTE_NODE_SERVER_HOST", default_value="localhost", + raise_missing=False, raise_not_set=False) + cls.test_node_port = int(get_constant("MAGPIE_TEST_REMOTE_NODE_SERVER_PORT", default_value=2002, + raise_missing=False, raise_not_set=False)) + cls.test_node_jwks_url = "http://{}:{}/network/jwks".format(cls.test_node_host, cls.test_node_port) + cls.test_node_token_url = "http://{}:{}/network/token".format(cls.test_node_host, cls.test_node_port) + cls.test_authorization_url = "http://{}:{}/ui/network/authorize".format(cls.test_node_host, cls.test_node_port) + cls.test_redirect_uris = "[\"http://{}:{}/network/link\"]".format(cls.test_node_host, cls.test_node_port) + @classmethod def login_admin(cls): """ @@ -474,6 +528,377 @@ def test_LoginAnonymous_Forbidden(self): resp = utils.test_request(self, "POST", s.SigninAPI.path, json=data, expect_errors=True) utils.check_response_basic_info(resp, 403, expected_method="POST") + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode + def test_LoginProtectedUsername_Forbidden(self): + """ + Test different login variations and ensure that users with usernames that start with + ``MAGPIE_NETWORK_NAME_PREFIX`` are blocked in network mode. + + .. versionadded:: 3.38 + """ + + utils.warn_version(self, "Login protected usernames explicitly blocked.", "3.38.0", skip=True) + protected_username = "{}{!s}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), uuid.uuid4()) + data = { + "user_name": protected_username, + "password": get_constant("MAGPIE_ANONYMOUS_PASSWORD") + } + headers = {"Accept": CONTENT_TYPE_JSON} + + resp = utils.test_request(self, "GET", s.SigninAPI.path, params=data, headers=headers, expect_errors=True) + utils.check_response_basic_info(resp, 403, expected_method="GET") + + form = "user_name={user_name}&password={password}".format(**data) + headers["Content-Type"] = CONTENT_TYPE_FORM + resp = utils.test_request(self, "POST", s.SigninAPI.path, data=form, expect_errors=True) + utils.check_response_basic_info(resp, 403, expected_method="POST") + + headers["Content-Type"] = CONTENT_TYPE_JSON + resp = utils.test_request(self, "POST", s.SigninAPI.path, json=data, expect_errors=True) + utils.check_response_basic_info(resp, 403, expected_method="POST") + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode + def test_LoginProtectedEmail_Forbidden(self): + """ + Test different login variations and ensure that users with emails that start with + ``MAGPIE_NETWORK_NAME_PREFIX`` are blocked in network mode. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login protected emails explicitly blocked.", "3.38.0", skip=True) + protected_email = get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT").format(uuid.uuid4()) + data = { + "user_name": protected_email, + "password": get_constant("MAGPIE_ANONYMOUS_PASSWORD") + } + headers = {"Accept": CONTENT_TYPE_JSON} + + resp = utils.test_request(self, "GET", s.SigninAPI.path, params=data, headers=headers, expect_errors=True) + utils.check_response_basic_info(resp, 403, expected_method="GET") + + form = "user_name={user_name}&password={password}".format(**data) + headers["Content-Type"] = CONTENT_TYPE_FORM + resp = utils.test_request(self, "POST", s.SigninAPI.path, data=form, expect_errors=True) + utils.check_response_basic_info(resp, 403, expected_method="POST") + + headers["Content-Type"] = CONTENT_TYPE_JSON + resp = utils.test_request(self, "POST", s.SigninAPI.path, json=data, expect_errors=True) + utils.check_response_basic_info(resp, 403, expected_method="POST") + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode(enable=False) + def test_LoginProtectedUsername_Allowed(self): + """ + Test different login variations and ensure that users with usernames that start with + ``MAGPIE_NETWORK_NAME_PREFIX`` are allowed if network mode is not enabled. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login protected usernames explicitly blocked.", "3.38.0", skip=True) + protected_username = "{}{!s}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), uuid.uuid4()) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_user_name=protected_username, override_cookies=self.cookies) + + data = { + "user_name": protected_username, + "password": protected_username + } + headers = {"Accept": CONTENT_TYPE_JSON} + + resp = utils.test_request(self, "GET", s.SigninAPI.path, params=data, headers=headers, expect_errors=True) + utils.check_response_basic_info(resp, 200, expected_method="GET") + + form = "user_name={user_name}&password={password}".format(**data) + headers["Content-Type"] = CONTENT_TYPE_FORM + resp = utils.test_request(self, "POST", s.SigninAPI.path, data=form, expect_errors=True) + utils.check_response_basic_info(resp, 200, expected_method="POST") + + headers["Content-Type"] = CONTENT_TYPE_JSON + resp = utils.test_request(self, "POST", s.SigninAPI.path, json=data, expect_errors=True) + utils.check_response_basic_info(resp, 200, expected_method="POST") + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode(enable=False) + def test_LoginProtectedEmail_Allowed(self): + """ + Test different login variations and ensure that users with emails that start with + ``MAGPIE_NETWORK_NAME_PREFIX`` are allowed if network mode is not enabled. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login protected emails explicitly blocked.", "3.38.0", skip=True) + protected_email = get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT").format(uuid.uuid4()) + user_name = "{!s}".format(uuid.uuid4()) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_user_name=user_name, + override_email=protected_email, + override_cookies=self.cookies) + data = { + "user_name": protected_email, + "password": user_name + } + headers = {"Accept": CONTENT_TYPE_JSON} + + resp = utils.test_request(self, "GET", s.SigninAPI.path, params=data, headers=headers, expect_errors=True) + utils.check_response_basic_info(resp, 200, expected_method="GET") + + form = "user_name={user_name}&password={password}".format(**data) + headers["Content-Type"] = CONTENT_TYPE_FORM + resp = utils.test_request(self, "POST", s.SigninAPI.path, data=form, expect_errors=True) + utils.check_response_basic_info(resp, 200, expected_method="POST") + + headers["Content-Type"] = CONTENT_TYPE_JSON + resp = utils.test_request(self, "POST", s.SigninAPI.path, json=data, expect_errors=True) + utils.check_response_basic_info(resp, 200, expected_method="POST") + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode + def test_Login_NetworkToken_Authorized(self): + """ + Test logging in with a network token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login with a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + token = utils.TestSetup.create_TestNetworkToken(self) + + headers = {"Accept": CONTENT_TYPE_JSON, "Authorization": "Bearer {}".format(token["token"])} + path = s.ProviderSigninAPI.path.format(provider_name=get_constant("MAGPIE_NETWORK_PROVIDER")) + + resp = utils.test_request(self, "GET", path, headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, 302, expected_method="GET") + + app_or_url = utils.get_app_or_url(self) + if isinstance(app_or_url, TestApp): + resp_cookies = app_or_url.cookies + else: + resp_cookies = resp.cookies + resp = utils.test_request(self, "GET", "/session", headers=self.json_headers, cookies=resp_cookies) + session_json = utils.get_json_body(resp) + utils.check_val_true(session_json.get("authenticated")) + utils.check_val_equal(session_json.get("user", {}).get("user_name"), self.test_user_name) + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode + def test_Login_NetworkToken_Authorized_Anonymous(self): + """ + Test logging in with a network token associated with an anonymous network user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login with a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, + override_data={"node_name": self.test_node_name, + "remote_user_name": self.test_remote_user_name}) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + token = utils.TestSetup.create_TestNetworkToken(self) + + headers = {"Accept": CONTENT_TYPE_JSON, "Authorization": "Bearer {}".format(token["token"])} + path = s.ProviderSigninAPI.path.format(provider_name=get_constant("MAGPIE_NETWORK_PROVIDER")) + + resp = utils.test_request(self, "GET", path, headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, 302, expected_method="GET") + + app_or_url = utils.get_app_or_url(self) + if isinstance(app_or_url, TestApp): + resp_cookies = app_or_url.cookies + else: + resp_cookies = resp.cookies + resp = utils.test_request(self, "GET", "/session", headers=self.json_headers, cookies=resp_cookies) + session_json = utils.get_json_body(resp) + utils.check_val_true(session_json.get("authenticated")) + anonymous_username = "{}{}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), self.test_node_name) + utils.check_val_equal(session_json.get("user", {}).get("user_name"), anonymous_username) + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode + def test_Login_NetworkToken_Authorized_Refreshed(self): + """ + Test logging in with a refreshed network token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login with a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + initial_token = utils.TestSetup.create_TestNetworkToken(self) + token = utils.TestSetup.create_TestNetworkToken(self) + + utils.check_val_not_equal(token, initial_token) + + headers = {"Accept": CONTENT_TYPE_JSON, "Authorization": "Bearer {}".format(token["token"])} + path = s.ProviderSigninAPI.path.format(provider_name=get_constant("MAGPIE_NETWORK_PROVIDER")) + + resp = utils.test_request(self, "GET", path, headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, 302, expected_method="GET") + + app_or_url = utils.get_app_or_url(self) + if isinstance(app_or_url, TestApp): + resp_cookies = app_or_url.cookies + else: + resp_cookies = resp.cookies + resp = utils.test_request(self, "GET", "/session", headers=self.json_headers, cookies=resp_cookies) + session_json = utils.get_json_body(resp) + utils.check_val_true(bool(session_json.get("authenticated"))) + utils.check_val_equal(session_json.get("user", {}).get("user_name"), self.test_user_name) + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode + def test_Login_NetworkToken_Unauthorized_BadFormat(self): + """ + Test logging in with an incorrectly formatted network token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login with a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + + headers = {"Accept": CONTENT_TYPE_JSON, "Authorization": "Bearer {}".format("abc123")} + path = s.ProviderSigninAPI.path.format(provider_name=get_constant("MAGPIE_NETWORK_PROVIDER")) + + resp = utils.test_request(self, "GET", path, headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, 401, expected_method="GET") + + app_or_url = utils.get_app_or_url(self) + if isinstance(app_or_url, TestApp): + resp_cookies = app_or_url.cookies + else: + resp_cookies = resp.cookies + resp = utils.test_request(self, "GET", "/session", headers=self.json_headers, cookies=resp_cookies) + session_json = utils.get_json_body(resp) + utils.check_val_false(session_json.get("authenticated")) + utils.check_val_equal(session_json.get("user", {}).get("user_name"), None) + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode + def test_Login_NetworkToken_Unauthorized_Deleted(self): + """ + Test logging in with a deleted network token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login with a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + token = utils.TestSetup.create_TestNetworkToken(self) + utils.TestSetup.delete_TestNetworkToken(self) + + headers = {"Accept": CONTENT_TYPE_JSON, "Authorization": "Bearer {}".format(token["token"])} + path = s.ProviderSigninAPI.path.format(provider_name=get_constant("MAGPIE_NETWORK_PROVIDER")) + + resp = utils.test_request(self, "GET", path, headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, 401, expected_method="GET") + + app_or_url = utils.get_app_or_url(self) + if isinstance(app_or_url, TestApp): + resp_cookies = app_or_url.cookies + else: + resp_cookies = resp.cookies + resp = utils.test_request(self, "GET", "/session", headers=self.json_headers, cookies=resp_cookies) + session_json = utils.get_json_body(resp) + utils.check_val_false(session_json.get("authenticated")) + utils.check_val_equal(session_json.get("user", {}).get("user_name"), None) + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode(enable=False) + def test_Login_NetworkToken_Unauthorized_NetworkNotEnabled(self): + """ + Test logging in with a network token when network mode is not enabled. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login with a network token", "3.38.0", skip=True) + + headers = {"Accept": CONTENT_TYPE_JSON, "Authorization": "Bearer {}".format(str(uuid.uuid4()))} + path = s.ProviderSigninAPI.path.format(provider_name=get_constant("MAGPIE_NETWORK_PROVIDER")) + + resp = utils.test_request(self, "GET", path, headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, 404, expected_method="GET") # provider is not found + + app_or_url = utils.get_app_or_url(self) + if isinstance(app_or_url, TestApp): + resp_cookies = app_or_url.cookies + else: + resp_cookies = resp.cookies + resp = utils.test_request(self, "GET", "/session", headers=self.json_headers, cookies=resp_cookies) + session_json = utils.get_json_body(resp) + utils.check_val_false(session_json.get("authenticated")) + utils.check_val_equal(session_json.get("user", {}).get("user_name"), None) + + @runner.MAGPIE_TEST_NETWORK + @runner.MAGPIE_TEST_LOGIN + @utils.check_network_mode + def test_Login_NetworkToken_Unauthorized_BadToken(self): + """ + Test logging in with a correctly formatted but invalid network token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Login with a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + + headers = {"Accept": CONTENT_TYPE_JSON, "Authorization": "Bearer {}".format(str(uuid.uuid4()))} + path = s.ProviderSigninAPI.path.format(provider_name=get_constant("MAGPIE_NETWORK_PROVIDER")) + + resp = utils.test_request(self, "GET", path, headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, 401, expected_method="GET") + + app_or_url = utils.get_app_or_url(self) + if isinstance(app_or_url, TestApp): + resp_cookies = app_or_url.cookies + else: + resp_cookies = resp.cookies + resp = utils.test_request(self, "GET", "/session", headers=self.json_headers, cookies=resp_cookies) + session_json = utils.get_json_body(resp) + utils.check_val_false(session_json.get("authenticated")) + utils.check_val_equal(session_json.get("user", {}).get("user_name"), None) + @runner.MAGPIE_TEST_LOGIN def test_Login_GetRequestFormat(self): """ @@ -672,6 +1097,7 @@ def test_GetUser_ReservedKeyword_Anonymous_Allowed(self): body = utils.check_response_basic_info(resp) info = utils.TestSetup.get_UserInfo(self, override_body=body) utils.check_val_equal(info["user_name"], anonymous) + utils.check_val_not_in("user_id", info) @runner.MAGPIE_TEST_USERS @runner.MAGPIE_TEST_LOGGED @@ -700,6 +1126,7 @@ def test_GetUser_ReservedKeyword_LoggedUser_Allowed(self): body = utils.check_response_basic_info(resp) info = utils.TestSetup.get_UserInfo(self, override_body=body) utils.check_val_equal(info["user_name"], anonymous) + utils.check_val_not_in("user_id", info) @runner.MAGPIE_TEST_USERS @runner.MAGPIE_TEST_STATUS @@ -828,6 +1255,214 @@ def test_DeleteUser_OtherUser_Unauthorized(self): self.headers, self.cookies = utils.check_or_try_login_user(self, username=self.usr, password=self.pwd) utils.TestSetup.get_UserInfo(self, override_username=self.test_user_name) # if found, no deletion was applied + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkToken(self): + """ + Test get a network token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Acquire a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + token = utils.TestSetup.create_TestNetworkToken(self) + utils.check_val_true(bool(token.get("token"))) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkToken_Create_AnonymousUser(self): + """ + Test creating network token for an anonymous user and check that the anonymous remote user is created as well. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + utils.TestSetup.create_TestNetworkToken(self) + + self.login_admin() + resp = utils.test_request(self, "GET", "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + headers=self.headers, cookies=type(self).cookies) + json_body = utils.get_json_body(resp) + anon_user_name = "{}{}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), self.test_node_name) + utils.check_val_equal(json_body.get("user_name"), anon_user_name) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkToken_InvalidNode(self): + """ + Test do not get a network token if the associated node doesn't exist. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Acquire a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + token = utils.TestSetup.create_TestNetworkToken(self, override_node_name="some_other_node", expect_errors=True) + utils.check_val_equal(token.get("token"), None) + utils.check_val_equal(token.get("code"), 404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkToken_InvalidNodeCredentials(self): + """ + Test do not get a network token if the associated node exists but has invalid/missing + JSON web keys. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Acquire a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_jwks_url="http://example.com/jwks") + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + token = utils.TestSetup.create_TestNetworkToken(self, expect_errors=True) + utils.check_val_equal(token.get("token"), None) + utils.check_val_equal(token.get("code"), 500) + utils.check_val_equal(token.get("call", {}).get("exception"), "PyJWKClientConnectionError") + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkToken(self): + """ + Test delete a network token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + utils.TestSetup.create_TestNetworkToken(self) + utils.TestSetup.delete_TestNetworkToken(self) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkToken_NotRemoteUser(self): + """ + Test delete a network token but not the associated NetworkRemoteUser. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + utils.TestSetup.create_TestNetworkToken(self) + utils.TestSetup.delete_TestNetworkToken(self) + + resp = utils.test_request(self, "GET", "/network/remote_users", cookies=self.cookies, headers=self.headers) + json_body = utils.get_json_body(resp) + utils.check_val_equal(json_body.get("remote_users", [{}])[0].get("remote_user_name"), + self.test_remote_user_name) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkToken_And_AnonymousUser(self): + """ + Test delete a network token for an anonymous user and check that the anonymous remote user is deleted as well. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkToken(self, override_cookies={}, override_headers={}) + utils.TestSetup.delete_TestNetworkToken(self, override_cookies={}, override_headers={}) + + resp = utils.test_request(self, "GET", "/network/remote_users", cookies=self.cookies, headers=self.headers) + json_body = utils.get_json_body(resp) + utils.check_val_false(bool(json_body["remote_users"])) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkToken_InvalidNode(self): + """ + Test do not delete a network token if the node doesn't match. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + utils.TestSetup.create_TestNetworkToken(self) + resp = utils.TestSetup.delete_TestNetworkToken(self, override_node_name="some_other_node", allow_missing=True) + utils.check_response_basic_info(resp, expected_code=404, expected_method="DELETE") + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkToken_InvalidRemoteUser(self): + """ + Test do not delete a network token if the node doesn't match. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network token", "3.38.0", skip=True) + self.login_admin() + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.check_or_try_logout_user(self) + self.cookies = self.headers = None + utils.TestSetup.create_TestNetworkToken(self) + resp = utils.TestSetup.delete_TestNetworkToken(self, override_remote_user_name="some_other_user", + allow_missing=True) + utils.check_response_basic_info(resp, expected_code=404, expected_method="DELETE") + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetJSONWebKeySet(self): + """ + Test get a valid JSON Web Key Set. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Get a valid JSON Web Key Set", "3.38.0", skip=True) + resp = utils.test_request(self, "GET", "/network/jwks") + utils.check_response_basic_info(resp) + json_body = utils.get_json_body(resp) + utils.check_val_true(bool(json_body.get("keys"))) + for key in json_body["keys"]: + utils.check_val_equal(key.get("kty"), "RSA") + utils.check_val_true(bool(key.get("kid"))) + utils.check_val_true(bool(key.get("n"))) + utils.check_val_true(bool(key.get("e"))) + @runner.MAGPIE_TEST_API @six.add_metaclass(ABCMeta) @@ -1738,13 +2373,354 @@ def test_DeleteDiscoverableGroup_Forbidden(self): resp = utils.test_request(self, "GET", path, cookies=self.cookies, headers=self.headers) utils.check_response_basic_info(resp, 200) + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkNodes_NameOnly(self): + """ + Test user can view network node names. -@runner.MAGPIE_TEST_API -@six.add_metaclass(ABCMeta) -class Interface_MagpieAPI_AdminAuth(AdminTestCase, BaseTestCase): - # pylint: disable=C0103,invalid-name - """ - Interface class for unittests of Magpie API. Test any operation that require at least 'administrator' group + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network node names", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test123") + headers, cookies = self.login_test_user() + resp = utils.test_request(self, "GET", "/network/nodes", cookies=cookies, headers=headers) + + utils.check_response_basic_info(resp) + json_body = utils.get_json_body(resp) + node_info = json_body.get("nodes", [{}])[0] + utils.check_val_equal(node_info, {"name": "test123"}) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkNodeToken(self): + """ + Test can get a token from another node in the network for the current user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Get token from another node", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + node_server = utils.TestSetup.remote_node(self) + token = str(uuid.uuid4()) + path = urlparse(self.test_node_token_url).path + node_server.expect_request(path, method="POST").respond_with_json({"token": token}) + headers, cookies = self.login_test_user() + resp = utils.test_request(self, "GET", "/network/nodes/{}/token".format(self.test_node_name), + cookies=cookies, headers=headers) + utils.check_response_basic_info(resp) + json_body = utils.get_json_body(resp) + utils.check_val_equal(json_body.get("token"), token) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkNodeToken(self): + """ + Test can delete a token from another node in the network for the current user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete token on another node", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + node_server = utils.TestSetup.remote_node(self) + path = urlparse(self.test_node_token_url).path + node_server.expect_request(path, method="DELETE").respond_with_json({}) + headers, cookies = self.login_test_user() + resp = utils.test_request(self, "DELETE", "/network/nodes/{}/token".format(self.test_node_name), + cookies=cookies, headers=headers) + utils.check_response_basic_info(resp, expected_method="DELETE") + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkLink(self): + """ + Test can create a NetworkRemoteUser associated with another node for the current user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Link current user with a user on another node", "3.38.0", skip=True) + + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + claims = {"user_name": "test123", "requesting_user_name": self.test_user_name} + headers, cookies = self.login_test_user() + with utils.TestSetup.valid_jwt(self, override_jwt_claims=claims) as token: + resp = utils.test_request(self, "GET", "/network/link?token={}".format(token), cookies=cookies, + headers=headers) + utils.check_response_basic_info(resp) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/test123".format(self.test_node_name), + cookies=self.cookies, headers=self.headers) + utils.check_response_basic_info(resp) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkLink_DifferentUser(self): + """ + Test cannot create a NetworkRemoteUser associated with another node for a user other than the current user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Link current user with a user on another node", "3.38.0", skip=True) + + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + claims = {"user_name": "test123", "requesting_user_name": "some_other_user"} + headers, cookies = self.login_test_user() + with utils.TestSetup.valid_jwt(self, override_jwt_claims=claims) as token: + resp = utils.test_request(self, "GET", "/network/link?token={}".format(token), cookies=cookies, + headers=headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=403) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/test123".format(self.test_node_name), + cookies=self.cookies, headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkLink_MissingClaim_user_name(self): + """ + Test cannot create a NetworkRemoteUser associated with another node if the user_name claim is missing. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Link current user with a user on another node", "3.38.0", skip=True) + + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + claims = {"requesting_user_name": self.test_user_name} + headers, cookies = self.login_test_user() + with utils.TestSetup.valid_jwt(self, override_jwt_claims=claims) as token: + resp = utils.test_request(self, "GET", "/network/link?token={}".format(token), cookies=cookies, + headers=headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=400) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/test123".format(self.test_node_name), + cookies=self.cookies, headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkLink_MissingClaim_requesting_user_name(self): + """ + Test cannot create a NetworkRemoteUser associated with another node if the requesting_user_name claim is + missing. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Link current user with a user on another node", "3.38.0", skip=True) + + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + claims = {"user_name": "test123"} + headers, cookies = self.login_test_user() + with utils.TestSetup.valid_jwt(self, override_jwt_claims=claims) as token: + resp = utils.test_request(self, "GET", "/network/link?token={}".format(token), cookies=cookies, + headers=headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=400) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/test123".format(self.test_node_name), + cookies=self.cookies, headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkLink_InvalidIssuer(self): + """ + Test cannot create a NetworkRemoteUser associated with another node if the issuer claim is for a different node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Link current user with a user on another node", "3.38.0", skip=True) + + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="some_other_name") + claims = {"user_name": "test123", "requesting_user_name": "some_other_user"} + headers, cookies = self.login_test_user() + with utils.TestSetup.valid_jwt(self, override_jwt_claims=claims) as token: + resp = utils.test_request(self, "GET", "/network/link?token={}".format(token), cookies=cookies, + headers=headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/test123".format(self.test_node_name), + cookies=self.cookies, headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkLink(self): + """ + Test redirect to authorization url of the requested node with an appropriate request token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Request a link with a remote node for the current user", "3.38.0", skip=True) + + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + headers, cookies = self.login_test_user() + resp = utils.test_request(self, "POST", "/network/nodes/{}/link".format(self.test_node_name), cookies=cookies, + headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, expected_method="POST", expected_code=302) + redirect_uri = resp.headers.get("Location") + utils.check_val_true(bool(redirect_uri)) + + parsed_url = urlparse(redirect_uri) + utils.check_val_equal(parsed_url.hostname, self.test_node_host) + utils.check_val_equal(parsed_url.port, self.test_node_port) + utils.check_val_equal(parsed_url.path, "/ui/network/authorize") + + query = parse_qs(parsed_url.query) + utils.check_val_equal(query.get("response_type", [None])[0], "id_token") + utils.check_val_is_in(urlparse(query.get("redirect_uri", [None])[0]).path, + ["/network/link", "/magpie/network/link"]) + + token = query.get("token", [None])[0] + utils.check_val_true(bool(token)) + + jwt_claims = jwt.decode(token, options={"verify_signature": False}) + jwt_claims.pop("exp") + utils.check_val_equal(jwt_claims, + {"user_name": self.test_user_name, + "iss": get_constant("MAGPIE_NETWORK_INSTANCE_NAME"), + "aud": self.test_node_name}) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkLink_DifferentNode(self): + """ + Test redirect to authorization url of the requested node if the node doesn't exist. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Request a link with a remote node for the current user", "3.38.0", skip=True) + + utils.TestSetup.create_TestUser(self, override_exist=True) + headers, cookies = self.login_test_user() + resp = utils.test_request(self, "POST", "/network/nodes/{}/link".format(self.test_node_name), cookies=cookies, + headers=headers, expect_errors=True, allow_redirects=False) + utils.check_response_basic_info(resp, expected_method="POST", expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkRemoteUser(self): + """ + Test get remote user information for currently logged-in user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Get remote user information", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + headers, cookies = self.login_test_user() + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + cookies=cookies, + headers=headers) + json_body = utils.check_response_basic_info(resp) + expected = {"user_name": self.test_user_name, + "remote_user_name": self.test_remote_user_name, + "node_name": self.test_node_name} + actual = {k: v for k, v in json_body.items() if k in expected} + utils.check_val_equal(actual, expected) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkRemoteUser_DifferentUser(self): + """ + Test cannot get remote user information for a user other than the currently logged-in user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Get remote user information", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True, override_user_name="test123", + override_password=self.test_user_name, override_email="test123@example.com") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, override_user_name="test123") + + headers, cookies = self.login_test_user() + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + cookies=cookies, + headers=headers, + expect_errors=True) + utils.check_response_basic_info(resp, expected_code=403) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkRemoteUser(self): + """ + Test delete a remote user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + headers, cookies = self.login_test_user() + utils.TestSetup.delete_TestNetworkRemoteUser(self, override_cookies=cookies, override_headers=headers) + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + cookies=self.cookies, + headers=self.headers, + expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkRemoteUsersCurrent(self): + """ + Test get all remote user information for currently logged-in user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Get remote user information", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + headers, cookies = self.login_test_user() + resp = utils.test_request(self, "GET", + "/network/remote_users/current", + cookies=cookies, + headers=headers) + json_body = utils.check_response_basic_info(resp) + expected = [{"user_name": self.test_user_name, + "remote_user_name": self.test_remote_user_name, + "node_name": self.test_node_name}] + utils.check_val_equal(json_body.get("remote_users"), expected) + + +@runner.MAGPIE_TEST_API +@six.add_metaclass(ABCMeta) +class Interface_MagpieAPI_AdminAuth(AdminTestCase, BaseTestCase): + # pylint: disable=C0103,invalid-name + """ + Interface class for unittests of Magpie API. Test any operation that require at least 'administrator' group AuthN/AuthZ. Derived classes must implement :meth:`setUpClass` accordingly to generate the Magpie test application. @@ -1788,6 +2764,7 @@ def setup_test_values(cls): cls.test_group_name = "magpie-unittest-dummy-group" cls.test_user_name = "magpie-unittest-toto" + cls.setup_network_attrs() @runner.MAGPIE_TEST_STATUS def test_unauthorized_forbidden_responses(self): @@ -7103,6 +8080,831 @@ def test_GetResourceTypes_ServiceGeoserver(self): utils.check_val_equal(body["root_service_name"], self.test_service_name) utils.check_val_equal(body["root_service_type"], ServiceGeoserver.service_type) + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkTokens(self): + """ + Test delete all network tokens. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete all network tokens", "3.38.0", skip=True) + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test1") + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, override_remote_user_name="test1", + override_node_name="test1") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test2") + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, override_remote_user_name="test2", + override_node_name="test2") + utils.TestSetup.create_TestNetworkToken(self, override_remote_user_name="test1", override_node_name="test1") + utils.TestSetup.create_TestNetworkToken(self, override_remote_user_name="test2", override_node_name="test2") + + resp = utils.test_request(self, "DELETE", "/network/tokens", cookies=self.cookies, headers=self.headers) + utils.check_response_basic_info(resp, 200, expected_method="DELETE") + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkTokens_NotRemoteUser(self): + """ + Test deleting network tokens does not delete associated non-anonymous network users. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete all network tokens", "3.38.0", skip=True) + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test1") + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, override_remote_user_name="test1", + override_node_name="test1") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test2") + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, override_remote_user_name="test2", + override_node_name="test2") + utils.TestSetup.create_TestNetworkToken(self, override_remote_user_name="test1", override_node_name="test1") + utils.TestSetup.create_TestNetworkToken(self, override_remote_user_name="test2", override_node_name="test2") + + utils.test_request(self, "DELETE", "/network/tokens", cookies=self.cookies, headers=self.headers) + + resp = utils.test_request(self, "GET", "/network/remote_users", cookies=self.cookies, headers=self.headers) + json_body = utils.get_json_body(resp) + utils.check_val_equal({u.get("remote_user_name") for u in json_body.get("remote_users", [{}])}, + {"test1", "test2"}) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkTokens_AndAnonymousNetworkUsers(self): + """ + Test deleting network tokens does delete associated anonymous network users. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete all network tokens", "3.38.0", skip=True) + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test1") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test2") + utils.TestSetup.create_TestNetworkToken(self, override_remote_user_name="test1", override_node_name="test1") + utils.TestSetup.create_TestNetworkToken(self, override_remote_user_name="test2", override_node_name="test2") + + utils.test_request(self, "DELETE", "/network/tokens", cookies=self.cookies, headers=self.headers) + + resp = utils.test_request(self, "GET", "/network/remote_users", cookies=self.cookies, headers=self.headers) + json_body = utils.get_json_body(resp) + utils.check_val_false(bool(json_body["remote_users"])) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetDecodeJWT(self): + """ + Test decode a JSON web token. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Decode a JSON web token", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + claims = {"test": 123, "test2": "another value"} + with utils.TestSetup.valid_jwt(self, override_jwt_claims=claims) as token: + resp = utils.test_request(self, "GET", "/network/decode_jwt?token={}".format(token), cookies=self.cookies, + headers=self.headers) + utils.check_response_basic_info(resp) + json_body = utils.get_json_body(resp) + utils.check_val_true(bool(json_body.get("jwt_content"))) + utils.check_val_equal(json_body["jwt_content"].get("iss"), self.test_node_name) + utils.check_val_equal(json_body["jwt_content"].get("aud"), get_constant("MAGPIE_NETWORK_INSTANCE_NAME")) + for key, val in claims.items(): + utils.check_val_equal(json_body["jwt_content"].get(key), val) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetDecodeJWT_NoToken(self): + """ + Test that decoding an empty JSON web token returns an error. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Decode a JSON web token", "3.38.0", skip=True) + + resp = utils.test_request(self, "GET", "/network/decode_jwt", cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=400) + json_body = utils.get_json_body(resp) + utils.check_val_is_in("Missing token", json_body.get("detail", "")) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetDecodeJWT_BadToken(self): + """ + Test that decoding an improperly formatted JSON web token returns an error. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Decode a JSON web token", "3.38.0", skip=True) + + resp = utils.test_request(self, "GET", "/network/decode_jwt?token=abc123", cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=400) + json_body = utils.get_json_body(resp) + utils.check_val_is_in("Token is improperly formatted", json_body.get("detail", "")) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetDecodeJWT_BadIssuer(self): + """ + Test that decoding a JSON web token with an issuer that doesn't exist as a node returns an error. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Decode a JSON web token", "3.38.0", skip=True) + + with utils.TestSetup.valid_jwt(self) as token: + resp = utils.test_request(self, "GET", "/network/decode_jwt?token={}".format(token), cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=400) + json_body = utils.get_json_body(resp) + utils.check_val_is_in("invalid or missing issuer claim", json_body.get("detail", "")) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetDecodeJWT_Expired(self): + """ + Test raise error when decoding a JSON web token that has expired. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Decode a JSON web token", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + expiry = datetime.datetime.utcnow() - datetime.timedelta(days=365) + with utils.TestSetup.valid_jwt(self, override_jwt_expiry=expiry) as token: + resp = utils.test_request(self, "GET", "/network/decode_jwt?token={}".format(token), + cookies=self.cookies, headers=self.headers, expect_errors=True) + json_info = utils.check_response_basic_info(resp, expected_code=400) + utils.check_val_equal(json_info.get("call", {}).get("exception"), "ExpiredSignatureError") + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkNodes_AllInfo(self): + """ + Test admin can view full information of all network nodes. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network node information", "3.38.0", skip=True) + + expected_node_info = { + "name": "test123", + "jwks_url": "http://test1.example.com/jwks", + "token_url": "http://test1.example.com/token", + "authorization_url": "http://test1.example.com/authorization", + "redirect_uris": ["http://uri.test.some.example.com"] + } + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_data=expected_node_info) + resp = utils.test_request(self, "GET", "/network/nodes", cookies=self.cookies, headers=self.headers) + + utils.check_response_basic_info(resp) + json_body = utils.get_json_body(resp) + node_info = json_body.get("nodes", [{}])[0] + utils.check_val_equal({k: v for k, v in node_info.items() if k in expected_node_info}, expected_node_info) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkNode_AllInfo(self): + """ + Test admin can view full information of a network node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network node information", "3.38.0", skip=True) + + expected_node_info = { + "name": "test123", + "jwks_url": "http://test1.example.com/jwks", + "token_url": "http://test1.example.com/token", + "authorization_url": "http://test1.example.com/authorization", + "redirect_uris": ["http://uri.test.some.example.com"] + } + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_data=expected_node_info) + resp = utils.test_request(self, "GET", "/network/nodes/test123", cookies=self.cookies, headers=self.headers) + + utils.check_response_basic_info(resp) + json_body = utils.get_json_body(resp) + utils.check_val_equal({k: v for k, v in json_body.items() if k in expected_node_info}, expected_node_info) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkNode_NotFound(self): + """ + Test non-existant node returns 404 error. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Can't find non-existant node", "3.38.0", skip=True) + + resp = utils.test_request(self, "GET", "/network/nodes/test123", cookies=self.cookies, headers=self.headers, + expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkNode(self): + """ + Test create a new network node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a network node", "3.38.0", skip=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkNode_CreateAssociatedRecords(self): + """ + Test create a new network node and check that the associated Group and anonymous User are created. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a network node", "3.38.0", skip=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + + anonymous_name = "{}{}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), self.test_node_name) + + resp = utils.test_request(self, "GET", "/users/{}".format(anonymous_name), cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp) + + resp = utils.test_request(self, "GET", "/groups/{}".format(anonymous_name), cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkNode_MissingParamName(self): + """ + Test cannot create a new network node without required parameters. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a network node without required parameters", "3.38.0", skip=True) + node_info = { + "name": "test1", + "jwks_url": "http://test1.example.com/jwks", + "token_url": "http://test1.example.com/token", + "authorization_url": "http://test1.example.com/authorization", + "redirect_uris": ["http://uri.test.some.example.com"] + } + + for param in node_info: + json_body = utils.TestSetup.create_TestNetworkNode(self, override_exist=True, + override_data={**node_info, param: None}, + expect_errors=True) + utils.check_val_equal(json_body.get("code"), 201 if param == "redirect_uris" else 400) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkNode_InvalidParam(self): + """ + Test cannot create a new network node with invalid params. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a network node with invalid parameters", "3.38.0", skip=True) + node_info = { + "name": "test1", + "jwks_url": "http://test1.example.com/jwks", + "token_url": "http://test1.example.com/token", + "authorization_url": "http://test1.example.com/authorization", + "redirect_uris": ["http://uri.test.some.example.com"] + } + + for param in node_info: + json_body = utils.TestSetup.create_TestNetworkNode(self, override_exist=True, + override_data={**node_info, param: ""}, + expect_errors=True) + utils.check_val_equal(json_body.get("code"), 400) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PatchNetworkNode(self): + """ + Test can update attributes of a network node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Update a network node", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + node_info = { + "name": "test123", + "jwks_url": "http://test1.example.com/jwks", + "token_url": "http://test1.example.com/token", + "authorization_url": "http://test1.example.com/authorization", + "redirect_uris": ["http://uri.test.some.example.com"] + } + resp = utils.test_request(self, "PATCH", "/network/nodes/{}".format(self.test_node_name), cookies=self.cookies, + headers=self.headers, data=node_info) + utils.check_response_basic_info(resp, expected_method="PATCH") + self.extra_node_names.add("test123") # indicate potential removal at a later point + + resp = utils.test_request(self, "GET", "/network/nodes/test123", cookies=self.cookies, headers=self.headers) + json_body = utils.check_response_basic_info(resp) + + utils.check_val_equal({k: v for k, v in json_body.items() if k in node_info}, node_info) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PatchNetworkNode_CreateAssociatedRecords(self): + """ + Test can update attributes of a network node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Update a network node", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + resp = utils.test_request(self, "PATCH", "/network/nodes/{}".format(self.test_node_name), cookies=self.cookies, + headers=self.headers, data={"name": "test123"}) + utils.check_response_basic_info(resp, expected_method="PATCH") + self.extra_node_names.add("test123") # indicate potential removal at a later point + + anonymous_name = "{}test123".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX")) + + resp = utils.test_request(self, "GET", "/users/{}".format(anonymous_name), cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp) + + resp = utils.test_request(self, "GET", "/groups/{}".format(anonymous_name), cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PatchNetworkNode_DuplicateName(self): + """ + Test cannot update attributes of a network node if another node exists with the same name. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Update a network node", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test123") + node_info = { + "name": "test123", + "jwks_url": "http://test1.example.com/jwks", + "token_url": "http://test1.example.com/token", + "authorization_url": "http://test1.example.com/authorization", + "redirect_uris": ["http://uri.test.some.example.com"] + } + resp = utils.test_request(self, "PATCH", "/network/nodes/{}".format(self.test_node_name), cookies=self.cookies, + headers=self.headers, data=node_info, expect_errors=True) + utils.check_response_basic_info(resp, expected_method="PATCH", expected_code=409) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PatchNetworkNode_InvalidName(self): + """ + Test cannot update attributes of a network node if the new name is invalid. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Update a network node", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + node_info = { + "name": "test123", + "jwks_url": "http://test1.example.com/jwks", + "token_url": "http://test1.example.com/token", + "authorization_url": "http://test1.example.com/authorization", + "redirect_uris": ["http://uri.test.some.example.com"] + } + + for param in node_info: + resp = utils.test_request(self, "PATCH", "/network/nodes/{}".format(self.test_node_name), + cookies=self.cookies, headers=self.headers, + data={**node_info, param: ""}, expect_errors=True) + utils.check_response_basic_info(resp, expected_method="PATCH", expected_code=400) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkNode(self): + """ + Test can delete a network node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network node", "3.38.0", skip=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.delete_TestNetworkNode(self) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkNode_BadName(self): + """ + Test cannot delete a network node if the node doesn't exist. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network node", "3.38.0", skip=True) + resp = utils.TestSetup.delete_TestNetworkNode(self, allow_missing=True) + utils.check_response_basic_info(resp, expected_method="DELETE", expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkNode_RemoveAssociatedRecords(self): + """ + Test can delete a network node and the associated User and Group records are removed as well. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete a network node", "3.38.0", skip=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.delete_TestNetworkNode(self) + + anonymous_name = "{}{}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), self.test_node_name) + + resp = utils.test_request(self, "GET", "/users/{}".format(anonymous_name), cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + resp = utils.test_request(self, "GET", "/groups/{}".format(anonymous_name), cookies=self.cookies, + headers=self.headers, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkRemoteUsers(self): + """ + Test get all remote user information. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Get remote user information", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + resp = utils.test_request(self, "GET", "/network/remote_users", cookies=self.cookies, + headers=self.headers) + json_body = utils.check_response_basic_info(resp) + utils.check_val_equal(json_body.get("remote_users", [{}]), [{"user_name": self.test_user_name, + "remote_user_name": self.test_remote_user_name, + "node_name": self.test_node_name}]) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkRemoteUsers_OneUser(self): + """ + Test get remote user information for a single user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Get remote user information", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True, override_user_name="test123", + override_password=self.test_user_name, override_email="test123@example.com") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, override_remote_user_name="test123", + override_user_name="test123") + + resp = utils.test_request(self, "GET", "/network/remote_users?user_name={}".format(self.test_user_name), + cookies=self.cookies, headers=self.headers) + json_body = utils.check_response_basic_info(resp) + utils.check_val_equal(json_body.get("remote_users", [{}]), [{"user_name": self.test_user_name, + "remote_user_name": self.test_remote_user_name, + "node_name": self.test_node_name}]) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetNetworkRemoteUser(self): + """ + Test get remote user information for a single user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Get remote user information", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + cookies=self.cookies, + headers=self.headers) + json_body = utils.check_response_basic_info(resp) + expected = {"user_name": self.test_user_name, + "remote_user_name": self.test_remote_user_name, + "node_name": self.test_node_name} + actual = {k: v for k, v in json_body.items() if k in expected} + utils.check_val_equal(actual, expected) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser(self): + """ + Test create a remote user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser_MissingRequiredParams(self): + """ + Test cannot create a remote user if required params are missing. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + data = { + "remote_user_name": self.test_remote_user_name, + "node_name": self.test_node_name + } + for key in data: + resp = utils.test_request(self, "POST", "/network/remote_users", json={**data, key: None}, + expect_errors=True, headers=self.headers, cookies=self.cookies) + utils.check_response_basic_info(resp, expected_method="POST", expected_code=400) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser_MissingNode(self): + """ + Test cannot create a remote user when associated node is not found. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + + data = { + "remote_user_name": self.test_remote_user_name, + "user_name": self.test_user_name, + "node_name": self.test_node_name + } + resp = utils.test_request(self, "POST", "/network/remote_users", json=data, + expect_errors=True, headers=self.headers, cookies=self.cookies) + utils.check_response_basic_info(resp, expected_method="POST", expected_code=404) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser_DuplicateRemoteUserName(self): + """ + Test cannot create a remote user with the same name for the same node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True, override_user_name="test123", + override_password=self.test_user_name, override_email="test123@example.com") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + data = { + "remote_user_name": self.test_remote_user_name, + "user_name": "test123", + "node_name": self.test_node_name + } + resp = utils.test_request(self, "POST", "/network/remote_users", json=data, + expect_errors=True, headers=self.headers, cookies=self.cookies) + utils.check_response_basic_info(resp, expected_method="POST", expected_code=500) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser_DuplicateUser(self): + """ + Test cannot create a remote user with the same associated user for the same node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + data = { + "remote_user_name": "some_other_name", + "user_name": self.test_user_name, + "node_name": self.test_node_name + } + resp = utils.test_request(self, "POST", "/network/remote_users", json=data, + expect_errors=True, headers=self.headers, cookies=self.cookies) + utils.check_response_basic_info(resp, expected_method="POST", expected_code=500) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser_DifferentUserAndName(self): + """ + Test create a remote user with a different name and associated user for the same node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True, override_user_name="test123", + override_password=self.test_user_name, override_email="test123@example.com") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + data = { + "remote_user_name": "test123", + "user_name": "test123", + "node_name": self.test_node_name + } + resp = utils.test_request(self, "POST", "/network/remote_users", json=data, headers=self.headers, + cookies=self.cookies) + utils.check_response_basic_info(resp, expected_code=201) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser_DifferentNodeSameName(self): + """ + Test create a remote user with the same name and associated user for a different node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test123") + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, override_node_name="test123") + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser_NoUser(self): + """ + Test create a remote user with no user specified gets associated to the node's anonymous user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + + data = { + "remote_user_name": self.test_remote_user_name, + "node_name": self.test_node_name + } + resp = utils.test_request(self, "POST", "/network/remote_users", json=data, headers=self.headers, + cookies=self.cookies, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=201, expected_method="POST") + + self.extra_remote_user_names.add((self.test_remote_user_name, self.test_node_name)) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + cookies=self.cookies, + headers=self.headers) + json_body = utils.check_response_basic_info(resp) + data["user_name"] = "{}{}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), self.test_node_name) + actual = {k: v for k, v in json_body.items() if k in data} + utils.check_val_equal(actual, data) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PostNetworkRemoteUser_MultipleAnonymous(self): + """ + Test create a remote user with no user specified gets associated to the node's anonymous user even when another + remote user is associated with the anonymous user for that node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Create a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True, + override_data={ + "remote_user_name": self.test_remote_user_name, + "node_name": self.test_node_name + }) + + data = { + "remote_user_name": "other_remote_user_name", + "node_name": self.test_node_name + } + resp = utils.test_request(self, "POST", "/network/remote_users", json=data, headers=self.headers, + cookies=self.cookies, expect_errors=True) + utils.check_response_basic_info(resp, expected_code=201, expected_method="POST") + + self.extra_remote_user_names.add((self.test_remote_user_name, self.test_node_name)) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + "other_remote_user_name"), + cookies=self.cookies, + headers=self.headers) + json_body = utils.check_response_basic_info(resp) + actual = {k: v for k, v in json_body.items() if k in data} + utils.check_val_equal(actual, data) + anonymous_username = "{}{}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), self.test_node_name) + utils.check_val_equal(json_body["user_name"], anonymous_username) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PatchNetworkRemoteUser(self): + """ + Test update a remote user. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Update a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True, override_user_name="test123_user_name", + override_password=self.test_user_name, override_email="test123@example.com") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test123_node_name") + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + data = { + "remote_user_name": "test123", + "user_name": "test123_user_name", + "node_name": "test123_node_name" + } + + resp = utils.test_request(self, "PATCH", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + json=data, + headers=self.headers, + cookies=self.cookies) + utils.check_response_basic_info(resp, expected_code=200) + self.extra_remote_user_names.add(("test123_user_name", "test123_node_name")) + + resp = utils.test_request(self, "GET", + "/network/nodes/test123_node_name/remote_users/test123", + cookies=self.cookies, + headers=self.headers) + json_body = utils.check_response_basic_info(resp) + actual = {k: v for k, v in json_body.items() if k in data} + utils.check_val_equal(actual, data) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_PatchNetworkRemoteUser_AssignAnonymous(self): + """ + Test update a remote user so that it is assigned with the anonymous user for the same node. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Update a remote user", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + resp = utils.test_request(self, "PATCH", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + json={"assign_anonymous": True}, + headers=self.headers, + cookies=self.cookies) + utils.check_response_basic_info(resp, expected_code=200) + + resp = utils.test_request(self, "GET", + "/network/nodes/{}/remote_users/{}".format(self.test_node_name, + self.test_remote_user_name), + cookies=self.cookies, + headers=self.headers) + json_body = utils.check_response_basic_info(resp) + anonymous_username = "{}{}".format(get_constant("MAGPIE_NETWORK_NAME_PREFIX"), self.test_node_name) + utils.check_val_equal(json_body.get("user_name"), anonymous_username) + @runner.MAGPIE_TEST_UI @six.add_metaclass(ABCMeta) @@ -7366,6 +9168,193 @@ def test_UserAccount_DeleteSelf(self): self.login_admin() utils.TestSetup.check_NonExistingTestUser(self, override_user_name=other_user) + @runner.MAGPIE_TEST_USERS + @runner.MAGPIE_TEST_LOGGED + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_UserAccount_NetworkInfo_WithRemoteUser(self): + """ + Test that remote user information is visible in a table + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network info.", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + + self.login_test_user() + path = "/ui/users/{}".format(get_constant("MAGPIE_LOGGED_USER")) + resp = utils.TestSetup.check_UpStatus(self, method="GET", path=path) + + nodes_table = utils.find_html_body_contents(resp, [{"class": ["content"]}, {"id": "network_node_list"}]) + utils.check_val_true(bool(nodes_table)) + + node_info = nodes_table.find_all("tr", recursive=True)[-1].find_all("td") + utils.check_val_equal(node_info[0].text, self.test_node_name) + utils.check_val_equal(node_info[1].text, self.test_remote_user_name) + utils.check_val_equal(node_info[2].find("input").attrs.get("name"), + "delete_node_link_{}".format(self.test_node_name)) + + @runner.MAGPIE_TEST_USERS + @runner.MAGPIE_TEST_LOGGED + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_UserAccount_NetworkInfo_WithoutRemoteUser(self): + """ + Test that node information is visible in a table even if there is no remote user for that node + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network info.", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + + self.login_test_user() + path = "/ui/users/{}".format(get_constant("MAGPIE_LOGGED_USER")) + resp = utils.TestSetup.check_UpStatus(self, method="GET", path=path) + + nodes_table = utils.find_html_body_contents(resp, [{"class": ["content"]}, {"id": "network_node_list"}]) + utils.check_val_true(bool(nodes_table)) + + node_info = nodes_table.find_all("tr", recursive=True)[-1].find_all("td") + utils.check_val_equal(node_info[0].text, self.test_node_name) + utils.check_val_equal(node_info[1].text.strip(), "") + utils.check_val_equal(node_info[2].find("input").attrs.get("name"), + "create_node_link_{}".format(self.test_node_name)) + + @runner.MAGPIE_TEST_USERS + @runner.MAGPIE_TEST_LOGGED + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode(enable=False) + def test_UserAccount_NetworkInfo_NetworkModeOff(self): + """ + Test that node information is not visible when network mode is off. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network info.", "3.38.0", skip=True) + + self.login_test_user() + path = "/ui/users/{}".format(get_constant("MAGPIE_LOGGED_USER")) + resp = utils.TestSetup.check_UpStatus(self, method="GET", path=path) + + nodes_table = utils.find_html_body_contents(resp, [{"class": ["content"]}, {"id": "network_node_list"}]) + utils.check_val_false(bool(nodes_table)) + + @runner.MAGPIE_TEST_LOGGED + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_UserNetworkAuthorization(self): + """ + Test that authorization view is shown if there is a valid token in the request. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View authorization page.", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + + with utils.TestSetup.valid_jwt(self, override_jwt_claims={"user_name": "test123"}) as token: + self.login_test_user() + path = "/ui/network/authorize?token={}&response_type=id_token&redirect_uri={}".format( + token, json.loads(self.test_redirect_uris)[0] + ) + resp = utils.TestSetup.check_UpStatus(self, method="GET", path=path, expected_title="Magpie") + node_form = utils.find_html_body_contents(resp, [{"class": ["content"]}, {"id": "authorize_node_link"}]) + utils.check_val_true(bool(node_form)) + utils.check_val_is_in( + "Magpie is requesting permission to link your account with the test123 user's account", + node_form.text + ) + utils.check_val_is_in( + 'on the Magpie instance named "{}"'.format(self.test_node_name), # pylint: disable=C4001 + node_form.text + ) + token_input = node_form.find_all("input", recursive=True)[0] + token = token_input.attrs.get("value") + utils.check_val_true(bool(token)) + claims = jwt.decode(token, options={"verify_signature": False}) + expected = { + "requesting_user_name": "test123", + "user_name": self.test_user_name, + "iss": get_constant("MAGPIE_NETWORK_INSTANCE_NAME"), + "aud": self.test_node_name + } + expiry = claims.pop("exp") + utils.check_val_type(expiry, int) + utils.check_val_equal(expected, claims) + + @runner.MAGPIE_TEST_LOGGED + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_UserNetworkAuthorization_BadResponseType(self): + """ + Test that authorization view is not shown if the response type is invalid. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View authorization page.", "3.38.0", skip=True) + + with utils.TestSetup.valid_jwt(self, override_jwt_claims={"user_name": "test123"}) as token: + headers, cookies = self.login_test_user() + path = "/ui/network/authorize?token={}&response_type=other&redirect_uri={}".format( + token, json.loads(self.test_redirect_uris)[0] + ) + resp = utils.test_request(self, "GET", path, headers=headers, cookies=cookies, expect_errors=True) + utils.check_val_equal(resp.status_code, 400) + + @runner.MAGPIE_TEST_LOGGED + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_UserNetworkAuthorization_NoToken(self): + """ + Test that authorization view is not shown if the token is missing. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View authorization page.", "3.38.0", skip=True) + + headers, cookies = self.login_test_user() + path = "/ui/network/authorize?response_type=id_token&redirect_uri={}".format( + json.loads(self.test_redirect_uris)[0]) + resp = utils.test_request(self, "GET", path, headers=headers, cookies=cookies, expect_errors=True) + utils.check_val_equal(resp.status_code, 400) + + @runner.MAGPIE_TEST_LOGGED + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_UserNetworkAuthorization_InvalidToken(self): + """ + Test that authorization view is not shown if the token is missing. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View authorization page.", "3.38.0", skip=True) + + headers, cookies = self.login_test_user() + path = "/ui/network/authorize?token=123123&response_type=id_token&redirect_uri={}".format( + json.loads(self.test_redirect_uris)[0]) + resp = utils.test_request(self, "GET", path, headers=headers, cookies=cookies, expect_errors=True) + utils.check_val_equal(resp.status_code, 400) + + @runner.MAGPIE_TEST_LOGGED + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_UserNetworkAuthorization_BadRedirectURI(self): + """ + Test that authorization view is not shown if the response type is invalid. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View authorization page.", "3.38.0", skip=True) + + with utils.TestSetup.valid_jwt(self, override_jwt_claims={"user_name": "test123"}) as token: + headers, cookies = self.login_test_user() + path = "/ui/network/authorize?token={}&response_type=id_token&redirect_uri=blah".format(token) + resp = utils.test_request(self, "GET", path, headers=headers, cookies=cookies, expect_errors=True) + utils.check_val_equal(resp.status_code, 400) + @runner.MAGPIE_TEST_UI @six.add_metaclass(ABCMeta) @@ -7447,6 +9436,70 @@ def test_EditUser_PageStatus(self): path = "/ui/users/{}/default".format(self.test_user_name) utils.TestSetup.check_UpStatus(self, method="GET", path=path, expected_type=CONTENT_TYPE_HTML) + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_EditUser_NetworkInfo_WithRemoteUser(self): + """ + Test that remote user information is visible in a table + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network info.", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + utils.TestSetup.create_TestNetworkRemoteUser(self, override_exist=True) + path = "/ui/users/{}/default".format(self.test_user_name) + resp = utils.TestSetup.check_UpStatus(self, method="GET", path=path, expected_type=CONTENT_TYPE_HTML) + + nodes_table = utils.find_html_body_contents(resp, [{"class": ["content"]}, {"id": "network_node_list"}]) + utils.check_val_true(bool(nodes_table)) + + node_info = nodes_table.find_all("tr", recursive=True)[-1].find_all("td") + utils.check_val_equal(node_info[0].text, self.test_node_name) + utils.check_val_equal(node_info[1].text, self.test_remote_user_name) + utils.check_val_equal(node_info[2].find("input").attrs.get("name"), + "delete_node_link_{}".format(self.test_node_name)) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_EditUser_NetworkInfo_WithoutRemoteUser(self): + """ + Test that remote user information is not visible when there are no remote users for the given user + but that the table is still visible. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network info.", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + path = "/ui/users/{}/default".format(self.test_user_name) + resp = utils.TestSetup.check_UpStatus(self, method="GET", path=path, expected_type=CONTENT_TYPE_HTML) + + nodes_table = utils.find_html_body_contents(resp, [{"class": ["content"]}, {"id": "network_node_list"}]) + utils.check_val_true(bool(nodes_table)) + utils.check_val_equal(len(nodes_table.find_all("tr", recursive=True)), 1) # header row only + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode(enable=False) + def test_EditUser_NetworkInfo_NetworkModeOff(self): + """ + Test that remote user information and table is not visible when network mode is off. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "View network info.", "3.38.0", skip=True) + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + path = "/ui/users/{}/default".format(self.test_user_name) + resp = utils.TestSetup.check_UpStatus(self, method="GET", path=path, expected_type=CONTENT_TYPE_HTML) + + nodes_table = utils.find_html_body_contents(resp, [{"class": ["content"]}, {"id": "network_node_list"}]) + utils.check_val_false(bool(nodes_table)) + @runner.MAGPIE_TEST_USERS def test_EditUser_DisplayedInheritedPermissionPriority(self): """ diff --git a/tests/runner.py b/tests/runner.py index fcd81ec79..965867808 100644 --- a/tests/runner.py +++ b/tests/runner.py @@ -51,6 +51,7 @@ def filter_test_files(root, filename): MAGPIE_TEST_AUTH_ADMIN = RunOptionDecorator("MAGPIE_TEST_AUTH_ADMIN", "operations that require admin-level access") MAGPIE_TEST_AUTH_USERS = RunOptionDecorator("MAGPIE_TEST_AUTH_USERS", "operations that require user-level access") MAGPIE_TEST_AUTH_PUBLIC = RunOptionDecorator("MAGPIE_TEST_AUTH_PUBLIC", "operations that are publicly accessible") +MAGPIE_TEST_NETWORK = RunOptionDecorator("MAGPIE_TEST_NETWORK", "operations that are enabled in network mode") def test_suite(): diff --git a/tests/test_constants.py b/tests/test_constants.py index 97e28be08..6208f1c19 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,8 +1,10 @@ +import re + import mock import pytest from magpie import constants as c -from tests import runner +from tests import runner, utils @runner.MAGPIE_TEST_UTILS @@ -130,3 +132,123 @@ def test_constant_protected_no_override(): with mock.patch.dict("os.environ", {const_name: "override-value"}): const = c.get_constant(const_name) assert const != "override-value" + + +@runner.MAGPIE_TEST_UTILS +class TestProtectedUserNameRegex: + def test_include_admin(self): + c.protected_user_name_regex.cache_clear() + assert re.search(c.protected_user_name_regex(), c.get_constant("MAGPIE_ADMIN_USER")) + + def test_include_anonymous(self): + c.protected_user_name_regex.cache_clear() + assert re.search(c.protected_user_name_regex(), c.get_constant("MAGPIE_ANONYMOUS_USER")) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_include_network(self): + c.protected_user_name_regex.cache_clear() + assert re.search(c.protected_user_name_regex(), c.get_constant("MAGPIE_NETWORK_NAME_PREFIX") + "test") + + def test_extra_patterns(self): + assert re.search(c.protected_user_name_regex(additional_patterns=("test.*end",)), "test abc end") + + def test_no_admin(self): + assert re.search(c.protected_user_name_regex(include_admin=False), c.get_constant("MAGPIE_ADMIN_USER")) is None + + def test_no_anonymous(self): + assert re.search(c.protected_user_name_regex(include_anonymous=False), + c.get_constant("MAGPIE_ANONYMOUS_USER")) is None + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_no_network_network_mode_on(self): + assert re.search(c.protected_user_name_regex(include_network=False), + c.get_constant("MAGPIE_NETWORK_NAME_PREFIX") + "test") is None + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode(enable=False) + def test_include_network_network_mode_off(self): + c.protected_user_name_regex.cache_clear() + assert re.search(c.protected_user_name_regex(), + c.get_constant("MAGPIE_NETWORK_NAME_PREFIX") + "test") is None + + +@runner.MAGPIE_TEST_UTILS +class TestProtectedUserEmailRegex: + def test_include_admin(self): + c.protected_user_email_regex.cache_clear() + assert re.search(c.protected_user_email_regex(), c.get_constant("MAGPIE_ADMIN_EMAIL")) + + def test_include_anonymous(self): + c.protected_user_email_regex.cache_clear() + assert re.search(c.protected_user_email_regex(), c.get_constant("MAGPIE_ANONYMOUS_EMAIL")) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_include_network(self): + c.protected_user_email_regex.cache_clear() + assert re.search(c.protected_user_email_regex(), + c.get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT").format("test")) + + def test_extra_patterns(self): + assert re.search(c.protected_user_email_regex(additional_patterns=("test.*end",)), "test abc end") + + def test_no_admin(self): + assert re.search(c.protected_user_email_regex(include_admin=False), + c.get_constant("MAGPIE_ADMIN_EMAIL")) is None + + def test_no_anonymous(self): + assert re.search(c.protected_user_email_regex(include_anonymous=False), + c.get_constant("MAGPIE_ANONYMOUS_EMAIL")) is None + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_no_network_network_mode_on(self): + assert re.search(c.protected_user_email_regex(include_network=False), + c.get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT").format("test")) is None + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode(enable=False) + def test_include_network_network_mode_off(self): + c.protected_user_email_regex.cache_clear() + assert re.search(c.protected_user_email_regex(), + c.get_constant("MAGPIE_NETWORK_ANONYMOUS_EMAIL_FORMAT").format("test")) is None + + +@runner.MAGPIE_TEST_UTILS +class TestProtectedGroupNameRegex: + def test_include_admin(self): + c.protected_group_name_regex.cache_clear() + assert re.search(c.protected_group_name_regex(), c.get_constant("MAGPIE_ADMIN_GROUP")) + + def test_include_anonymous(self): + c.protected_group_name_regex.cache_clear() + assert re.search(c.protected_group_name_regex(), c.get_constant("MAGPIE_ANONYMOUS_GROUP")) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_include_network(self): + c.protected_group_name_regex.cache_clear() + assert re.search(c.protected_group_name_regex(), c.get_constant("MAGPIE_NETWORK_NAME_PREFIX") + "test") + + def test_no_admin(self): + assert re.search(c.protected_group_name_regex(include_admin=False), + c.get_constant("MAGPIE_ADMIN_GROUP")) is None + + def test_no_anonymous(self): + assert re.search(c.protected_group_name_regex(include_anonymous=False), + c.get_constant("MAGPIE_ANONYMOUS_GROUP")) is None + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_no_network_network_mode_on(self): + assert re.search(c.protected_group_name_regex(include_network=False), + c.get_constant("MAGPIE_NETWORK_NAME_PREFIX") + "test") is None + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode(enable=False) + def test_include_network_network_mode_off(self): + c.protected_group_name_regex.cache_clear() + assert re.search(c.protected_group_name_regex(), + c.get_constant("MAGPIE_NETWORK_NAME_PREFIX") + "test") is None diff --git a/tests/test_magpie_api.py b/tests/test_magpie_api.py index 81341eace..a12ee1bb8 100644 --- a/tests/test_magpie_api.py +++ b/tests/test_magpie_api.py @@ -7,6 +7,7 @@ Tests for :mod:`magpie.api` module. """ +import datetime import unittest import mock @@ -17,7 +18,7 @@ from magpie.models import UserGroupStatus, UserStatuses from magpie.utils import CONTENT_TYPE_JSON from tests import runner, utils -from tests.utils import TestVersion +from tests.utils import TestVersion, patch_datetime @runner.MAGPIE_TEST_API @@ -46,6 +47,7 @@ def setUpClass(cls): raise_missing=False, raise_not_set=False) cls.test_group_name = get_constant("MAGPIE_TEST_GROUP", default_value="unittest-no-auth_api-group-local", raise_missing=False, raise_not_set=False) + cls.setup_network_attrs() @runner.MAGPIE_TEST_API @@ -80,6 +82,7 @@ def setUpClass(cls): cls.test_resource_type = "route" cls.test_group_name = "unittest-user-auth-local_test-group" cls.test_user_name = "unittest-user-auth-local_test-user-username" + cls.setup_network_attrs() @runner.MAGPIE_TEST_USERS @runner.MAGPIE_TEST_GROUPS @@ -546,6 +549,52 @@ def test_PostUsers_WithExtraRegex_ValidBoth(self): headers=self.json_headers, cookies=self.cookies, expect_errors=True) utils.check_response_basic_info(resp, 201, expected_method="POST") + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_DeleteNetworkTokens_AndAnonymousNetworkUsers_ExpiredOnly(self): + """ + Test deleting only expired network tokens and their associated anonymous network users. + + Note that this test only runs locally because it requires mocking the expiry time. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Delete expired network tokens", "3.38.0", skip=True) + + utils.TestSetup.create_TestGroup(self, override_exist=True) + utils.TestSetup.create_TestUser(self, override_exist=True) + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test1") + utils.TestSetup.create_TestNetworkNode(self, override_exist=True, override_name="test2") + utils.TestSetup.create_TestNetworkToken(self, override_remote_user_name="test1", override_node_name="test1") + + with patch_datetime({"utcnow": datetime.datetime.utcnow() - datetime.timedelta(days=365)}): + utils.TestSetup.create_TestNetworkToken(self, override_remote_user_name="test2", override_node_name="test2") + + utils.test_request(self, "DELETE", "/network/tokens", data={"expired_only": True}, cookies=self.cookies, + headers=self.headers) + + resp = utils.test_request(self, "GET", "/network/remote_users", cookies=self.cookies, headers=self.headers) + json_body = utils.get_json_body(resp) + utils.check_val_equal({u["remote_user_name"] for u in json_body["remote_users"]}, {"test1"}) + + @runner.MAGPIE_TEST_NETWORK + @utils.check_network_mode + def test_GetDecodeJWT_DifferentAudience(self): + """ + Test raise error when decoding a JSON web token for a different audience. + + .. versionadded:: 3.38 + """ + utils.warn_version(self, "Decode a JSON web token", "3.38.0", skip=True) + + utils.TestSetup.create_TestNetworkNode(self, override_exist=True) + with utils.TestSetup.valid_jwt(self) as token: + with utils.mocked_get_settings(settings={"MAGPIE_NETWORK_INSTANCE_NAME": "some_other_instance"}): + resp = utils.test_request(self, "GET", "/network/decode_jwt?token={}".format(token), + cookies=self.cookies, headers=self.headers, expect_errors=True) + json_info = utils.check_response_basic_info(resp, expected_code=400) + utils.check_val_equal(json_info.get("call", {}).get("exception"), "InvalidAudienceError") + @runner.MAGPIE_TEST_API @runner.MAGPIE_TEST_LOCAL @@ -662,6 +711,7 @@ def setUpClass(cls): raise_missing=False, raise_not_set=False) cls.test_group_name = get_constant("MAGPIE_TEST_GROUP", default_value="unittest-no-auth_api-group-remote", raise_missing=False, raise_not_set=False) + cls.setup_network_attrs() @runner.MAGPIE_TEST_API @@ -695,6 +745,7 @@ def setUpClass(cls): cls.test_resource_type = "route" cls.test_group_name = "unittest-user-auth-remote_test-group" cls.test_user_name = "unittest-user-auth-remote_test-user-username" + cls.setup_network_attrs() @runner.MAGPIE_TEST_API diff --git a/tests/test_magpie_cli.py b/tests/test_magpie_cli.py index f063c38de..55bdaa4de 100644 --- a/tests/test_magpie_cli.py +++ b/tests/test_magpie_cli.py @@ -16,9 +16,10 @@ from urllib.parse import urlparse import mock +import requests import six -from magpie.cli import batch_update_permissions, batch_update_users, magpie_helper_cli +from magpie.cli import batch_update_permissions, batch_update_users, magpie_helper_cli, purge_expired_network_tokens from magpie.constants import get_constant from magpie.permissions import Permission, PermissionType from magpie.services import ServiceAPI, ServiceWPS @@ -38,11 +39,12 @@ "register_providers", "run_db_migration", "send_email", - "sync_resources" + "sync_resources", + "purge_expired_network_tokens" ] -def run_and_get_output(command, trim=True): +def run_and_get_output(command, trim=True, expect_output=True): cmd = " ".join(command) if isinstance(command, (list, tuple)) else command env = {"PATH": os.path.expandvars(os.environ["PATH"])} # when debugging, explicit expand of install path required pipe = subprocess.PIPE @@ -50,9 +52,10 @@ def run_and_get_output(command, trim=True): out, err = proc.communicate() assert not err, "process returned with error code {}".format(err) # when no output is present, it is either because CLI was not installed correctly, or caused by some other error - assert out != "", "process did not execute as expected, no output available" out_lines = [line for line in out.splitlines() if not trim or (line and not line.startswith(" "))] - assert len(out_lines), "could not retrieve any console output" + if expect_output: + assert out != "", "process did not execute as expected, no output available" + assert len(out_lines), "could not retrieve any console output" return out_lines @@ -405,3 +408,126 @@ def test_magpie_sync_resources_help_directly(): out_lines = run_and_get_output("magpie_sync_resources --help") assert "usage: magpie_sync_resources" in out_lines[0] assert "Synchronize local and remote resources based on Magpie Service sync-type" in out_lines[1] + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_purge_expired_network_tokens_help_via_magpie_helper(): + out_lines = run_and_get_output("magpie_helper purge_expired_network_tokens --help") + assert "usage: magpie_helper purge_expired_network_tokens" in out_lines[0] + assert "Delete all expired network tokens." in out_lines[1] + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_purge_expired_network_tokens_help_directly(): + out_lines = run_and_get_output("magpie_purge_expired_network_tokens --help") + assert "usage: magpie_purge_expired_network_tokens" in out_lines[0] + assert "Delete all expired network tokens." in out_lines[1] + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_purge_expired_network_tokens(): + test_url = "http://localhost" + test_username = "test_username" + test_password = "qwertyqwerty" + + def mocked_request(*args, **_kwargs): + method, *_ = args + response = requests.Response() + response.status_code = 200 + if method == "DELETE": + response._content = '{"deleted": 101}'.encode() # pylint: disable=C4001,W0212 + return response + + with mock.patch("requests.Session.request", side_effect=mocked_request) as session_mock: + cmd = ["api", test_url, test_username, test_password] + assert purge_expired_network_tokens.main(cmd) == 0, "failed execution due to invalid arguments" + session_mock.assert_any_call("POST", "{}/signin".format(test_url), data=None, + json={"user_name": "test_username", "password": "qwertyqwerty"}) + session_mock.assert_any_call("DELETE", "{}/network/tokens?expired_only=true".format(test_url)) + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_create_private_key_help_via_magpie_helper(): + out_lines = run_and_get_output("magpie_helper create_private_key --help") + assert "usage: magpie_helper create_private_key" in out_lines[0] + assert "Create a private key used to generate a JSON Web Key." in out_lines[1] + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_create_private_key_help_directly(): + out_lines = run_and_get_output("magpie_create_private_key --help") + assert "usage: magpie_create_private_key" in out_lines[0] + assert "Create a private key used to generate a JSON Web Key." in out_lines[1] + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_create_private_key_create_file(): + tmp_file = tempfile.NamedTemporaryFile(mode="w") # pylint: disable=R1732 + tmp_file.close() + try: + run_and_get_output("magpie_create_private_key --key-file {}".format(tmp_file.name), expect_output=False) + with open(tmp_file.name, encoding="utf-8") as f: + key_content = f.read() + assert "PRIVATE KEY" in key_content.splitlines()[0] + assert "ENCRYPTED" not in key_content + finally: + os.unlink(tmp_file.name) + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_create_private_key_create_file_no_force_exists(): + tmp_file = tempfile.NamedTemporaryFile(mode="w", delete=False) # pylint: disable=R1732 + tmp_file.close() + try: + out = run_and_get_output("magpie_create_private_key --key-file {}".format(tmp_file.name), expect_output=False) + assert "File {} already exists.".format(tmp_file.name) in out[0] + finally: + os.unlink(tmp_file.name) + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_create_private_key_create_file_force_exists(): + tmp_file = tempfile.NamedTemporaryFile(mode="w") # pylint: disable=R1732 + tmp_file.close() + try: + run_and_get_output("magpie_create_private_key --force --key-file {}".format(tmp_file.name), expect_output=False) + with open(tmp_file.name, encoding="utf-8") as f: + key_content = f.read() + assert "PRIVATE KEY" in key_content.splitlines()[0] + assert "ENCRYPTED" not in key_content + + finally: + os.unlink(tmp_file.name) + + +@runner.MAGPIE_TEST_CLI +@runner.MAGPIE_TEST_LOCAL +@runner.MAGPIE_TEST_NETWORK +def test_create_private_key_create_file_with_password(): + tmp_file = tempfile.NamedTemporaryFile(mode="w") # pylint: disable=R1732 + tmp_file.close() + try: + run_and_get_output("magpie_create_private_key --password qwertqwerty --key-file {}".format(tmp_file.name), + expect_output=False) + with open(tmp_file.name, encoding="utf-8") as f: + key_content = f.read() + assert "PRIVATE KEY" in key_content.splitlines()[0] + assert "ENCRYPTED" in key_content + finally: + os.unlink(tmp_file.name) diff --git a/tests/test_magpie_ui.py b/tests/test_magpie_ui.py index 0dc5a44b0..0e54cf2e3 100644 --- a/tests/test_magpie_ui.py +++ b/tests/test_magpie_ui.py @@ -85,6 +85,7 @@ def setUpClass(cls): raise_missing=False, raise_not_set=False) cls.test_group_name = get_constant("MAGPIE_TEST_GROUP", default_value="unittest-user-auth_ui-group-local", raise_missing=False, raise_not_set=False) + cls.setup_network_attrs() @runner.MAGPIE_TEST_UI @@ -125,6 +126,7 @@ def setUpClass(cls): cls.test_service_parent_resource_name = "magpie-unittest-ui-tree-parent" cls.test_service_child_resource_type = Route.resource_type_name cls.test_service_child_resource_name = "magpie-unittest-ui-tree-child" + cls.setup_network_attrs() @runner.MAGPIE_TEST_STATUS @runner.MAGPIE_TEST_FUNCTIONAL @@ -826,6 +828,7 @@ def setUpClass(cls): raise_missing=False, raise_not_set=False) cls.test_group_name = get_constant("MAGPIE_TEST_GROUP", default_value="unittest-user-auth_ui-group-remote", raise_missing=False, raise_not_set=False) + cls.setup_network_attrs() @runner.MAGPIE_TEST_UI @@ -855,3 +858,4 @@ def setUpClass(cls): raise_missing=False, raise_not_set=False) cls.test_service_type = ServiceAPI.service_type cls.test_service_name = "magpie-unittest-ui-admin-remote-service" + cls.setup_network_attrs() diff --git a/tests/test_utils.py b/tests/test_utils.py index 910d43963..211d6ffeb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -191,6 +191,10 @@ def test_verify_param_proper_verifications_raised(self): utils.check_raises(lambda: ax.verify_param("abc", matches=True, param_compare=r"[0-9]+"), HTTPBadRequest) utils.check_raises(lambda: ax.verify_param("abc", matches=True, param_compare=re.compile(r"[0-9]+")), HTTPBadRequest) + utils.check_raises(lambda: ax.verify_param("abc", not_matches=True, param_compare=r"[a-z]+"), + HTTPBadRequest) + utils.check_raises(lambda: ax.verify_param("abc", not_matches=True, param_compare=re.compile(r"[a-z]+")), + HTTPBadRequest) # with requested error utils.check_raises(lambda: @@ -218,6 +222,16 @@ def test_verify_param_proper_verifications_raised(self): param_compare=re.compile(r"[0-9]+"), http_error=HTTPForbidden), HTTPForbidden) + utils.check_raises(lambda: + ax.verify_param("abc", not_matches=True, + param_compare=r"[a-z]+", + http_error=HTTPForbidden), + HTTPForbidden) + utils.check_raises(lambda: + ax.verify_param("abc", not_matches=True, + param_compare=re.compile(r"[a-z]+"), + http_error=HTTPForbidden), + HTTPForbidden) def test_verify_param_proper_verifications_passed(self): ax.verify_param("x", param_compare=["a", "b"], not_in=True) @@ -235,6 +249,8 @@ def test_verify_param_proper_verifications_passed(self): ax.verify_param("", is_empty=True) ax.verify_param("abc", matches=True, param_compare=r"[a-z]+") ax.verify_param("abc", matches=True, param_compare=re.compile(r"[a-z]+")) + ax.verify_param("abc", not_matches=True, param_compare=r"[0-9]+") + ax.verify_param("abc", not_matches=True, param_compare=re.compile(r"[0-9]+")) def test_verify_param_args_incorrect_usage(self): """ @@ -252,8 +268,10 @@ def test_verify_param_args_incorrect_usage(self): HTTPInternalServerError, msg="incorrect non-iterable compare should raise invalid type") utils.check_raises(lambda: ax.verify_param("a", matches=True, param_compare=1), HTTPInternalServerError, msg="incorrect matching pattern not a string or compiled pattern") + utils.check_raises(lambda: ax.verify_param("a", not_matches=True, param_compare=1), + HTTPInternalServerError, msg="incorrect matching pattern not a string or compiled pattern") for flag in ["not_none", "not_empty", "not_in", "not_equal", "is_none", "is_empty", "is_in", "is_equal", - "is_true", "is_false", "is_type", "matches"]: + "is_true", "is_false", "is_type", "matches", "not_matches"]: utils.check_raises(lambda: ax.verify_param("x", **{flag: 1}), HTTPInternalServerError, msg="invalid flag '{}' type should be caught".format(flag)) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 3b31d7324..fb595967f 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -31,7 +31,7 @@ from magpie.constants import get_constant from magpie.permissions import Access, Permission, PermissionSet, Scope from magpie.services import ServiceAPI -from magpie.utils import CONTENT_TYPE_HTML +from magpie.utils import CONTENT_TYPE_HTML, get_twitcher_protected_service_url from tests import interfaces as ti from tests import runner, utils @@ -548,7 +548,7 @@ def _test(t_name, r_id, perm, action): else: expected["data"]["group_name"] = self.test_group_name expected["data"]["group_id"] = grp_id - url = "https://localhost/twitcher/ows/proxy/{}".format(self.test_service_name) + url = get_twitcher_protected_service_url(self.test_service_name) if r_id == res_id: expected["data"]["resource_name"] = self.test_resource_name expected["data"]["resource_type"] = self.test_resource_type diff --git a/tests/utils.py b/tests/utils.py index 568dc6d0b..95f616397 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -10,21 +10,27 @@ import uuid import warnings from copy import deepcopy +from datetime import datetime, timedelta from errno import EADDRINUSE from typing import TYPE_CHECKING +import jwt import mock import pytest import requests import requests.exceptions import six from beaker.cache import cache_managers, cache_regions +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat +from jwt.utils import to_base64url_uint from pyramid.config import Configurator from pyramid.httpexceptions import HTTPException from pyramid.response import Response as PyramidResponse from pyramid.settings import asbool from pyramid.testing import DummyRequest from pyramid.testing import setUp as PyramidSetUp +from pytest_httpserver import HTTPServer from requests.cookies import RequestsCookieJar, create_cookie from requests.models import Response as RequestsResponse from six.moves.urllib.parse import urlparse @@ -34,6 +40,7 @@ from webtest.response import TestResponse from magpie import __meta__, app, services +from magpie.api import schemas from magpie.compat import LooseVersion from magpie.constants import get_constant from magpie.permissions import Access, PermissionSet, Scope @@ -190,6 +197,7 @@ def test_func(): All ```` definitions should be added to ``setup.cfg`` to allow :mod:`pytest` to reference them. """ + @functools.wraps(run_option) def wrap(test_func, *_, **__): # type: (Callable, *Any, **Any) -> Callable @@ -212,6 +220,7 @@ class RunOptionDecorator(object): RunOptionDecorator("MAGPIE_TEST_CUSTOM_MARKER") """ + def __new__(cls, name, description=None): # type: (Type[RunOptionDecorator], Str, Optional[Str]) -> RunOptionDecorator return make_run_option_decorator(RunOption(name, description=description)) @@ -731,6 +740,50 @@ def wrapped(*_, **__): # type: (*Any, **Any) -> Any return mocked_get_settings_decorator +def check_network_mode(_test_func=None, enable=True): + # type: (Optional[Callable[[Callable], Any]], bool) -> Callable + """ + Returns a decorator that checks whether a test function is marked as local or remote according to the + :class:`RunOptionDecorator` on the current test. + If local, then ensure that the test application has network mode enabled (if :paramref:`enable` is ``True``) or + disabled (if :paramref:`enable` is ``False``). + If remote, then check whether the network mode is enabled or not for the remote application. If the network mode + status does not match the :paramref:`enable` value, then skip the test. For example, if the remote application has + network mode enabled but :paramref:`enable` is ``False`` then the test is skipped. + + .. note:: + + A test cannot affect the settings of a remote application. For this reason, we skip tests that would fail only + because a remote application is not configured in a way that would allow the test to pass. + + :param _test_func: Test function being decorated. + :param enable: Boolean value indicating whether ``_test_func`` expects network mode to be enabled in order to pass. + """ + if enable: + settings = {"magpie.network_enabled": True} + else: + settings = {"magpie.network_enabled": False} + + def decorator_func(test_func): + @functools.wraps(test_func) + def wrapper(*args, **kwargs): + test = args[0] + if hasattr(test, "pytestmark") and any(mark.name == "remote" for mark in test.pytestmark): + resp = test_request(test, "GET", schemas.NetworkJSONWebKeySetAPI.path) + remote_enabled = get_json_body(resp).get("code") != 501 + if enable and not remote_enabled: + test.skipTest("Test requires remote to be running with network mode enabled.") + elif not enable and remote_enabled: + test.skipTest("Test requires remote to be running without network mode enabled.") + return test_func(*args, **kwargs) + # assume local test + return mocked_get_settings(settings=settings)(test_func)(*args, **kwargs) + return wrapper + if _test_func is None: + return decorator_func + return decorator_func(_test_func) + + def mock_request(request_path_query="", # type: Str method="GET", # type: Str params=None, # type: Optional[Dict[Str, Str]] @@ -1146,6 +1199,8 @@ def test_request(test_item, # type: AnyMagpieTestItemType # automatically follow the redirect if any and evaluate its response max_redirect = kwargs.get("max_redirects", 5) + if not allow_redirects: + max_redirect = 0 while 300 <= resp.status_code < 400 and max_redirect > 0: # noqa resp = resp.follow() max_redirect -= 1 @@ -1465,6 +1520,27 @@ def check_val_type(val, ref, msg=None): assert isinstance(val, ref), format_test_val_ref(val, repr(ref), pre="Type Fail", msg=msg) +@contextlib.contextmanager +def patch_datetime(patches): + # type: (Dict[Str, Any]) -> Any + """ + Patch functions in the datetime.datetime module where keys in patches are names of functions and values are their + patched return values. + + Note: mocking datetime is complicated because it's implemented in C. The technique used here is inspired by: + https://williambert.online/2011/07/how-to-unit-testing-in-django-with-mocking-and-patching/ + """ + + class FakeDatetime(datetime): + def __new__(cls, *args, **kwargs): + return datetime.__new__(datetime, *args, **kwargs) + + with mock.patch("datetime.datetime", FakeDatetime): + for method, return_value in patches.items(): + setattr(FakeDatetime, method, classmethod(lambda cls: return_value)) + yield + + def check_raises(func, exception_type, msg=None): # type: (Callable[[], Any], Type[Exception], Optional[Str]) -> Exception """ @@ -1700,12 +1776,12 @@ def find_html_body_contents(response_or_body, html_search=None): # ignore 'dont care' items like comments hasattr(item, "attrs") and # filter by class names, any one matched if element as any - (len(item.attrs.get("class", [])) and - any(str(css_cls) in search_element.get("class", []) for css_cls in item.attrs.get("class", []))) or - # filter by html element name - (search_element.get("name", "") != "" and str(search_element.get("name")) == item.name) or - # filter by html element id - (search_element.get("id", "") != "" and str(search_element.get("id")) == item.attrs.get("id", "")) + ((len(item.attrs.get("class", [])) and + any(str(css_cls) in search_element.get("class", []) for css_cls in item.attrs.get("class", []))) or + # filter by html element name + (search_element.get("name", "") != "" and str(search_element.get("name")) == item.name) or + # filter by html element id + (search_element.get("id", "") != "" and str(search_element.get("id")) == item.attrs.get("id", ""))) ] if i + 1 != len(html_search): elem_idx = search_element.get("index") @@ -2931,6 +3007,278 @@ def create_TestUser(test_case, # type: AnyMagpieTestItemType accept_terms=accept_terms) return check_response_basic_info(create_user_resp, 201, expected_method="POST") + @staticmethod + def create_TestNetworkNode(test_case, # type: AnyMagpieTestCaseType + override_name=null, # type: Optional[Str] + override_jwks_url=null, # type: Optional[Str] + override_token_url=null, # type: Optional[Str] + override_authorization_url=null, # type: Optional[Str] + override_redirect_uris=null, # type: Optional[JSON] + override_headers=null, # type: Optional[HeadersType] + override_cookies=null, # type: Optional[CookiesType] + override_exist=False, # type: bool + override_data=null, # type: Optional[JSON] + expect_errors=False, # type: bool + ): # type: (...) -> JSON + """ + Creates a Network Node + """ + app_or_url = get_app_or_url(test_case) + if override_data is not null: + data = override_data + else: + data = { + "name": override_name if override_name is not null else test_case.test_node_name, + "jwks_url": override_jwks_url if override_jwks_url is not null else test_case.test_node_jwks_url, + "token_url": override_token_url if override_token_url is not null else test_case.test_node_token_url, + "authorization_url": (override_authorization_url if override_authorization_url is not null + else test_case.test_authorization_url), + "redirect_uris": (override_redirect_uris if override_redirect_uris is not null + else test_case.test_redirect_uris) + } + headers = override_headers if override_headers is not null else test_case.json_headers + cookies = override_cookies if override_cookies is not null else test_case.cookies + + resp = test_request(app_or_url, "POST", "/network/nodes", json=data, + expect_errors=(override_exist or expect_errors), headers=headers, cookies=cookies) + + if data.get("name"): + test_case.extra_node_names.add(data["name"]) # indicate potential removal at a later point + + if resp.status_code == 409 and override_exist and data.get("name"): + TestSetup.delete_TestNetworkNode(test_case, + override_name=data["name"], + override_headers=headers, + override_cookies=cookies) + return TestSetup.create_TestNetworkNode(test_case, + override_data=data, + override_headers=headers, + override_cookies=cookies, + override_exist=False, + expect_errors=expect_errors) + + if expect_errors: + return get_json_body(resp) + return check_response_basic_info(resp, 201, expected_method="POST") + + @staticmethod + def delete_TestNetworkNode(test_case, # type: AnyMagpieTestCaseType + override_name=null, # type: Optional[Str] + override_headers=null, # type: Optional[HeadersType] + override_cookies=null, # type: Optional[CookiesType] + allow_missing=False, # type: bool + ): # type: (...) -> Optional[AnyResponseType] + """ + Deletes a Network Node. + """ + app_or_url = get_app_or_url(test_case) + headers = override_headers if override_headers is not null else test_case.json_headers + cookies = override_cookies if override_cookies is not null else test_case.cookies + name = override_name if override_name is not null else test_case.test_node_name + path = "/network/nodes/{}".format(name) + resp = test_request(app_or_url, "DELETE", path, headers=headers, cookies=cookies, expect_errors=allow_missing) + if resp.status_code == 404 and allow_missing: + return resp + check_response_basic_info(resp, 200, expected_method="DELETE") + + @staticmethod + def create_TestNetworkRemoteUser(test_case, # type: AnyMagpieTestCaseType + override_remote_user_name=null, # type: Optional[Str] + override_user_name=null, # type: Optional[Str] + override_node_name=null, # type: Optional[Str] + override_node_host=null, # type: Optional[Str] + override_node_port=null, # type: Optional[int] + override_headers=null, # type: Optional[HeadersType] + override_cookies=null, # type: Optional[CookiesType] + override_data=null, # type: Optional[JSON] + override_exist=False, # type: bool + ): # type: (...) -> JSON + """ + Creates a Network Remote User. + """ + app_or_url = get_app_or_url(test_case) + if override_data is not null: + data = override_data + else: + data = { + "remote_user_name": (override_remote_user_name if override_remote_user_name is not null + else test_case.test_remote_user_name), + "user_name": override_user_name if override_user_name is not null else test_case.test_user_name, + "node_name": override_node_name if override_node_name is not null else test_case.test_node_name + } + headers = override_headers if override_headers is not null else test_case.json_headers + cookies = override_cookies if override_cookies is not null else test_case.cookies + + resp = test_request(app_or_url, "POST", "/network/remote_users", json=data, + expect_errors=override_exist, headers=headers, cookies=cookies) + + if data.get("remote_user_name") and data.get("node_name"): + # indicate potential removal at a later point + test_case.extra_remote_user_names.add((data["remote_user_name"], data["node_name"])) + + if resp.status_code == 409 and override_exist and data.get("remote_user_name") and data.get("node_name"): + TestSetup.delete_TestNetworkRemoteUser(test_case, + override_remote_user_name=data["remote_user_name"], + override_node_name=data["node_name"], + override_headers=headers, + override_cookies=cookies) + return TestSetup.create_TestNetworkRemoteUser(test_case, + override_remote_user_name=data["remote_user_name"], + override_node_name=data["node_name"], + override_user_name=override_user_name, + override_node_host=override_node_host, + override_node_port=override_node_port, + override_headers=headers, + override_cookies=cookies, + override_exist=False) + + return check_response_basic_info(resp, 201, expected_method="POST") + + @staticmethod + def delete_TestNetworkRemoteUser(test_case, # type: AnyMagpieTestCaseType + override_remote_user_name=null, # type: Optional[Str] + override_node_name=null, # type: Optional[Str] + override_headers=null, # type: Optional[HeadersType] + override_cookies=null, # type: Optional[CookiesType] + allow_missing=False, # type: bool + ): # type: (...) -> Optional[AnyResponseType] + """ + Deletes a Network Remote User. + """ + app_or_url = get_app_or_url(test_case) + headers = override_headers if override_headers is not null else test_case.json_headers + cookies = override_cookies if override_cookies is not null else test_case.cookies + name = override_remote_user_name if override_remote_user_name is not null else test_case.test_remote_user_name + node_name = override_node_name if override_node_name is not null else test_case.test_node_name + + path = "/network/nodes/{}/remote_users/{}".format(node_name, name) + resp = test_request(app_or_url, "DELETE", path, headers=headers, cookies=cookies, expect_errors=allow_missing) + if resp.status_code == 404 and allow_missing: + return resp + check_response_basic_info(resp, 200, expected_method="DELETE") + + @staticmethod + def remote_node(test_case, override_node_host=null, override_node_port=null, clear=True): + # type: (AnyMagpieTestCaseType, Optional[Str], Optional[int], bool) -> HTTPServer + """ + Starts a :class:`pytest_httpserver.HTTPServer` instance which can be used to generate fake responses + from a fake network node. + """ + node_host = override_node_host if override_node_host is not null else test_case.test_node_host + node_port = override_node_port if override_node_port is not null else test_case.test_node_port + server = test_case.extra_remote_servers.get((node_host, node_port)) + if server is None: + server = HTTPServer(host=node_host, port=node_port) + test_case.extra_remote_servers[(node_host, node_port)] = server + if not server.is_running(): + server.start() + if clear: + server.clear() + return server + + @staticmethod + @contextlib.contextmanager + def valid_jwt(test_case, # type: AnyMagpieTestCaseType + override_jwt_claims=null, # type: Optional[Dict[Str, Str]] + override_jwt_headers=null, # type: Optional[Dict[Str, Str]] + override_jwt_issuer=null, # type: Optional[Str] + override_jwt_expiry=null, # type: Optional[datetime] + override_jwks_url=null, # type: Optional[Str] + override_node_host=null, # type: Optional[Str] + override_node_port=null # type: Optional[int] + ): # type: (...) -> Any + jwks_url = override_jwks_url if override_jwks_url is not null else test_case.test_node_jwks_url + jwks_request_path = urlparse(jwks_url).path + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_key = private_key.public_key() + private_bytes = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()) + public_numbers = public_key.public_numbers() + kid = str(uuid.uuid4()) + jwk = { + "kid": kid, + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": to_base64url_uint(public_numbers.n).decode("ascii"), + "e": to_base64url_uint(public_numbers.e).decode("ascii") + } + jwt_headers = {} if override_jwt_headers is null else override_jwt_headers + issuer = test_case.test_node_name if override_jwt_issuer is null else override_jwt_issuer + jwt_claims = {} if override_jwt_claims is null else override_jwt_claims + if override_jwt_expiry is null: + expiry = int(get_constant("MAGPIE_NETWORK_INTERNAL_TOKEN_EXPIRY")) + expiry_time = datetime.utcnow() + timedelta(seconds=expiry) + else: + expiry_time = override_jwt_expiry + audience = get_constant("MAGPIE_NETWORK_INSTANCE_NAME") + claims = {"iss": issuer, "aud": audience, "exp": expiry_time, **jwt_claims} + token = jwt.encode(claims, private_bytes, headers={"kid": jwk["kid"], **jwt_headers}, algorithm="RS256") + node_server = TestSetup.remote_node(test_case, override_node_host=override_node_host, + override_node_port=override_node_port) + node_server.expect_request(jwks_request_path, method="GET").respond_with_json({"keys": [jwk]}) + yield token + + @staticmethod + def create_TestNetworkToken(test_case, # type: AnyMagpieTestCaseType + override_jwks_url=null, # type: Optional[Str] + override_node_host=null, # type: Optional[Str] + override_node_port=null, # type: Optional[int] + override_node_name=null, # type: Optional[Str] + override_remote_user_name=null, # type: Optional[Str] + override_headers=null, # type: Optional[HeadersType] + override_cookies=null, # type: Optional[CookiesType] + expect_errors=False, # type: bool + ): # type: (...) -> JSON + app_or_url = get_app_or_url(test_case) + + node_name = override_node_name if override_node_name is not null else test_case.test_node_name + headers = override_headers if override_headers is not null else test_case.json_headers + cookies = override_cookies if override_cookies is not null else test_case.cookies + if override_remote_user_name is null: + remote_user_name = test_case.test_remote_user_name + else: + remote_user_name = override_remote_user_name + with TestSetup.valid_jwt(test_case, override_jwt_claims={"user_name": remote_user_name}, + override_jwt_issuer=node_name, override_jwks_url=override_jwks_url, + override_node_host=override_node_host, override_node_port=override_node_port) as token: + resp = test_request(app_or_url, "POST", "/network/token", json={"token": token}, headers=headers, + cookies=cookies, expect_errors=expect_errors) + test_case.extra_network_tokens.add((remote_user_name, node_name)) + if expect_errors: + return get_json_body(resp) + json_body = check_response_basic_info(resp, 201, expected_method="POST") + return json_body + + @staticmethod + def delete_TestNetworkToken(test_case, # type: AnyMagpieTestCaseType + override_jwks_url=null, # type: Optional[Str] + override_node_host=null, # type: Optional[Str] + override_node_port=null, # type: Optional[int] + override_node_name=null, # type: Optional[Str] + override_remote_user_name=null, # type: Optional[Str] + override_headers=null, # type: Optional[HeadersType] + override_cookies=null, # type: Optional[CookiesType] + allow_missing=False, # type: bool + ): # type: (...) -> Optional[AnyResponseType] + app_or_url = get_app_or_url(test_case) + + node_name = override_node_name if override_node_name is not null else test_case.test_node_name + headers = override_headers if override_headers is not null else test_case.json_headers + cookies = override_cookies if override_cookies is not null else test_case.cookies + if override_remote_user_name is null: + remote_user_name = test_case.test_remote_user_name + else: + remote_user_name = override_remote_user_name + with TestSetup.valid_jwt(test_case, override_jwt_claims={"user_name": remote_user_name}, + override_jwt_issuer=node_name, override_jwks_url=override_jwks_url, + override_node_host=override_node_host, override_node_port=override_node_port) as token: + resp = test_request(app_or_url, "DELETE", "/network/token", json={"token": token}, headers=headers, + cookies=cookies, expect_errors=allow_missing) + if resp.status_code == 404 and allow_missing: + return resp + check_response_basic_info(resp, 200, expected_method="DELETE") + @staticmethod def delete_TestUser(test_case, # type: AnyMagpieTestItemType override_user_name=null, # type: Optional[Str]