diff --git a/.circleci/config.yml b/.circleci/config.yml index 17761677a..44b0457bb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -108,10 +108,10 @@ commands: - restore_cache: keys: - - v2-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ checksum + - v3-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ checksum "poetry.lock" }} # fallback to using the latest cache if no exact match is found - - v2-dependencies-{{ .Environment.CIRCLE_JOB }} + - v3-dependencies-{{ .Environment.CIRCLE_JOB }} - run: name: install python dependencies @@ -123,7 +123,7 @@ commands: - save_cache: paths: - /mnt/ramdisk/.venv - key: v2-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ checksum + key: v3-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ checksum "poetry.lock" }} wait_for_postgres: diff --git a/.coveragerc b/.coveragerc deleted file mode 120000 index d98fa0e0d..000000000 --- a/.coveragerc +++ /dev/null @@ -1 +0,0 @@ -setup.cfg \ No newline at end of file diff --git a/conftest.py b/conftest.py index 8a1794f1e..73f9b11c3 100644 --- a/conftest.py +++ b/conftest.py @@ -6,9 +6,17 @@ import pytest from typing import Dict, Any +import redis +from sqlalchemy.exc import ProgrammingError + from irrd import conf from irrd.rpsl.rpsl_objects import rpsl_object_from_text -from irrd.utils.rpsl_samples import SAMPLE_KEY_CERT +from irrd.storage import get_engine +from irrd.storage.database_handler import DatabaseHandler +from irrd.storage.models import RPSLDatabaseObject, JournalEntryOrigin +from irrd.storage.orm_provider import ORMSessionProvider +from irrd.utils.factories import set_factory_session, AuthUserFactory +from irrd.utils.rpsl_samples import SAMPLE_KEY_CERT, SAMPLE_MNTNER, SAMPLE_PERSON, SAMPLE_ROLE from irrd.vendor.dotted.collection import DottedDict @@ -52,7 +60,7 @@ def preload_gpg_key(): def pytest_configure(config): """ - This function is called by py.test, and will set a flag to + This function is called by py.test, and will set flags to indicate tests are running. This is used by the configuration checker to not require a full working config for most tests. Can be checked with: @@ -61,6 +69,7 @@ def pytest_configure(config): """ import sys sys._called_from_test = True + os.environ["TESTING"] = "true" @pytest.fixture(autouse=True) @@ -71,3 +80,74 @@ def reset_config(): """ conf.config_init(None) conf.testing_overrides = None + + +@pytest.fixture(scope="package") +def irrd_database_create_destroy(): + """ + Some tests use a live PostgreSQL database, as it's rather complicated + to mock, and mocking would not make them less useful. + Using in-memory SQLite is not an option due to using specific + PostgreSQL features. + + To improve performance, these tests do not run full migrations, but + the database is only created once per session, and truncated between tests. + """ + if not conf.is_config_initialised(): + conf.config_init(None) + conf.testing_overrides = None + + engine = get_engine() + try: + engine.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") + except ProgrammingError as pe: # pragma: no cover + print(f"WARNING: unable to create extension pgcrypto on the database. Queries may fail: {pe}") + + table_name = RPSLDatabaseObject.__tablename__ + if engine.has_table(engine, table_name): # pragma: no cover + if engine.url.database not in ["irrd_test", "circle_test"]: + print( + f"The database on URL {engine.url} already has a table named {table_name} - " + "delete existing database and all data in it?" + ) + confirm = input(f"Type '{engine.url.database}' to confirm deletion\n> ") + if confirm != engine.url.database: + pytest.exit("Not overwriting database, terminating test run") + RPSLDatabaseObject.metadata.drop_all(engine) + + RPSLDatabaseObject.metadata.create_all(engine) + + yield engine + + engine.dispose() + RPSLDatabaseObject.metadata.drop_all(engine) + + +@pytest.fixture(scope="function") +def irrd_db(irrd_database_create_destroy): + engine = irrd_database_create_destroy + dh = DatabaseHandler() + for table in engine.table_names(): + dh.execute_statement(f"TRUNCATE {table} CASCADE") + dh.commit() + dh.close() + + +@pytest.fixture(scope="function") +def irrd_db_session_with_user(irrd_db): + dh = DatabaseHandler() + dh.upsert_rpsl_object(rpsl_object_from_text(SAMPLE_MNTNER), origin=JournalEntryOrigin.unknown) + dh.upsert_rpsl_object(rpsl_object_from_text(SAMPLE_PERSON), origin=JournalEntryOrigin.unknown) + dh.upsert_rpsl_object(rpsl_object_from_text(SAMPLE_ROLE), origin=JournalEntryOrigin.unknown) + dh.commit() + dh.close() + + provider = ORMSessionProvider() + set_factory_session(provider.session) + user = AuthUserFactory() + provider.session.commit() + provider.session.connection().execute("COMMIT") + + yield provider, user + + provider.commit_close() diff --git a/docs/admins/configuration.rst b/docs/admins/configuration.rst index 69b718caa..58558d528 100644 --- a/docs/admins/configuration.rst +++ b/docs/admins/configuration.rst @@ -19,6 +19,8 @@ a key ``roa_source`` under a key ``rpki``. :local: :depth: 2 + + Example configuration file -------------------------- @@ -41,6 +43,8 @@ This sample shows most configuration options piddir: /var/run/ user: irrd group: irrd + # required, but no default included for safety + secret_key: null access_lists: http_database_status: @@ -55,6 +59,7 @@ This sample shows most configuration options status_access_list: http_database_status interface: '::0' port: 8080 + url: "https://irrd.example.com/" whois: interface: '::0' max_connections: 50 @@ -63,6 +68,7 @@ This sample shows most configuration options auth: gnupg_keyring: /home/irrd/gnupg-keyring/ override_password: {hash} + webui_auth_failure_rate_limit: "30/hour" password_hashers: md5-pw: legacy @@ -220,6 +226,13 @@ General settings need for IRRd to bind to port 80 or 443. |br| **Default**: not defined, IRRd does not drop privileges. |br| **Change takes effect**: after full IRRd restart. +* ``secret_key``: a random secret string. **The secrecy of this key protects + all web authentication.** If rotated, all sessions and password resets + are invalidated, requiring users to log in or request new password + reset links. Second factor authentication is *not* attached to this key. + Minimum 30 characters. + |br| **Default**: not defined, but required. + |br| **Change takes effect**: after full IRRd restart. Servers @@ -249,6 +262,13 @@ Servers stream. If not defined, all access is denied. |br| **Default**: not defined, all access denied for event stream. |br| **Change takes effect**: after SIGHUP. +* ``server.http.url``: the external URL on which users will reach the + IRRD instance. This is used for WebAuthn security tokens. + **Changing this URL after users have configured security tokens + will invalidate all tokens ** - an intentional anti-phishing + design feature of WebAuthn. The scheme must be included. + |br| **Default**: not defined, but required. + |br| **Change takes effect**: after SIGHUP. * ``server.whois.max_connections``: the maximum number of simultaneous whois connections permitted. Note that each permitted connection will result in one IRRd whois worker to be started, each of which use about 200 MB memory. @@ -319,6 +339,19 @@ Authentication and validation * ``auth.gnupg_keyring``: the full path to the gnupg keyring. |br| **Default**: not defined, but required. |br| **Change takes effect**: after full IRRd restart. +* ``auth.irrd_internal_migration_enabled``: whether users can initiate a migration + their mntners to IRRD-INTERNAL-AUTH authentication through the web interface. + If this is disabled after some mntners have already been migrated, those + will remain available and usable - this setting only affects new migrations. + |br| **Default**: disabled. + |br| **Change takes effect**: after SIGHUP, for all subsequent attempts to + initiate a migration. +* ``auth.webui_auth_failure_rate_limit``: the rate limit for failed + authentication attempts through the web interface. This includes logins, + but also other cases that request passwords. This is a moving window + in the format of the limits_ library. + |br| **Default**: ``30/hour``. + |br| **Change takes effect**: after full IRRd restart. * ``auth.password_hashers``: which password hashers to allow in mntner objects. This is a dictionary with the hashers (``crypt-pw``, ``md5-pw``, ``bcrypt-pw``) as possible keys, and ``enabled``, ``legacy``, or ``disabled`` as possible values. @@ -346,6 +379,8 @@ Authentication and validation However, you can use the ``irrd_load_pgp_keys`` command to refill the keyring in ``auth.gnupg_keyring``. +.. _limits: https://limits.readthedocs.io/en/latest/index.html + .. _conf-auth-set-creation: auth.set_creation diff --git a/docs/admins/suspension.rst b/docs/admins/suspension.rst index da6dc18f5..0269dab7d 100644 --- a/docs/admins/suspension.rst +++ b/docs/admins/suspension.rst @@ -155,3 +155,4 @@ You can use a shortened `mntner` syntax, like so:: suspension: suspend mntner: EXAMPLE-MNT source: EXAMPLE + diff --git a/docs/admins/webui.rst b/docs/admins/webui.rst new file mode 100644 index 000000000..6c82d5ab5 --- /dev/null +++ b/docs/admins/webui.rst @@ -0,0 +1,89 @@ +Web interface and internal authentication +========================================= + +Along with HTTP based API calls, GraphQL and the status page, IRRD contains +a web interface that allows users to migrate their authoritative maintainers +to an IRRD internal authentication method. It also offers more secure +override access. + +The web interface contains a RPSL submission form, accepting +the same format as emails, to make object changes. This form accepts +the new internal authentication as well as passwords, and is meant +as a more practical and more secure alternative. + +The submission form and internal authentication only affect +objects in authoritative sources. + +IRRD internal authentication +---------------------------- + +Traditional maintainer objects authenticate with a list of passwords +and/or PGP keys in the maintainer object. In IRRD internal authentication, +the permissions are kept in a separate storage, i.e. not in RPSL +objects. The major features of internal over traditional are: + +* Users can choose whether to give other users access to change + permissions on the maintainer, or only modify other objects. + In traditional authentication, anyone with maintainer access can + essentially take over the maintainer. +* Users can create API keys with limited permissions, rather than include + a password in emails. +* Users can submit object updates after logging in, without needing + to pass further authentication. +* Internal authentication can be combined with traditional, but + this is not recommended. +* Logins on the web interface can be protected with two-factor + authentication. +* Hashes of (new) user passwords are no longer part of RPSL objects. +* User passwords can not be used directly for authentication of + e.g. email updates. + +You can allow migrations with the +``auth.irrd_internal_migration_enabled`` setting. +By default, this is disabled. + +Override access +--------------- +Independent of whether regular users can migrate their account +(``auth.irrd_internal_migration_enabled``), you can +use the web interface to provide override access. +Rather than sharing a single password with your staff with traditional +override access, you can use this feature to restrict override access +to HTTPS and two-factor authenticated users. + +To enable override access for a user, the user must first create +an account and set up two-factor authentication. +Then, use the ``irrdctl user-change-override`` command +to enable or disable access for the user. + +User registration +----------------- +Users can register their own account through the interface, after verifying +their e-mail address. Users can also independently change their details or +request a link to reset. Two-factor authentication is +supported with WebAuthn tokens (SoloKeys, YubiKey, PassKey, etc.) or +one time password (TOTP, through Google Authenticator, Authy, etc.) + +Significant changes and authentication failures are logged in IRRD's log file, +and a notification is mailed to the user. +Important endpoints (e.g. login attempts) have rate limiting. + +If a user loses access to all their two-factor authentication methods, +an IRRD operator needs to reset this for them. You can do this with +the ``irrdctl user-mfa-clear`` command. + +Maintainer migration +-------------------- +Migrating a maintainer can be done by any registered user, and involves +the following steps: + +* The user requests migration with the maintainer name and one of the + current valid passwords on the maintainer. +* IRRD will mail all admin-c contacts on the maintainer with a + confirmation link. +* The same user must open the confirmation link, and confirm again with + a current valid password. +* The maintainer is now migrated. Existing methods are kept. + +A migrated maintainer must have ``IRRD-INTERNAL-AUTH`` as one of +the ``auth`` methods. This is added as part of the migration process. diff --git a/docs/conf.py b/docs/conf.py index de00cd857..930dc43c9 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -80,7 +80,7 @@ # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' -# If true, `todo` and `todoList` produce output, else they produce nothing. +# If true, `to^do` and `todoList` produce output, else they produce nothing. todo_include_todos = False diff --git a/docs/index.rst b/docs/index.rst index 23dd29c4a..1ad9d70bc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -34,11 +34,6 @@ older versions lead to the IRRd v4 project. .. _Internetstiftelsen: https://internetstiftelsen.se/ .. _Older versions: https://github.com/irrdnet/irrd-legacy -.. warning:: - IRRd 4.2.x versions prior to 4.2.3 had a security issue that exposed password - hashes in some cases. All 4.2.x users are urged to - update to 4.2.3 or later. - See the :doc:`4.2.3 release notes ` for further details. For administrators ------------------ @@ -54,6 +49,7 @@ This documentation is mainly for administrators of IRRd deployments. admins/availability-and-migration admins/migrating-legacy-irrd admins/status_page + admins/webui admins/faq diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index ded4ea03b..8ac66cc36 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -1,31 +1,22 @@ ASes aut -CPython -customisable -Escribano -IPtables -IPv -IRR -Mypy -Pre -RPSL -Redis -Redistributions -Rey -Starlette -Systemd auth +Authenticator +Authy backend balancers boolean bugfix conf config +CPython cron +customisable daemonised desynchronise dev enums +Escribano gpg gzipped hasher @@ -33,9 +24,12 @@ hashers hostname http incrementing +integrations +IPtables +IPv +IRR irr irrd -integrations journaling keepalive keychain @@ -43,11 +37,13 @@ keyring logfile loglevel logrotate +Miroslav mnt mntner mntners multipart multirow +Mypy nd nfy nginx @@ -57,31 +53,42 @@ ntt num ov pidfile +poe postgres +Pre pre preload preloaded preloader preloading preloads -poe pubsub py +Rajiv rc -reStructuredText reactivations +Redis +Redistributions resolvers +reStructuredText +Rey rpki rpki-ov-state +RPSL rpsl rr +Sarvepalli schemas setcap +Shubernetskiy snapshotting sqlalchemy +Starlette +starlette stdin stdout sublicense +Systemd systemd th tracebacks diff --git a/docs/users/database-changes.rst b/docs/users/database-changes.rst index 046f0a527..4ce440573 100644 --- a/docs/users/database-changes.rst +++ b/docs/users/database-changes.rst @@ -11,13 +11,17 @@ Additionally, notifications may be sent on attempted or successful changes. Submission format ----------------- -There are two ways to submit changes directly to IRRd: +There are three ways to submit changes directly to IRRd: * By sending an e-mail with the RPSL objects. This method supports BCRYPT-PW, MD5-PW, CRYPT-PW and PGPKEY authentication. You will receive a reply by e-mail with the result. * Over HTTPS, through a REST API. This method supports BCRYPT-PW, MD5-PW and CRYPT-PW authentication. You receive the results in the HTTP response. +* Over HTTPS, through a web form. This method supports BCRYPT-PW, MD5-PW, + CRYPT-PW and IRRD-INTERNAL-AUTH authentication. + +Your IRRD operator may restrict which password hashing methods are available. All objects submitted are validated for the presence, count and syntax, though the syntax validation is limited for some attributes. @@ -173,6 +177,18 @@ For PGP authentication, sign your message with a PGP/MIME signature or inline PGP. You can combine PGP signatures and passwords, and each method will be considered for each authentication check. +Submission through web interface +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +In the same format as e-mail, you can submit your changes through +a web interface on ``/ui/rpsl/update/``. This provides direct +feedback and is more secure when using HTTPS. It does not support +PGP. + +If your IRRD operator has enabled +:doc:`maintainer migration ` to IRRD internal authentication, +this web interface will process authentication automatically if you are +logged in and are authorised on one or more maintainers. + .. _database-changes-irr-rpsl-submit: Submission through irr_rpsl_submit @@ -238,6 +254,10 @@ If an invalid override password is used, or if no override password was configured, the invalid use is logged, and authentication and notification proceeds as usual, **as if no override password was provided.** +As a more secure alternative, you can use override access through the +:doc:`web interface ` where specific users can +be granted override access. + .. note:: New `mntner` objects can only be created using the override password. diff --git a/irrd/conf/__init__.py b/irrd/conf/__init__.py index 36ae4b87c..325e01e9e 100644 --- a/irrd/conf/__init__.py +++ b/irrd/conf/__init__.py @@ -7,7 +7,9 @@ import time from pathlib import Path from typing import Any, List, Optional +from urllib.parse import urlparse +import limits import yaml from IPy import IP @@ -22,6 +24,8 @@ ROUTEPREF_IMPORT_TIME = 3600 AUTH_SET_CREATION_COMMON_KEY = "COMMON" SOCKET_DEFAULT_TIMEOUT = 30 +RPSL_MNTNER_AUTH_INTERNAL = "IRRD-INTERNAL-AUTH" +MIN_SECRET_KEY_LENGTH = 30 LOGGING = { @@ -41,7 +45,10 @@ "gnupg": { "level": "INFO", }, - # Must be specified explicitly to disable tracing middleware, + "faker.factory": { + "level": "INFO", + }, + # uvicorn.error be specified explicitly to disable tracing middleware, # which adds substantial overhead "uvicorn.error": { "level": "INFO", @@ -49,6 +56,9 @@ "sqlalchemy": { "level": "WARNING", }, + "multipart": { + "level": "INFO", + }, "": { "handlers": ["console"], "level": "INFO", @@ -245,6 +255,11 @@ def _validate_subconfig(key, value): if not self._check_is_str(config, "piddir") or not os.path.isdir(config["piddir"]): errors.append("Setting piddir is required and must point to an existing directory.") + if not self._check_is_str(config, "secret_key") or len(config["secret_key"]) < MIN_SECRET_KEY_LENGTH: + errors.append( + f"Setting secret_key is required and must be at least {MIN_SECRET_KEY_LENGTH} characters." + ) + if not str(config.get("route_object_preference.update_timer", "0")).isnumeric(): errors.append("Setting route_object_preference.update_timer must be a number.") @@ -262,6 +277,10 @@ def _validate_subconfig(key, value): ) or "@" not in config.get("email.recipient_override", "@"): errors.append("Setting email.recipient_override must be an email address if set.") + url_parsed = urlparse(config.get("server.http.url")) + if not url_parsed.scheme or not url_parsed.netloc: + errors.append("Setting server.http.url is missing or invalid.") + string_not_required = [ "email.footer", "server.whois.access_list", @@ -282,6 +301,15 @@ def _validate_subconfig(key, value): if not self._check_is_str(config, "auth.gnupg_keyring"): errors.append("Setting auth.gnupg_keyring is required.") + if not isinstance(config.get("auth.irrd_internal_migration_enabled", False), bool): + errors.append("Setting auth.irrd_internal_migration_enabled must be a bool.") + + try: + if config.get("auth.webui_auth_failure_rate_limit"): + limits.parse(config.get("auth.webui_auth_failure_rate_limit", "")) + except ValueError: + errors.append("Setting auth.webui_auth_failure_rate_limit is missing or invalid.") + from irrd.updates.parser_state import RPSLSetAutnumAuthenticationMode valid_auth = [mode.value for mode in RPSLSetAutnumAuthenticationMode] @@ -297,7 +325,7 @@ def _validate_subconfig(key, value): f" {valid_auth} if set" ) - from irrd.rpsl.passwords import PasswordHasherAvailability + from irrd.rpsl.auth import PasswordHasherAvailability valid_hasher_availability = [avl.value for avl in PasswordHasherAvailability] for hasher_name, setting in config.get("auth.password_hashers", {}).items(): diff --git a/irrd/conf/default_config.yaml b/irrd/conf/default_config.yaml index be22395ce..dd1b4b4c9 100644 --- a/irrd/conf/default_config.yaml +++ b/irrd/conf/default_config.yaml @@ -47,6 +47,8 @@ irrd: auth: gnupg_keyring: null authenticate_parents_route_creation: true + irrd_internal_migration_enabled: false + webui_auth_failure_rate_limit: "30/hour" set_creation: COMMON: prefix_required: true diff --git a/irrd/conf/known_keys.py b/irrd/conf/known_keys.py index dc55def92..ef83f3bca 100644 --- a/irrd/conf/known_keys.py +++ b/irrd/conf/known_keys.py @@ -1,5 +1,5 @@ from irrd.conf import AUTH_SET_CREATION_COMMON_KEY -from irrd.rpsl.passwords import PASSWORD_HASHERS_ALL +from irrd.rpsl.auth import PASSWORD_HASHERS_ALL from irrd.rpsl.rpsl_objects import OBJECT_CLASS_MAPPING, RPSLSet from irrd.vendor.dotted.collection import DottedDict @@ -13,6 +13,7 @@ "piddir": {}, "user": {}, "group": {}, + "secret_key": {}, "server": { "http": { "interface": {}, @@ -21,6 +22,7 @@ "event_stream_access_list": {}, "workers": {}, "forwarded_allowed_ips": {}, + "url": {}, }, "whois": { "interface": {}, @@ -41,6 +43,8 @@ "override_password": {}, "authenticate_parents_route_creation": {}, "gnupg_keyring": {}, + "irrd_internal_migration_enabled": {}, + "webui_auth_failure_rate_limit": {}, "set_creation": { rpsl_object_class: {"prefix_required": {}, "autnum_authentication": {}} for rpsl_object_class in [ diff --git a/irrd/conf/test_conf.py b/irrd/conf/test_conf.py index a2a9b8b6c..ea809909a 100644 --- a/irrd/conf/test_conf.py +++ b/irrd/conf/test_conf.py @@ -60,6 +60,8 @@ def test_load_valid_reload_valid_config(self, monkeypatch, save_yaml_config, tmp "database_url": "db-url", "redis_url": "redis-url", "piddir": str(tmpdir), + "secret_key": "sssssssssssssssssssssssssssssss", + "server": {"http": {"url": "https://example.com/"}}, "email": {"from": "example@example.com", "smtp": "192.0.2.1"}, "route_object_preference": { "update_timer": 10, @@ -172,6 +174,8 @@ def test_load_custom_logging_config(self, monkeypatch, save_yaml_config, tmpdir, "database_url": "db-url", "redis_url": "redis-url", "piddir": str(tmpdir), + "secret_key": "sssssssssssssssssssssssssssssss", + "server": {"http": {"url": "https://example.com/"}}, "email": {"from": "example@example.com", "smtp": "192.0.2.1"}, "rpki": { "roa_source": None, @@ -193,6 +197,8 @@ def test_load_valid_reload_invalid_config(self, save_yaml_config, tmpdir, caplog "database_url": "db-url", "redis_url": "redis-url", "piddir": str(tmpdir), + "secret_key": "sssssssssssssssssssssssssssssss", + "server": {"http": {"url": "https://example.com/"}}, "email": {"from": "example@example.com", "smtp": "192.0.2.1"}, "access_lists": { "valid-list": { @@ -239,11 +245,13 @@ def test_load_invalid_config(self, save_yaml_config, tmpdir): "database_readonly": True, "piddir": str(tmpdir + "/does-not-exist"), "user": "a", + "secret_key": "sssssssssssss", "server": { "whois": { "access_list": "doesnotexist", }, "http": { + "url": "💩", "status_access_list": ["foo"], }, }, @@ -255,6 +263,8 @@ def test_load_invalid_config(self, save_yaml_config, tmpdir): "bad-list": {"192.0.2.2.1"}, }, "auth": { + "irrd_internal_migration_enabled": "invalid", + "webui_auth_failure_rate_limit": "invalid", "set_creation": { "as-set": { "prefix_required": "not-a-bool", @@ -325,12 +335,15 @@ def test_load_invalid_config(self, save_yaml_config, tmpdir): assert "Setting database_url is required." in str(ce.value) assert "Setting redis_url is required." in str(ce.value) assert "Setting piddir is required and must point to an existing directory." in str(ce.value) + assert "Setting secret_key is required and must be at least 30 characters." in str(ce.value) assert "Setting email.from is required and must be an email address." in str(ce.value) assert "Setting email.smtp is required." in str(ce.value) assert "Setting email.footer must be a string, if defined." in str(ce.value) assert "Setting email.recipient_override must be an email address if set." in str(ce.value) assert "Settings user and group must both be defined, or neither." in str(ce.value) assert "Setting auth.gnupg_keyring is required." in str(ce.value) + assert "Setting auth.irrd_internal_migration_enabled must be a bool." in str(ce.value) + assert "Setting auth.webui_auth_failure_rate_limit is missing or invalid." in str(ce.value) assert "Unknown setting key: auth.set_creation.not-a-real-set.prefix_required" in str(ce.value) assert "Setting auth.set_creation.as-set.prefix_required must be a bool" in str(ce.value) assert "Setting auth.set_creation.as-set.autnum_authentication must be one of" in str(ce.value) @@ -339,6 +352,7 @@ def test_load_invalid_config(self, save_yaml_config, tmpdir): assert "Access lists doesnotexist, invalid-list referenced in settings, but not defined." in str( ce.value ) + assert "Setting server.http.url is missing or invalid." in str(ce.value) assert "Setting server.http.status_access_list must be a string, if defined." in str(ce.value) assert "Invalid item in access list bad-list: IPv4 Address with more than 4 bytes." in str(ce.value) assert "Invalid item in prefix scopefilter: invalid-prefix" in str(ce.value) diff --git a/irrd/integration_tests/run.py b/irrd/integration_tests/run.py index b496d016c..f0574301a 100644 --- a/irrd/integration_tests/run.py +++ b/irrd/integration_tests/run.py @@ -838,9 +838,15 @@ def _start_irrds(self): base_config = { "irrd": { + "secret_key": "secretsecretsecretsecretsecretsecretsecretsecretsecretsecret", "access_lists": {"localhost": ["::/32", "127.0.0.1"]}, "server": { - "http": {"status_access_list": "localhost", "interface": "::1", "port": 8080}, + "http": { + "status_access_list": "localhost", + "interface": "::1", + "port": 8080, + "url": "https://localhost:8080/", + }, "whois": {"interface": "::1", "max_connections": 10, "port": 8043}, }, "rpki": { diff --git a/irrd/mirroring/mirror_runners_import.py b/irrd/mirroring/mirror_runners_import.py index 5230c62ed..1674cc3fb 100644 --- a/irrd/mirroring/mirror_runners_import.py +++ b/irrd/mirroring/mirror_runners_import.py @@ -152,7 +152,7 @@ def _retrieve_file_download(self, url, url_parsed, return_contents=False) -> Tup destination = NamedTemporaryFile(delete=False) logger.debug(f"Downloaded file is expected to be gzipped, gunzipping from {zipped_file.name}") with gzip.open(zipped_file.name, "rb") as f_in: - shutil.copyfileobj(f_in, destination) + shutil.copyfileobj(f_in, destination) # type: ignore os.unlink(zipped_file.name) destination.close() diff --git a/irrd/rpsl/passwords.py b/irrd/rpsl/auth.py similarity index 57% rename from irrd/rpsl/passwords.py rename to irrd/rpsl/auth.py index 481e3e9d8..a3fa16cd2 100644 --- a/irrd/rpsl/passwords.py +++ b/irrd/rpsl/auth.py @@ -1,4 +1,5 @@ from enum import Enum, unique +from typing import List, Optional from passlib.hash import bcrypt, des_crypt, md5_crypt @@ -39,3 +40,29 @@ def get_password_hashers(permit_legacy=True): PASSWORD_REPLACEMENT_HASH = ("BCRYPT-PW", bcrypt) + + +def verify_auth_lines( + auth_lines: List[str], passwords: List[str], keycert_obj_pk: Optional[str] = None +) -> bool: + """ + Verify whether one of a given list of passwords matches + any of the auth lines in the provided list, or match the + keycert object PK. + """ + hashers = get_password_hashers(permit_legacy=True) + for auth in auth_lines: + if keycert_obj_pk and auth.upper() == keycert_obj_pk.upper(): + return True + if " " not in auth: + continue + scheme, hash = auth.split(" ", 1) + hasher = hashers.get(scheme.upper()) + if hasher: + for password in passwords: + try: + if hasher.verify(password, hash): + return True + except ValueError: + pass + return False diff --git a/irrd/rpsl/fields.py b/irrd/rpsl/fields.py index cc1502069..da0039c8b 100644 --- a/irrd/rpsl/fields.py +++ b/irrd/rpsl/fields.py @@ -8,10 +8,10 @@ from irrd.utils.text import clean_ip_value_error from irrd.utils.validators import ValidationError, parse_as_number -from .parser_state import RPSLFieldParseResult, RPSLParserMessages -from .passwords import get_password_hashers - # The IPv4/IPv6 regexes are for initial screening - not full validators +from ..conf import RPSL_MNTNER_AUTH_INTERNAL +from .auth import get_password_hashers +from .parser_state import RPSLFieldParseResult, RPSLParserMessages re_ipv4_prefix = re.compile(r"^\d+\.\d+\.\d+\.\d+/\d+$") re_ipv6_prefix = re.compile(r"^[A-F\d:]+/\d+$", re.IGNORECASE) @@ -633,12 +633,14 @@ def parse( valid_beginnings = [hasher + " " for hasher in hashers.keys()] has_valid_beginning = any(value.upper().startswith(b) for b in valid_beginnings) is_valid_hash = has_valid_beginning and value.count(" ") == 1 and not value.count(",") - if is_valid_hash or re_pgpkey.match(value.upper()): + + if is_valid_hash or re_pgpkey.match(value.upper()) or value == RPSL_MNTNER_AUTH_INTERNAL: return RPSLFieldParseResult(value) hashers = ", ".join(hashers.keys()) messages.error( - f"Invalid auth attribute: {value}: supported options are {hashers} and PGPKEY-xxxxxxxx" + f"Invalid auth attribute: {value}: supported options are {hashers}, PGPKEY-xxxxxxxx and" + f" {RPSL_MNTNER_AUTH_INTERNAL} for migrated objects" ) return None diff --git a/irrd/rpsl/parser.py b/irrd/rpsl/parser.py index a5541304a..b851b76c7 100644 --- a/irrd/rpsl/parser.py +++ b/irrd/rpsl/parser.py @@ -458,7 +458,7 @@ def _normalise_rpsl_value(self, value: str) -> str: normalized_lines.append(parsed_line) return ",".join(normalized_lines) - def _update_attribute_value(self, attribute, new_values): + def _update_attribute_value(self, attribute, new_values, flatten=True): """ Update the value of an attribute in the internal state and in parsed_data. @@ -471,7 +471,10 @@ def _update_attribute_value(self, attribute, new_values): """ if isinstance(new_values, str): new_values = [new_values] - self.parsed_data[attribute] = "\n".join(new_values) + if flatten: + self.parsed_data[attribute] = "\n".join(new_values) + else: + self.parsed_data[attribute] = new_values self._object_data = list(filter(lambda a: a[0] != attribute, self._object_data)) insert_idx = 1 diff --git a/irrd/rpsl/rpsl_objects.py b/irrd/rpsl/rpsl_objects.py index f0ec820c9..e6f479611 100644 --- a/irrd/rpsl/rpsl_objects.py +++ b/irrd/rpsl/rpsl_objects.py @@ -4,11 +4,13 @@ from irrd.conf import ( AUTH_SET_CREATION_COMMON_KEY, PASSWORD_HASH_DUMMY_VALUE, + RPSL_MNTNER_AUTH_INTERNAL, get_setting, ) from irrd.utils.pgp import get_gpg_instance from ..utils.validators import ValidationError, parse_as_number +from .auth import PASSWORD_REPLACEMENT_HASH, verify_auth_lines from .fields import ( RPSLASBlockField, RPSLASNumberField, @@ -30,7 +32,6 @@ RPSLURLField, ) from .parser import RPSLObject, UnknownRPSLObjectClassException -from .passwords import PASSWORD_REPLACEMENT_HASH, get_password_hashers RPSL_ROUTE_OBJECT_CLASS_FOR_IP_VERSION = { 4: "route", @@ -435,27 +436,7 @@ def clean(self): ) def verify_auth(self, passwords: List[str], keycert_obj_pk: Optional[str] = None) -> bool: - """ - Verify whether one of a given list of passwords matches - any of the auth hashes in this object, or match the - keycert object PK. - """ - hashers = get_password_hashers(permit_legacy=True) - for auth in self.parsed_data.get("auth", []): - if keycert_obj_pk and auth.upper() == keycert_obj_pk.upper(): - return True - if " " not in auth: - continue - scheme, hash = auth.split(" ", 1) - hasher = hashers.get(scheme.upper()) - if hasher: - for password in passwords: - try: - if hasher.verify(password, hash): - return True - except ValueError: - pass - return False + return verify_auth_lines(self.parsed_data["auth"], passwords, keycert_obj_pk) def has_dummy_auth_value(self) -> bool: """ @@ -475,7 +456,15 @@ def force_single_new_password(self, password) -> None: hash = hash_key + " " + hash_function.hash(password) auths = self._auth_lines(password_hashes=False) auths.append(hash) - self._update_attribute_value("auth", auths) + self._update_attribute_value("auth", auths, flatten=False) + + def add_irrd_internal_auth(self) -> None: + """ + Add IRRD-INTERNAL-AUTH to the auth lines. + Used when migrating a mntner. + """ + auths = [RPSL_MNTNER_AUTH_INTERNAL] + self.parsed_data.get("auth", []) + self._update_attribute_value("auth", auths, flatten=False) def _auth_lines(self, password_hashes=True) -> List[Union[str, List[str]]]: """ @@ -489,6 +478,9 @@ def _auth_lines(self, password_hashes=True) -> List[Union[str, List[str]]]: return [auth.split(" ", 1) for auth in lines if " " in auth] return [auth for auth in lines if " " not in auth] + def has_internal_auth(self) -> bool: + return RPSL_MNTNER_AUTH_INTERNAL in self.parsed_data["auth"] + class RPSLPeeringSet(RPSLSet): fields = OrderedDict( diff --git a/irrd/rpsl/tests/test_rpsl_objects.py b/irrd/rpsl/tests/test_rpsl_objects.py index a432eb574..2a22fecae 100644 --- a/irrd/rpsl/tests/test_rpsl_objects.py +++ b/irrd/rpsl/tests/test_rpsl_objects.py @@ -382,6 +382,10 @@ def test_parse(self, config_override): assert obj.render_rpsl_text() == rpsl_text assert obj.references_strong_inbound() == {"mnt-by"} + obj.add_irrd_internal_auth() + assert obj.has_internal_auth() + assert rpsl_object_from_text(obj.render_rpsl_text()).has_internal_auth() + def test_parse_invalid_partial_dummy_hash(self, config_override): config_override({"auth": {"password_hashers": {"crypt-pw": "enabled"}}}) rpsl_text = object_sample_mapping[RPSLMntner().rpsl_object_class] diff --git a/irrd/scripts/irrd_control.py b/irrd/scripts/irrd_control.py new file mode 100755 index 000000000..125902012 --- /dev/null +++ b/irrd/scripts/irrd_control.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python +# ruff: noqa: E402 +import logging +import sys +import textwrap +from functools import update_wrapper +from pathlib import Path + +import click + +from irrd.webui.helpers import send_authentication_change_mail + +sys.path.append(str(Path(__file__).resolve().parents[2])) + +from irrd.conf import CONFIG_PATH_DEFAULT, config_init, get_setting +from irrd.storage.models import AuthUser +from irrd.storage.orm_provider import ORMSessionProvider, session_provider_manager_sync +from irrd.webui import UI_DEFAULT_DATETIME_FORMAT + +logger = logging.getLogger(__name__) + + +def check_database_readonly(f): + def new_func(*args, **kwargs): + if get_setting("database_readonly"): + raise click.ClickException("Unable to run this command, because database_readonly is set.") + return f(*args, **kwargs) + + return update_wrapper(new_func, f) + + +@click.group() +@click.option( + "--config", + type=click.Path(exists=True, dir_okay=False), + help="use a different IRRd config file", + default=CONFIG_PATH_DEFAULT, + show_default=True, +) +def cli(config): + config_init(config) # pragma: no cover + + +@cli.command() +@click.argument("email") +@check_database_readonly +@session_provider_manager_sync +def user_mfa_clear(email, session_provider: ORMSessionProvider): + """ + Remove two-factor authentication for user EMAIL. + """ + user = find_user(session_provider, email) + + if not user.has_mfa: + raise click.ClickException("User has no two-factor methods enabled.") + + auth_methods = "\n - ".join( + [ + f"WebAuthn '{wa.name}' (last use {wa.last_used.strftime(UI_DEFAULT_DATETIME_FORMAT)})" + for wa in user.webauthns + ] + + (["TOTP"] if user.has_totp else []) + ) + + click.echo( + textwrap.dedent( + f""" + You are about to remove multi-factor authentication for user: + {user.name} ({user.email}) + + The user currently has the following methods enabled: + - {auth_methods} + + After this, the user will be able to log in with their password. + + It is your own responsibility to determine that the legitimate + user has lost access to their two-factor methods. + The user will be notified of this change. + """ + ) + ) + click.confirm(f"Are you sure you want to remove two-factor authentication for {email}?", abort=True) + for webauthn in user.webauthns: + session_provider.session.delete(webauthn) + user.totp_secret = None + user.totp_last_used = None + session_provider.session.add(user) + + click.echo("Two-factor authentication has been removed.") + logger.info(f"cleared two-factor authentication for user {user.email} ({user.pk})") + send_authentication_change_mail( + user, request=None, msg="All two-factor authentication methods were removed by an IRRD administrator." + ) + + +@cli.command() +@click.argument("email") +@click.option("--enable/--disable", default=True) +@check_database_readonly +@session_provider_manager_sync +def user_change_override(email: str, enable: bool, session_provider: ORMSessionProvider): + """ + Change the override permission for user EMAIL. + """ + user = find_user(session_provider, email) + + if not user.has_mfa: + raise click.ClickException( + "User has no two-factor methods enabled, which is required to add override." + ) + + if enable and user.override: + raise click.ClickException("User already has override permission.") + + if not enable and not user.override: + raise click.ClickException("User already has no override permission.") + + if enable: + click.echo( + textwrap.dedent( + f""" + You are about to assign override permission for user: + {user.name} ({user.email}) + + This will allow the user to edit any object in the database. + """ + ) + ) + click.confirm(f"Are you sure you want to assign this permission to {email}?", abort=True) + + user.override = enable + session_provider.session.add(user) + + enabled_str = "enabled" if enable else "disabled" + click.echo(f"Override permission has been {enabled_str}.") + logger.info(f"override {enabled_str} for user {user.email} ({user.pk})") + + +def find_user(session_provider: ORMSessionProvider, email: str) -> AuthUser: + query = session_provider.session.query(AuthUser).filter_by(email=email) + user = session_provider.run_sync(query.one) + if not user: + raise click.ClickException(f"No user found for {email}.") + return user + + +if __name__ == "__main__": + cli() diff --git a/irrd/scripts/tests/test_irrd_control.py b/irrd/scripts/tests/test_irrd_control.py new file mode 100644 index 000000000..c9774c414 --- /dev/null +++ b/irrd/scripts/tests/test_irrd_control.py @@ -0,0 +1,172 @@ +import pytest +from click.testing import CliRunner + +from irrd.scripts.irrd_control import cli, user_change_override, user_mfa_clear +from irrd.storage.models import AuthWebAuthn +from irrd.utils.factories import AuthWebAuthnFactory + + +def test_cli(): + runner = CliRunner() + result = runner.invoke(cli) + assert result.exit_code == 0 + + +@pytest.fixture +def smtpd_override(config_override, smtpd): + config_override( + { + "email": {"smtp": f"localhost:{smtpd.port}", "from": "irrd@example.net"}, + } + ) + yield smtpd + + +class TestUserMfaClear: + def test_valid(self, irrd_db_session_with_user, smtpd_override): + session_provider, user = irrd_db_session_with_user + wn_token = AuthWebAuthnFactory(user_id=str(user.pk)) + wn_token_name = wn_token.name + + runner = CliRunner() + result = runner.invoke(user_mfa_clear, [user.email], input="y") + assert result.exit_code == 0 + assert wn_token_name in result.output + assert "TOTP" in result.output + + assert not session_provider.run_sync(session_provider.session.query(AuthWebAuthn).one) + session_provider.session.refresh(user) + assert not user.totp_secret + assert len(smtpd_override.messages) == 1 + assert "removed by an IRRD administrator" in smtpd_override.messages[0].as_string() + + def test_rejected_confirmation(self, irrd_db_session_with_user, smtpd_override): + session_provider, user = irrd_db_session_with_user + AuthWebAuthnFactory(user_id=str(user.pk)) + + runner = CliRunner() + result = runner.invoke(user_mfa_clear, [user.email], input="n") + assert result.exit_code == 1 + + assert session_provider.run_sync(session_provider.session.query(AuthWebAuthn).one) + session_provider.session.refresh(user) + assert user.totp_secret + assert not smtpd_override.messages + + def test_user_has_no_mfa(self, irrd_db_session_with_user, smtpd_override): + session_provider, user = irrd_db_session_with_user + user.totp_secret = None + session_provider.session.commit() + + runner = CliRunner() + result = runner.invoke(user_mfa_clear, [user.email]) + assert result.exit_code == 1 + assert "no two-factor methods enabled" in result.output + assert not smtpd_override.messages + + def test_user_does_not_exist(self, irrd_db_session_with_user, smtpd_override): + session_provider, user = irrd_db_session_with_user + user.totp_secret = None + session_provider.session.commit() + + runner = CliRunner() + result = runner.invoke(user_mfa_clear, ["invalid"]) + assert result.exit_code == 1 + assert "No user found" in result.output + assert not smtpd_override.messages + + def test_database_readonly(self, irrd_db_session_with_user, config_override, smtpd_override): + config_override({"database_readonly": True}) + + runner = CliRunner() + result = runner.invoke(user_mfa_clear, ["user.email"]) + assert result.exit_code == 1 + assert "database_readonly" in result.output + assert not smtpd_override.messages + + +class TestUserChangeOverride: + def test_valid_enable(self, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + + runner = CliRunner() + result = runner.invoke(user_change_override, [user.email, "--enable"], input="y") + assert result.exit_code == 0 + + session_provider.session.refresh(user) + assert user.override + + def test_valid_disable(self, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + user.override = True + session_provider.session.commit() + + runner = CliRunner() + result = runner.invoke(user_change_override, [user.email, "--disable"], input="y") + assert result.exit_code == 0 + + session_provider.session.refresh(user) + assert not user.override + + def test_enable_already_enabled(self, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + user.override = True + session_provider.session.commit() + + runner = CliRunner() + result = runner.invoke(user_change_override, [user.email, "--enable"], input="y") + assert result.exit_code == 1 + assert "already has" in result.output + + session_provider.session.refresh(user) + assert user.override + + def test_disable_already_disabled(self, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + + runner = CliRunner() + result = runner.invoke(user_change_override, [user.email, "--disable"], input="y") + assert result.exit_code == 1 + assert "already has" in result.output + + session_provider.session.refresh(user) + assert not user.override + + def test_rejected_confirmation(self, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + + runner = CliRunner() + result = runner.invoke(user_change_override, [user.email, "--enable"], input="n") + assert result.exit_code == 1 + + session_provider.session.refresh(user) + assert not user.override + + def test_user_does_not_exist(self, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + + runner = CliRunner() + result = runner.invoke(user_change_override, [user.email, "--enable"], input="y") + assert result.exit_code == 0 + + session_provider.session.refresh(user) + assert user.override + + def test_no_mfa(self, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + user.totp_secret = None + session_provider.session.commit() + + runner = CliRunner() + result = runner.invoke(user_change_override, [user.email, "--enable"], input="y") + assert result.exit_code == 1 + assert "has no two-factor" in result.output + + def test_database_readonly(self, irrd_db_session_with_user, config_override): + config_override({"database_readonly": True}) + session_provider, user = irrd_db_session_with_user + + runner = CliRunner() + result = runner.invoke(user_change_override, [user.email, "--enable"], input="y") + assert result.exit_code == 1 + assert "database_readonly" in result.output diff --git a/irrd/server/http/app.py b/irrd/server/http/app.py index ccf2b86b8..448fb0f1c 100644 --- a/irrd/server/http/app.py +++ b/irrd/server/http/app.py @@ -1,22 +1,28 @@ import logging import os import signal +from pathlib import Path +import limits from ariadne.asgi import GraphQL from ariadne.asgi.handlers import GraphQLHTTPHandler from setproctitle import setproctitle from starlette.applications import Starlette from starlette.middleware import Middleware +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import RedirectResponse from starlette.routing import Mount, Route, WebSocketRoute +from starlette.staticfiles import StaticFiles from starlette.types import ASGIApp, Receive, Scope, Send +from starlette_wtf import CSRFProtectMiddleware # Relative imports are not allowed in this file from irrd import ENV_MAIN_PROCESS_PID -from irrd.conf import config_init +from irrd.conf import config_init, get_setting from irrd.server.graphql import ENV_UVICORN_WORKER_CONFIG_PATH from irrd.server.graphql.extensions import QueryMetadataExtension, error_formatter from irrd.server.graphql.schema_builder import build_executable_schema -from irrd.server.http.endpoints import ( +from irrd.server.http.endpoints_api import ( ObjectSubmissionEndpoint, StatusEndpoint, SuspensionSubmissionEndpoint, @@ -28,7 +34,10 @@ ) from irrd.storage.database_handler import DatabaseHandler from irrd.storage.preload import Preloader +from irrd.utils.misc import secret_key_derive from irrd.utils.process_support import memory_trim, set_traceback_handler +from irrd.webui.auth.users import auth_middleware +from irrd.webui.routes import UI_ROUTES logger = logging.getLogger(__name__) @@ -52,13 +61,19 @@ async def startup(): global app config_path = os.getenv(ENV_UVICORN_WORKER_CONFIG_PATH) config_init(config_path) + set_middleware(app) try: app.state.database_handler = DatabaseHandler(readonly=True) app.state.preloader = Preloader(enable_queries=True) + app.state.rate_limiter_storage = limits.storage.storage_from_string( + "async+" + get_setting("redis_url"), + protocol_version=2, + ) + app.state.rate_limiter = limits.aio.strategies.MovingWindowRateLimiter(app.state.rate_limiter_storage) except Exception as e: logger.critical( ( - "HTTP worker failed to initialise preloader or database, " + "HTTP worker failed to initialise preloader, database or rate limiter, " f"unable to start, terminating IRRd, traceback follows: {e}" ), exc_info=e, @@ -75,6 +90,7 @@ async def shutdown(): global app app.state.database_handler.close() app.state.preloader = None + app.state.rate_limiter = None graphql = GraphQL( @@ -86,12 +102,18 @@ async def shutdown(): error_formatter=error_formatter, ) +STATIC_DIR = templates = Path(__file__).parent.parent.parent / "webui" / "static" + + routes = [ + Route("/", lambda request: RedirectResponse("/ui/", status_code=302)), Mount("/v1/status", StatusEndpoint), Mount("/v1/whois", WhoisQueryEndpoint), Mount("/v1/submit", ObjectSubmissionEndpoint), Mount("/v1/suspension", SuspensionSubmissionEndpoint), Mount("/graphql", graphql), + Mount("/ui", name="ui", routes=UI_ROUTES), + Mount("/static", name="static", app=StaticFiles(directory=STATIC_DIR)), WebSocketRoute("/v1/event-stream/", EventStreamEndpoint), Route("/v1/event-stream/initial/", EventStreamInitialDownloadEndpoint), ] @@ -111,5 +133,18 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: routes=routes, on_startup=[startup], on_shutdown=[shutdown], - middleware=[Middleware(MemoryTrimMiddleware)], ) + + +def set_middleware(app): + testing = os.environ.get("TESTING", False) + logger.info("Running in testing mode, disabling CSRF.") + app.user_middleware = [ + Middleware(MemoryTrimMiddleware), + Middleware(SessionMiddleware, secret_key=secret_key_derive("web.session_middleware")), + Middleware( + CSRFProtectMiddleware, csrf_secret=secret_key_derive("web.csrf_middleware"), enabled=not testing + ), + auth_middleware, + ] + app.middleware_stack = app.build_middleware_stack() diff --git a/irrd/server/http/endpoints.py b/irrd/server/http/endpoints_api.py similarity index 100% rename from irrd/server/http/endpoints.py rename to irrd/server/http/endpoints_api.py diff --git a/irrd/server/http/tests/test_endpoints.py b/irrd/server/http/tests/test_endpoints.py index a22f64d8f..65158aaf5 100644 --- a/irrd/server/http/tests/test_endpoints.py +++ b/irrd/server/http/tests/test_endpoints.py @@ -16,7 +16,7 @@ WhoisQueryResponseType, ) from ..app import app -from ..endpoints import StatusEndpoint, WhoisQueryEndpoint +from ..endpoints_api import StatusEndpoint, WhoisQueryEndpoint from ..status_generator import StatusGenerator @@ -53,7 +53,7 @@ def test_status_access_list_permitted(self, config_override, monkeypatch): mock_database_status_generator = Mock(spec=StatusGenerator) monkeypatch.setattr( - "irrd.server.http.endpoints.StatusGenerator", lambda: mock_database_status_generator + "irrd.server.http.endpoints_api.StatusGenerator", lambda: mock_database_status_generator ) mock_database_status_generator.generate_status = lambda: "status" @@ -85,7 +85,7 @@ class TestWhoisQueryEndpoint: def test_query_endpoint(self, monkeypatch): mock_query_parser = Mock(spec=WhoisQueryParser) monkeypatch.setattr( - "irrd.server.http.endpoints.WhoisQueryParser", + "irrd.server.http.endpoints_api.WhoisQueryParser", lambda client_ip, client_str, preloader, database_handler: mock_query_parser, ) app = Mock( @@ -157,7 +157,7 @@ def test_query_endpoint(self, monkeypatch): class TestObjectSubmissionEndpoint: def test_endpoint(self, monkeypatch): mock_handler = Mock(spec=ChangeSubmissionHandler) - monkeypatch.setattr("irrd.server.http.endpoints.ChangeSubmissionHandler", lambda: mock_handler) + monkeypatch.setattr("irrd.server.http.endpoints_api.ChangeSubmissionHandler", lambda: mock_handler) mock_handler.submitter_report_json = lambda: {"response": True} client = TestClient(app) @@ -216,7 +216,7 @@ def test_endpoint(self, monkeypatch): class TestSuspensionSubmissionEndpoint: def test_endpoint(self, monkeypatch): mock_handler = Mock(spec=ChangeSubmissionHandler) - monkeypatch.setattr("irrd.server.http.endpoints.ChangeSubmissionHandler", lambda: mock_handler) + monkeypatch.setattr("irrd.server.http.endpoints_api.ChangeSubmissionHandler", lambda: mock_handler) mock_handler.submitter_report_json = lambda: {"response": True} client = TestClient(app) diff --git a/irrd/storage/alembic/versions/5bbbc2989aa6_add_internal_auth.py b/irrd/storage/alembic/versions/5bbbc2989aa6_add_internal_auth.py new file mode 100644 index 000000000..07977a1bc --- /dev/null +++ b/irrd/storage/alembic/versions/5bbbc2989aa6_add_internal_auth.py @@ -0,0 +1,108 @@ +"""add_internal_auth + +Revision ID: 5bbbc2989aa6 +Revises: fd4473bc1a10 +Create Date: 2023-04-25 20:31:13.641738 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "5bbbc2989aa6" +down_revision = "fd4473bc1a10" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "auth_user", + sa.Column( + "pk", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("email", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("password", sa.String(), nullable=False), + sa.Column("totp_secret", sa.String(), nullable=True), + sa.Column("totp_last_used", sa.String(), nullable=True), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("override", sa.Boolean(), nullable=False), + sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("pk"), + ) + op.create_index(op.f("ix_auth_user_email"), "auth_user", ["email"], unique=True) + op.create_table( + "auth_mntner", + sa.Column( + "pk", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("rpsl_mntner_pk", sa.String(), nullable=False), + sa.Column("rpsl_mntner_obj_id", postgresql.UUID(), nullable=False), + sa.Column("rpsl_mntner_source", sa.String(), nullable=False), + sa.Column("migration_token", sa.String(), nullable=True), + sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["rpsl_mntner_obj_id"], ["rpsl_objects.pk"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("pk"), + sa.UniqueConstraint( + "rpsl_mntner_obj_id", "rpsl_mntner_source", name="auth_mntner_rpsl_mntner_obj_id_source_unique" + ), + ) + op.create_index( + op.f("ix_auth_mntner_rpsl_mntner_obj_id"), "auth_mntner", ["rpsl_mntner_obj_id"], unique=True + ) + op.create_index(op.f("ix_auth_mntner_rpsl_mntner_pk"), "auth_mntner", ["rpsl_mntner_pk"], unique=False) + op.create_index( + op.f("ix_auth_mntner_rpsl_mntner_source"), "auth_mntner", ["rpsl_mntner_source"], unique=False + ) + op.create_table( + "auth_webauthn", + sa.Column( + "pk", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("user_id", postgresql.UUID(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("credential_id", sa.LargeBinary(), nullable=False), + sa.Column("credential_public_key", sa.LargeBinary(), nullable=False), + sa.Column("credential_sign_count", sa.Integer(), nullable=False), + sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("last_used", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["auth_user.pk"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("pk"), + ) + op.create_index(op.f("ix_auth_webauthn_user_id"), "auth_webauthn", ["user_id"], unique=False) + op.create_table( + "auth_permission", + sa.Column( + "pk", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("user_id", postgresql.UUID(), nullable=True), + sa.Column("mntner_id", postgresql.UUID(), nullable=True), + sa.Column("user_management", sa.Boolean(), nullable=False), + sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["mntner_id"], ["auth_mntner.pk"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["user_id"], ["auth_user.pk"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("pk"), + sa.UniqueConstraint("user_id", "mntner_id", name="auth_permission_user_mntner_unique"), + ) + op.create_index(op.f("ix_auth_permission_mntner_id"), "auth_permission", ["mntner_id"], unique=False) + op.create_index(op.f("ix_auth_permission_user_id"), "auth_permission", ["user_id"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_auth_permission_user_id"), table_name="auth_permission") + op.drop_index(op.f("ix_auth_permission_mntner_id"), table_name="auth_permission") + op.drop_table("auth_permission") + op.drop_index(op.f("ix_auth_webauthn_user_id"), table_name="auth_webauthn") + op.drop_table("auth_webauthn") + op.drop_index(op.f("ix_auth_mntner_rpsl_mntner_source"), table_name="auth_mntner") + op.drop_index(op.f("ix_auth_mntner_rpsl_mntner_pk"), table_name="auth_mntner") + op.drop_index(op.f("ix_auth_mntner_rpsl_mntner_obj_id"), table_name="auth_mntner") + op.drop_table("auth_mntner") + op.drop_index(op.f("ix_auth_user_email"), table_name="auth_user") + op.drop_table("auth_user") + # ### end Alembic commands ### diff --git a/irrd/storage/models.py b/irrd/storage/models.py index 50bf5ea46..cd0537276 100644 --- a/irrd/storage/models.py +++ b/irrd/storage/models.py @@ -3,6 +3,7 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql as pg from sqlalchemy.ext.declarative import declarative_base, declared_attr +from sqlalchemy.orm import relationship from irrd.routepref.status import RoutePreferenceStatus from irrd.rpki.status import RPKIStatus @@ -86,6 +87,8 @@ class RPSLDatabaseObject(Base): # type: ignore created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + auth_mntner = relationship("AuthMntner", uselist=False, backref="rpsl_mntner_obj") + @declared_attr def __table_args__(cls): # noqa args = [ @@ -260,6 +263,185 @@ def __repr__(self): return f"<{self.prefix}/{self.asn}>" +class AuthPermission(Base): # type: ignore + __tablename__ = "auth_permission" + + pk = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True) + user_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT"), index=True) + mntner_id = sa.Column(pg.UUID, sa.ForeignKey("auth_mntner.pk", ondelete="RESTRICT"), index=True) + + # This may not scale well + user_management = sa.Column(sa.Boolean, default=False, nullable=False) + + created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + + @declared_attr + def __table_args__(cls): # noqa + args = [ + sa.UniqueConstraint("user_id", "mntner_id", name="auth_permission_user_mntner_unique"), + ] + return tuple(args) + + def __repr__(self): + return f"AuthPermission<{self.pk}, user {self.user_id}, mntner {self.mntner_id}>" + + +class AuthUser(Base): # type: ignore + __tablename__ = "auth_user" + + pk = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True) + email = sa.Column(sa.String, index=True, unique=True, nullable=False) + name = sa.Column(sa.String, nullable=False) + password = sa.Column(sa.String, nullable=False) + + totp_secret = sa.Column(sa.String, nullable=True) + totp_last_used = sa.Column(sa.String, nullable=True) + + active = sa.Column(sa.Boolean, default=False, nullable=False) + override = sa.Column(sa.Boolean, default=False, nullable=False) + # api_tokens = relationship("AuthApiToken", backref="user") + + permissions = relationship( + "AuthPermission", + backref=sa.orm.backref("user", uselist=False), + ) + webauthns = relationship( + "AuthWebAuthn", + backref=sa.orm.backref("user", uselist=False), + ) + mntners = relationship( + "AuthMntner", + backref="users", + secondary=( + "join(AuthPermission, AuthMntner, and_(AuthMntner.pk==AuthPermission.mntner_id," + " AuthMntner.migration_token.is_(None)))" + ), + ) + mntners_user_management = relationship( + "AuthMntner", + secondary=( + "join(AuthPermission, AuthMntner, and_(AuthMntner.pk==AuthPermission.mntner_id," + " AuthMntner.migration_token.is_(None),AuthPermission.user_management==True))" + ), + ) + mntners_no_user_management = relationship( + "AuthMntner", + secondary=( + "join(AuthPermission, AuthMntner, and_(AuthMntner.pk==AuthPermission.mntner_id," + " AuthMntner.migration_token.is_(None),AuthPermission.user_management==False))" + ), + ) + + created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + + def __repr__(self): + return f"AuthUser<{self.pk}, {self.email}>" + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.pk == other.pk + raise NotImplementedError + + @property + def has_totp(self) -> bool: + return bool(self.totp_secret) + + @property + def has_webauthn(self) -> bool: + return bool(self.webauthns) + + @property + def has_mfa(self) -> bool: + return self.has_webauthn or self.has_totp + + # getter methods are for compatibility with imia UserLike object + def get_display_name(self) -> str: # pragma: no cover + return self.name + + def get_id(self) -> str: + return self.email + + def get_hashed_password(self) -> str: + return self.password + + def get_scopes(self) -> list: # pragma: no cover + return [] + + +class AuthWebAuthn(Base): # type: ignore + __tablename__ = "auth_webauthn" + + pk = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True) + user_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT"), index=True) + name = sa.Column(sa.String, nullable=False) + credential_id = sa.Column(sa.LargeBinary, nullable=False) + credential_public_key = sa.Column(sa.LargeBinary, nullable=False) + credential_sign_count = sa.Column(sa.Integer, nullable=False) + created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + last_used = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + + +# class AuthApiToken(Base): # type: ignore +# __tablename__ = "auth_api_token" +# +# token = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True) +# user_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT")) +# # IP range? +# # submission method +# +# created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) +# updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) +# +# def __repr__(self): +# return f"<{self.pk}/{self.email}" + + +class AuthMntner(Base): # type: ignore + __tablename__ = "auth_mntner" + + pk = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True) + rpsl_mntner_pk = sa.Column(sa.String, index=True, nullable=False) + rpsl_mntner_obj_id = sa.Column( + pg.UUID, + sa.ForeignKey("rpsl_objects.pk", ondelete="RESTRICT"), + index=True, + unique=True, + nullable=False, + ) + rpsl_mntner_source = sa.Column(sa.String, index=True, nullable=False) + + migration_token = sa.Column(sa.String, nullable=True) + + # permissions = relationship("AuthPermission", backref='mntner') + permissions = relationship( + "AuthPermission", + backref=sa.orm.backref("mntner", uselist=False), + ) + + created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False) + + @property + def migration_complete(self) -> bool: + return self.migration_token is None + + @declared_attr + def __table_args__(cls): # noqa + args = [ + sa.UniqueConstraint( + "rpsl_mntner_obj_id", + "rpsl_mntner_source", + name="auth_mntner_rpsl_mntner_obj_id_source_unique", + ), + ] + return tuple(args) + + def __repr__(self): + return f"AuthMntner<{self.pk}, {self.rpsl_mntner_pk}>" + + # Before you update this, please check the storage documentation for changing lookup fields. expected_lookup_field_names = { "admin-c", diff --git a/irrd/storage/orm_provider.py b/irrd/storage/orm_provider.py new file mode 100644 index 000000000..aa31ac744 --- /dev/null +++ b/irrd/storage/orm_provider.py @@ -0,0 +1,102 @@ +import functools + +import sqlalchemy.orm as saorm +from asgiref.sync import sync_to_async +from sqlalchemy.exc import SQLAlchemyError + +from irrd.storage.database_handler import DatabaseHandler + + +class ORMSessionProvider: + """ + This object provides access to a SQLALchemy ORM session, + to allow access to the database for ORM queries. + These are mainly used for authentication, the ORM has overhead that + makes it a poor choice for high performance. + """ + + def __init__(self): + self.database_handler = DatabaseHandler() + self.session = self._get_session() + + def _get_session(self): + return saorm.Session(bind=self.database_handler._connection) + + def close(self): + """ + Close the connection, discarding changes. + """ + self.session.close() + self.database_handler.close() + + def commit_close(self): + """ + Commit any changes and close the connection. + Must be called explicitly on each session provider. + """ + self.session.commit() + self.database_handler.commit() + self.close() + + @sync_to_async + def run(self, target): + """ + Run the provided callable query, async interface. + This is just a small async wrapper around the sync version. + """ + return self.run_sync(target) + + def run_sync(self, target): + """ + Run the provided callable query, sync interface. + Target should be a callable, e.g. run_sync(query.all). + Automatically reconnects once if the connection was lost. + """ + try: + return target() + except saorm.exc.NoResultFound: + return None + except SQLAlchemyError: # pragma: no cover + self.database_handler.refresh_connection() + target.__self__.session = self.session = self._get_session() + return target() + + +def session_provider_manager(func): + """ + Decorator intended for async functions to provide an ORMSessionProvider. + Commits/closes at the end of a successful call. + """ + + @functools.wraps(func) + async def endpoint_wrapper(*args, **kwargs): + provider = ORMSessionProvider() + try: + response = await func(*args, session_provider=provider, **kwargs) + provider.commit_close() + except Exception: # pragma: no cover + provider.close() + raise + return response + + return endpoint_wrapper + + +def session_provider_manager_sync(func): + """ + Decorator for sync functions to provide an ORMSessionProvider. + Commits/closes at the end of a successful call. + """ + + @functools.wraps(func) + def endpoint_wrapper(*args, **kwargs): + provider = ORMSessionProvider() + try: + response = func(*args, session_provider=provider, **kwargs) + provider.commit_close() + except Exception: # pragma: no cover + provider.close() + raise + return response + + return endpoint_wrapper diff --git a/irrd/storage/preload.py b/irrd/storage/preload.py index bae15858a..4fab19e85 100644 --- a/irrd/storage/preload.py +++ b/irrd/storage/preload.py @@ -1,6 +1,7 @@ import logging import random import signal +import sys import threading import time from collections import defaultdict @@ -135,6 +136,10 @@ def routes_for_origins( """ while not self._memory_loaded: time.sleep(1) # pragma: no cover + logger.critical( + f"preload requested routes for {origins=} {sources=} {ip_version=}:" + f" {self._origin_route4_store=} {self._origin_route6_store=}" + ) if ip_version and ip_version not in [4, 6]: raise ValueError(f"Invalid IP version: {ip_version}") if not origins or not sources: @@ -171,7 +176,8 @@ def _load_routes_into_memory(self, redis_message=None): time.sleep(1) # pragma: no cover # Create a bit of randomness in when workers will update - time.sleep(random.random()) + if not getattr(sys, "_called_from_test", None): + time.sleep(random.random()) # pragma: no cover new_origin_route4_store = dict() new_origin_route6_store = dict() diff --git a/irrd/storage/tests/test_database.py b/irrd/storage/tests/test_database.py index 22474df47..e2d24b674 100644 --- a/irrd/storage/tests/test_database.py +++ b/irrd/storage/tests/test_database.py @@ -5,16 +5,14 @@ import pytest from IPy import IP from pytest import raises -from sqlalchemy.exc import ProgrammingError from irrd.routepref.status import RoutePreferenceStatus from irrd.rpki.status import RPKIStatus from irrd.scopefilter.status import ScopeFilterStatus from irrd.utils.test_utils import flatten_mock_calls -from .. import get_engine from ..database_handler import DatabaseHandler -from ..models import DatabaseOperation, JournalEntryOrigin, RPSLDatabaseObject +from ..models import DatabaseOperation, JournalEntryOrigin from ..preload import Preloader from ..queries import ( DatabaseStatusQuery, @@ -26,44 +24,17 @@ ) """ -These tests for the database use a live PostgreSQL database, -as it's rather complicated to mock, and mocking would not make it -a very useful test. Using in-memory SQLite is not an option due to -using specific PostgreSQL features. - -To improve performance, these tests do not run full migrations. - The tests also cover both database_handler.py and queries.py, as they closely interact with the database. """ @pytest.fixture() -def irrd_database(monkeypatch): - engine = get_engine() - # RPSLDatabaseObject.metadata.drop_all(engine) - try: - engine.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") - except ProgrammingError as pe: # pragma: no cover - print(f"WARNING: unable to create extension pgcrypto on the database. Queries may fail: {pe}") - - table_name = RPSLDatabaseObject.__tablename__ - if engine.dialect.has_table(engine, table_name): # pragma: no cover - raise Exception( - f"The database on URL {engine.url} already has a table named {table_name} - refusing " - "to overwrite existing database." - ) - RPSLDatabaseObject.metadata.create_all(engine) - +def irrd_db_mock_preload(irrd_db, monkeypatch): monkeypatch.setattr( "irrd.storage.database_handler.Preloader", lambda enable_queries: Mock(spec=Preloader) ) - yield None - - engine.dispose() - RPSLDatabaseObject.metadata.drop_all(engine) - # noinspection PyTypeChecker @pytest.fixture() @@ -98,7 +69,7 @@ class TestDatabaseHandlerLive: This test covers mainly DatabaseHandler and DatabaseStatusTracker. """ - def test_readonly(self, monkeypatch, irrd_database, config_override): + def test_readonly(self, monkeypatch, irrd_db_mock_preload, config_override): monkeypatch.setattr("irrd.storage.database_handler.MAX_RECORDS_BUFFER_BEFORE_INSERT", 1) rpsl_object_route_v4 = Mock( @@ -134,7 +105,7 @@ def test_readonly(self, monkeypatch, irrd_database, config_override): self.dh.upsert_rpsl_object(rpsl_object_route_v4, JournalEntryOrigin.auth_change) assert "readonly" in str(ex) - def test_duplicate_key_different_class(self, monkeypatch, irrd_database, config_override): + def test_duplicate_key_different_class(self, monkeypatch, irrd_db_mock_preload, config_override): monkeypatch.setattr("irrd.storage.database_handler.MAX_RECORDS_BUFFER_BEFORE_INSERT", 1) # tests for #560 @@ -182,7 +153,7 @@ def test_duplicate_key_different_class(self, monkeypatch, irrd_database, config_ self.dh.close() - def test_object_writing_and_status_checking(self, monkeypatch, irrd_database, config_override): + def test_object_writing_and_status_checking(self, monkeypatch, irrd_db_mock_preload, config_override): config_override( { "sources": { @@ -478,7 +449,7 @@ def test_object_writing_and_status_checking(self, monkeypatch, irrd_database, co ["", ({"route"},), {}], ] - def test_disable_journaling(self, monkeypatch, irrd_database): + def test_disable_journaling(self, monkeypatch, irrd_db_mock_preload): monkeypatch.setenv("IRRD_SOURCES_TEST_AUTHORITATIVE", "1") monkeypatch.setenv("IRRD_SOURCES_TEST_KEEP_JOURNAL", "1") @@ -529,7 +500,7 @@ def test_disable_journaling(self, monkeypatch, irrd_database): self.dh.close() - def test_roa_handling_and_query(self, irrd_database): + def test_roa_handling_and_query(self, irrd_db_mock_preload): self.dh = DatabaseHandler() self.dh.insert_roa_object( ip_version=4, prefix_str="192.0.2.0/24", asn=64496, max_length=28, trust_anchor="TEST TA" @@ -595,7 +566,7 @@ def test_roa_handling_and_query(self, irrd_database): self.dh.close() - def test_rpki_status_storage(self, monkeypatch, irrd_database, database_handler_with_route): + def test_rpki_status_storage(self, monkeypatch, irrd_db_mock_preload, database_handler_with_route): monkeypatch.setenv("IRRD_SOURCES_TEST_KEEP_JOURNAL", "1") dh = database_handler_with_route @@ -693,7 +664,7 @@ def test_rpki_status_storage(self, monkeypatch, irrd_database, database_handler_ dh.delete_journal_entries_before_date(datetime.utcnow(), "TEST") assert not list(dh.execute_query(RPSLDatabaseJournalQuery())) - def test_scopefilter_status_storage(self, monkeypatch, irrd_database, database_handler_with_route): + def test_scopefilter_status_storage(self, monkeypatch, irrd_db_mock_preload, database_handler_with_route): monkeypatch.setenv("IRRD_SOURCES_TEST_KEEP_JOURNAL", "1") dh = database_handler_with_route route_rpsl_objs = [ @@ -779,7 +750,9 @@ def test_scopefilter_status_storage(self, monkeypatch, irrd_database, database_h ) assert len(list(dh.execute_query(RPSLDatabaseJournalQuery()))) == 2 # no new entry since last test - def test_route_preference_status_storage(self, monkeypatch, irrd_database, database_handler_with_route): + def test_route_preference_status_storage( + self, monkeypatch, irrd_db_mock_preload, database_handler_with_route + ): monkeypatch.setenv("IRRD_SOURCES_TEST_KEEP_JOURNAL", "1") dh = database_handler_with_route existing_pk = list(dh.execute_query(RPSLDatabaseQuery()))[0]["pk"] @@ -878,7 +851,9 @@ def _clean_result(self, results): variable_fields = ["pk", "timestamp", "created", "updated", "last_error_timestamp"] return [{k: v for k, v in result.items() if k not in variable_fields} for result in list(results)] - def test_suspension(self, monkeypatch, irrd_database, database_handler_with_route, config_override): + def test_suspension( + self, monkeypatch, irrd_db_mock_preload, database_handler_with_route, config_override + ): monkeypatch.setenv("IRRD_SOURCES_TEST_KEEP_JOURNAL", "1") dh = database_handler_with_route route_object = next(dh.execute_query(RPSLDatabaseQuery())) @@ -907,7 +882,7 @@ def test_suspension(self, monkeypatch, irrd_database, database_handler_with_rout # noinspection PyTypeChecker class TestRPSLDatabaseQueryLive: - def test_matching_filters(self, irrd_database, database_handler_with_route): + def test_matching_filters(self, irrd_db_mock_preload, database_handler_with_route): self.dh = database_handler_with_route # Each of these filters should match @@ -933,7 +908,7 @@ def test_matching_filters(self, irrd_database, database_handler_with_route): self._assert_match(RPSLDatabaseQuery().scopefilter_status([ScopeFilterStatus.in_scope])) self._assert_match(RPSLDatabaseQuery().route_preference_status([RoutePreferenceStatus.visible])) - def test_chained_filters(self, irrd_database, database_handler_with_route): + def test_chained_filters(self, irrd_db_mock_preload, database_handler_with_route): self.dh = database_handler_with_route q = ( @@ -954,7 +929,7 @@ def test_chained_filters(self, irrd_database, database_handler_with_route): self._assert_match(RPSLDatabaseQuery().pk(pk)) self._assert_match(RPSLDatabaseQuery().pks([pk])) - def test_non_matching_filters(self, irrd_database, database_handler_with_route): + def test_non_matching_filters(self, irrd_db_mock_preload, database_handler_with_route): self.dh = database_handler_with_route # None of these should match self._assert_no_match(RPSLDatabaseQuery().pk(str(uuid.uuid4()))) @@ -977,7 +952,7 @@ def test_non_matching_filters(self, irrd_database, database_handler_with_route): self._assert_no_match(RPSLDatabaseQuery().scopefilter_status([ScopeFilterStatus.out_scope_as])) self._assert_no_match(RPSLDatabaseQuery().route_preference_status([RoutePreferenceStatus.suppressed])) - def test_ordering_sources(self, irrd_database, database_handler_with_route): + def test_ordering_sources(self, irrd_db_mock_preload, database_handler_with_route): self.dh = database_handler_with_route rpsl_object_2 = Mock( pk=lambda: "192.0.2.1/32,AS65537", @@ -1034,7 +1009,7 @@ def test_ordering_sources(self, irrd_database, database_handler_with_route): response_sources = [r["source"] for r in self.dh.execute_query(query)] assert response_sources == ["OTHER-SOURCE"] - def test_text_search_person_role(self, irrd_database): + def test_text_search_person_role(self, irrd_db_mock_preload): rpsl_object_person = Mock( pk=lambda: "PERSON", rpsl_object_class="person", @@ -1076,7 +1051,7 @@ def test_text_search_person_role(self, irrd_database): self.dh.close() - def test_more_less_specific_filters(self, irrd_database, database_handler_with_route): + def test_more_less_specific_filters(self, irrd_db_mock_preload, database_handler_with_route): self.dh = database_handler_with_route rpsl_route_more_specific_25_1 = Mock( pk=lambda: "192.0.2.0/25,AS65537", diff --git a/irrd/storage/tests/test_preload.py b/irrd/storage/tests/test_preload.py index 1fc241891..1400c3fdc 100644 --- a/irrd/storage/tests/test_preload.py +++ b/irrd/storage/tests/test_preload.py @@ -116,6 +116,7 @@ def test_routes_for_origins(self, mock_redis_keys): f"TEST2{REDIS_KEY_ORIGIN_SOURCE_SEPARATOR}AS65547": {"2001:db8::/32"}, }, ) + time.sleep(1) sources = ["TEST1", "TEST2"] assert preloader.routes_for_origins([], sources) == set() diff --git a/irrd/updates/email.py b/irrd/updates/email.py index 0e080800b..10c61287c 100644 --- a/irrd/updates/email.py +++ b/irrd/updates/email.py @@ -51,7 +51,9 @@ def handle_email_submission(email_txt: str) -> Optional[ChangeSubmissionHandler] """ ) else: - handler = ChangeSubmissionHandler().load_text_blob(msg.body, msg.pgp_fingerprint, request_meta) + handler = ChangeSubmissionHandler().load_text_blob( + msg.body, pgp_fingerprint=msg.pgp_fingerprint, request_meta=request_meta + ) logger.info(f"Processed e-mail {msg.message_id} from {msg.message_from}: {handler.status()}") logger.debug( f"Report for e-mail {msg.message_id} from {msg.message_from}:" diff --git a/irrd/updates/handler.py b/irrd/updates/handler.py index 10f01cc83..2952e53bd 100644 --- a/irrd/updates/handler.py +++ b/irrd/updates/handler.py @@ -8,6 +8,7 @@ from irrd.conf import get_setting from irrd.rpsl.rpsl_objects import RPSLMntner from irrd.storage.database_handler import DatabaseHandler +from irrd.storage.models import AuthUser from irrd.storage.queries import RPSLDatabaseQuery from irrd.utils import email @@ -31,6 +32,7 @@ def load_text_blob( self, object_texts_blob: str, pgp_fingerprint: Optional[str] = None, + internal_authenticated_user: Optional[AuthUser] = None, request_meta: Optional[Dict[str, Optional[str]]] = None, ): self.database_handler = DatabaseHandler() @@ -38,7 +40,7 @@ def load_text_blob( self._pgp_key_id = self._resolve_pgp_key_id(pgp_fingerprint) if pgp_fingerprint else None reference_validator = ReferenceValidator(self.database_handler) - auth_validator = AuthValidator(self.database_handler, self._pgp_key_id) + auth_validator = AuthValidator(self.database_handler, self._pgp_key_id, internal_authenticated_user) change_requests = parse_change_requests( object_texts_blob, self.database_handler, auth_validator, reference_validator ) diff --git a/irrd/updates/tests/test_handler.py b/irrd/updates/tests/test_handler.py index b17546207..98837a984 100644 --- a/irrd/updates/tests/test_handler.py +++ b/irrd/updates/tests/test_handler.py @@ -33,6 +33,9 @@ def prepare_mocks(monkeypatch, config_override): }, } ) + monkeypatch.setattr( + "irrd.updates.validators.RulesValidator._check_mntner_migrated", lambda slf, pk, source: False + ) mock_scopefilter = Mock(spec=ScopeFilterValidator) monkeypatch.setattr("irrd.updates.parser.ScopeFilterValidator", lambda: mock_scopefilter) diff --git a/irrd/updates/tests/test_parser.py b/irrd/updates/tests/test_parser.py index 5793fc867..11a2cf675 100644 --- a/irrd/updates/tests/test_parser.py +++ b/irrd/updates/tests/test_parser.py @@ -601,7 +601,7 @@ def test_check_auth_valid_update_mntner_submits_new_object_with_all_dummy_hash_v "authentication." ] - auth_pgp, auth_hash = splitline_unicodesafe(result_mntner.rpsl_obj_new.parsed_data["auth"]) + auth_pgp, auth_hash = splitline_unicodesafe("\n".join(result_mntner.rpsl_obj_new.parsed_data["auth"])) assert auth_pgp == "PGPKey-80F238C6" assert auth_hash.startswith("BCRYPT-PW ") assert bcrypt.verify("crypt-password", auth_hash[10:]) diff --git a/irrd/updates/tests/test_validators.py b/irrd/updates/tests/test_validators.py index 67e5ad9a4..94155f630 100644 --- a/irrd/updates/tests/test_validators.py +++ b/irrd/updates/tests/test_validators.py @@ -1,9 +1,10 @@ import itertools +from unittest import mock from unittest.mock import Mock import pytest -from irrd.conf import AUTH_SET_CREATION_COMMON_KEY +from irrd.conf import AUTH_SET_CREATION_COMMON_KEY, RPSL_MNTNER_AUTH_INTERNAL from irrd.rpsl.rpsl_objects import rpsl_object_from_text from irrd.storage.database_handler import DatabaseHandler from irrd.storage.queries import RPSLDatabaseSuspendedQuery @@ -20,7 +21,9 @@ ) from irrd.utils.test_utils import flatten_mock_calls from irrd.utils.text import remove_auth_hashes +from irrd.vendor.mock_alchemy.mocking import UnifiedAlchemyMagicMock +from ...storage.models import AuthMntner, AuthUser from ..validators import AuthValidator, RulesValidator VALID_PW = "override-password" @@ -65,6 +68,23 @@ def test_override_valid(self, prepare_mocks, config_override): assert result.is_valid(), result.error_messages assert result.used_override + def test_override_internal_auth(self, prepare_mocks, config_override): + validator, mock_dq, mock_dh = prepare_mocks + mock_dh.execute_query = lambda q: [] + person = rpsl_object_from_text(SAMPLE_PERSON) + + user = AuthUser(override=True) + validator = AuthValidator(mock_dh, keycert_obj_pk=None, internal_authenticated_user=user) + + result = validator.process_auth(person, None) + assert result.is_valid(), result.error_messages + assert result.used_override + + user.override = False + result = validator.process_auth(person, None) + assert not result.is_valid() + assert not result.used_override + def test_override_invalid_or_missing(self, prepare_mocks, config_override): # This test mostly ignores the regular process that happens # after override validation fails. @@ -265,6 +285,38 @@ def test_modify_mntner(self, prepare_mocks, config_override): "submitted. Either submit only full hashes, or a single password." } + def test_modify_mntner_internal_auth(self, prepare_mocks, config_override): + validator, mock_dq, mock_dh = prepare_mocks + mntner = rpsl_object_from_text(SAMPLE_MNTNER) + person = rpsl_object_from_text(SAMPLE_PERSON) + mock_dh.execute_query = lambda q: [] + + mock_mntners = [AuthMntner(rpsl_mntner_pk="TEST-MNT", rpsl_mntner_source="TEST")] + + # Modifying the mntner itself requires user management, this should work + user = AuthUser(mntners_user_management=mock_mntners) + validator = AuthValidator(mock_dh, keycert_obj_pk=None, internal_authenticated_user=user) + result = validator.process_auth(mntner, mntner) + assert result.is_valid() + + # Modifying mntner should fail without user_management + user = AuthUser(mntners=mock_mntners) + validator = AuthValidator(mock_dh, keycert_obj_pk=None, internal_authenticated_user=user) + result = validator.process_auth(mntner, mntner) + assert not result.is_valid() + + # Modifying different object should succeed without user_management, matching mntner PK + user = AuthUser(mntners=mock_mntners) + validator = AuthValidator(mock_dh, keycert_obj_pk=None, internal_authenticated_user=user) + result = validator.process_auth(person, person) + assert result.is_valid() + + # Mntner with entirely different PK should not work + user = AuthUser(mntners=[AuthMntner(rpsl_mntner_pk="INVALID-MNT", rpsl_mntner_source="TEST")]) + validator = AuthValidator(mock_dh, keycert_obj_pk=None, internal_authenticated_user=user) + result = validator.process_auth(person, person) + assert not result.is_valid() + def test_related_route_exact_inetnum(self, prepare_mocks, config_override): validator, mock_dq, mock_dh = prepare_mocks route = rpsl_object_from_text(SAMPLE_ROUTE) @@ -651,8 +703,11 @@ def prepare_mocks(self, monkeypatch): validator = RulesValidator(mock_dh) yield validator, mock_dsq, mock_dh - def test_mntner_create(self, prepare_mocks): + def test_check_suspended_mntner_with_same_pk(self, prepare_mocks, monkeypatch): validator, mock_dsq, mock_dh = prepare_mocks + monkeypatch.setattr( + "irrd.updates.validators.RulesValidator._check_mntner_migrated", lambda slf, pk, source: False + ) person = rpsl_object_from_text(SAMPLE_PERSON) mntner = rpsl_object_from_text(SAMPLE_MNTNER) @@ -688,3 +743,61 @@ def test_mntner_create(self, prepare_mocks): ["sources", (["TEST"],), {}], ["first_only", (), {}], ] + + def test_check_mntner_migrated(self, prepare_mocks, monkeypatch): + validator, mock_dsq, mock_dh = prepare_mocks + mock_dh._connection = None + monkeypatch.setattr( + "irrd.updates.validators.RulesValidator._check_suspended_mntner_with_same_pk", + lambda slf, pk, source: False, + ) + mock_sa_session = UnifiedAlchemyMagicMock( + data=[ + ( + [ + mock.call.query(AuthMntner), + mock.call.filter( + AuthMntner.rpsl_mntner_pk == "TEST-MNT", AuthMntner.rpsl_mntner_source == "TEST" + ), + ], + [AuthMntner(rpsl_mntner_pk="TEST-MNT")], + ) + ] + ) + monkeypatch.setattr("irrd.updates.validators.saorm.Session", lambda bind: mock_sa_session) + + person = rpsl_object_from_text(SAMPLE_PERSON) + mntner = rpsl_object_from_text(SAMPLE_MNTNER) + mntner_internal = rpsl_object_from_text(SAMPLE_MNTNER + f"auth: {RPSL_MNTNER_AUTH_INTERNAL}") + + mock_sa_session.count = lambda: False + assert validator.validate(person, UpdateRequestType.CREATE).is_valid() + assert validator.validate(mntner, UpdateRequestType.MODIFY).is_valid() + + mock_sa_session.filter.assert_has_calls( + [ + mock.call(AuthMntner.rpsl_mntner_pk == "TEST-MNT", AuthMntner.rpsl_mntner_source == "TEST"), + ] + ) + + validator._check_mntner_migrated.cache_clear() + mock_sa_session.count = lambda: True + assert validator.validate(person, UpdateRequestType.CREATE).is_valid() + invalid = validator.validate(mntner, UpdateRequestType.CREATE) + assert not invalid.is_valid() + assert invalid.error_messages == { + "This maintainer is migrated and must include the IRRD-INTERNAL-AUTH method." + } + + validator._check_mntner_migrated.cache_clear() + mock_sa_session.count = lambda: True + assert validator.validate(mntner_internal, UpdateRequestType.MODIFY).is_valid() + + validator._check_mntner_migrated.cache_clear() + mock_sa_session.count = lambda: False + assert validator.validate(person, UpdateRequestType.CREATE).is_valid() + invalid = validator.validate(mntner_internal, UpdateRequestType.CREATE) + assert not invalid.is_valid() + assert invalid.error_messages == { + "This maintainer is not migrated, and therefore can not use the IRRD-INTERNAL-AUTH method." + } diff --git a/irrd/updates/validators.py b/irrd/updates/validators.py index 1123519f8..b093c7346 100644 --- a/irrd/updates/validators.py +++ b/irrd/updates/validators.py @@ -3,13 +3,15 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union +import sqlalchemy.orm as saorm from ordered_set import OrderedSet from passlib.hash import md5_crypt -from irrd.conf import get_setting +from irrd.conf import RPSL_MNTNER_AUTH_INTERNAL, get_setting from irrd.rpsl.parser import RPSLObject from irrd.rpsl.rpsl_objects import RPSLMntner, RPSLSet, rpsl_object_from_text from irrd.storage.database_handler import DatabaseHandler +from irrd.storage.models import AuthMntner, AuthUser from irrd.storage.queries import RPSLDatabaseQuery, RPSLDatabaseSuspendedQuery from .parser_state import RPSLSetAutnumAuthenticationMode, UpdateRequestType @@ -164,13 +166,19 @@ class AuthValidator: overrides: List[str] keycert_obj_pk: Optional[str] = None - def __init__(self, database_handler: DatabaseHandler, keycert_obj_pk=None) -> None: + def __init__( + self, + database_handler: DatabaseHandler, + keycert_obj_pk=None, + internal_authenticated_user: Optional[AuthUser] = None, + ) -> None: self.database_handler = database_handler self.passwords = [] self.overrides = [] self._mntner_db_cache: Set[RPSLMntner] = set() self._pre_approved: Set[str] = set() self.keycert_obj_pk = keycert_obj_pk + self._internal_authenticated_user = internal_authenticated_user def pre_approve(self, presumed_valid_new_mntners: List[RPSLMntner]) -> None: """ @@ -208,7 +216,7 @@ def process_auth( mntners_new = rpsl_obj_new.parsed_data["mnt-by"] logger.debug(f"Checking auth for new object {rpsl_obj_new}, mntners in new object: {mntners_new}") - valid, mntner_objs_new = self._check_mntners(mntners_new, source) + valid, mntner_objs_new = self._check_mntners(rpsl_obj_new, mntners_new, source) if not valid: self._generate_failure_message(result, mntners_new, rpsl_obj_new) @@ -218,7 +226,7 @@ def process_auth( f"Checking auth for current object {rpsl_obj_current}, " f"mntners in current object: {mntners_current}" ) - valid, mntner_objs_current = self._check_mntners(mntners_current, source) + valid, mntner_objs_current = self._check_mntners(rpsl_obj_new, mntners_current, source) if not valid: self._generate_failure_message(result, mntners_current, rpsl_obj_new) @@ -232,7 +240,7 @@ def process_auth( f"Checking auth for related object {related_object_class} / " f"{related_pk} with mntners {related_mntner_list}" ) - valid, mntner_objs_related = self._check_mntners(related_mntner_list, source) + valid, mntner_objs_related = self._check_mntners(rpsl_obj_new, related_mntner_list, source) if not valid: self._generate_failure_message( result, related_mntner_list, rpsl_obj_new, related_object_class, related_pk @@ -261,12 +269,24 @@ def process_auth( "Object submitted with dummy hash values, but multiple or no passwords " "submitted. Either submit only full hashes, or a single password." ) - elif not rpsl_obj_new.verify_auth(self.passwords, self.keycert_obj_pk): + elif not any( + [ + rpsl_obj_new.verify_auth(self.passwords, self.keycert_obj_pk), + self._mntner_matches_internal_auth(rpsl_obj_new, rpsl_obj_new.pk(), source), + ] + ): result.error_messages.add("Authorisation failed for the auth methods on this mntner object.") return result def check_override(self) -> bool: + if self._internal_authenticated_user and self._internal_authenticated_user.override: + logger.info( + "Authenticated by valid override from internally authenticated " + f"user {self._internal_authenticated_user}" + ) + return True + override_hash = get_setting("auth.override_password") if override_hash: for override in self.overrides: @@ -284,7 +304,9 @@ def check_override(self) -> bool: logger.info("Ignoring override password, auth.override_password not set.") return False - def _check_mntners(self, mntner_pk_list: List[str], source: str) -> Tuple[bool, List[RPSLMntner]]: + def _check_mntners( + self, rpsl_obj_new: RPSLObject, mntner_pk_list: List[str], source: str + ) -> Tuple[bool, List[RPSLMntner]]: """ Check whether authentication passes for a list of maintainers. @@ -309,7 +331,8 @@ def _check_mntners(self, mntner_pk_list: List[str], source: str) -> Tuple[bool, mntner_objs += retrieved_mntner_objs for mntner_name in mntner_pk_list: - if mntner_name in self._pre_approved: + matches_internal_auth = self._mntner_matches_internal_auth(rpsl_obj_new, mntner_name, source) + if mntner_name in self._pre_approved or matches_internal_auth: return True, mntner_objs for mntner_obj in mntner_objs: @@ -318,6 +341,20 @@ def _check_mntners(self, mntner_pk_list: List[str], source: str) -> Tuple[bool, return False, mntner_objs + def _mntner_matches_internal_auth(self, rpsl_obj_new: RPSLObject, rpsl_pk: str, source: str) -> bool: + if not self._internal_authenticated_user: + return False + if rpsl_obj_new.pk() == rpsl_pk and rpsl_obj_new.source() == source: + user_mntner_set = self._internal_authenticated_user.mntners_user_management + else: + user_mntner_set = self._internal_authenticated_user.mntners + return any( + [ + rpsl_pk == mntner.rpsl_mntner_pk and source == mntner.rpsl_mntner_source + for mntner in user_mntner_set + ] + ) + def _generate_failure_message( self, result: ValidatorResult, @@ -446,6 +483,7 @@ def __init__(self, database_handler: DatabaseHandler) -> None: def validate(self, rpsl_obj: RPSLObject, request_type: UpdateRequestType) -> ValidatorResult: result = ValidatorResult() + if ( request_type == UpdateRequestType.CREATE and rpsl_obj.rpsl_object_class == "mntner" @@ -454,9 +492,32 @@ def validate(self, rpsl_obj: RPSLObject, request_type: UpdateRequestType) -> Val result.error_messages.add( f"A suspended mntner with primary key {rpsl_obj.pk()} already exists for {rpsl_obj.source()}" ) + + if isinstance(rpsl_obj, RPSLMntner): + is_migrated = self._check_mntner_migrated(rpsl_obj.pk(), rpsl_obj.source()) + has_internal_auth = rpsl_obj.has_internal_auth() + if is_migrated and not has_internal_auth: + result.error_messages.add( + f"This maintainer is migrated and must include the {RPSL_MNTNER_AUTH_INTERNAL} method." + ) + elif not is_migrated and has_internal_auth: + result.error_messages.add( + "This maintainer is not migrated, and therefore can not use the" + f" {RPSL_MNTNER_AUTH_INTERNAL} method." + ) + return result @functools.lru_cache(maxsize=50) def _check_suspended_mntner_with_same_pk(self, pk: str, source: str) -> bool: q = RPSLDatabaseSuspendedQuery().object_classes(["mntner"]).rpsl_pk(pk).sources([source]).first_only() return bool(list(self.database_handler.execute_query(q))) + + @functools.lru_cache(maxsize=50) + def _check_mntner_migrated(self, pk: str, source: str) -> bool: + session = saorm.Session(bind=self.database_handler._connection) + query = session.query(AuthMntner).filter( + AuthMntner.rpsl_mntner_pk == pk, + AuthMntner.rpsl_mntner_source == source, + ) + return bool(query.count()) diff --git a/irrd/utils/email.py b/irrd/utils/email.py index 5ad246871..8d5d25585 100644 --- a/irrd/utils/email.py +++ b/irrd/utils/email.py @@ -81,6 +81,7 @@ def send_email(recipient, subject, body) -> None: msg["From"] = get_setting("email.from") msg["To"] = recipient + assert msg["From"] s = SMTP(get_setting("email.smtp")) s.send_message(msg) s.quit() diff --git a/irrd/utils/factories.py b/irrd/utils/factories.py new file mode 100644 index 000000000..f8ef9ae95 --- /dev/null +++ b/irrd/utils/factories.py @@ -0,0 +1,75 @@ +import factory.alchemy +from webauthn import base64url_to_bytes + +from irrd.storage.models import ( + AuthMntner, + AuthPermission, + AuthUser, + AuthWebAuthn, + RPSLDatabaseObject, +) +from irrd.webui.auth.users import password_handler + +SAMPLE_USER_PASSWORD = "password" +SAMPLE_USER_TOTP_TOKEN = "4U47VXPTM3GGM2MFDLN33G6XM4RIC6UT" + + +def set_factory_session(session): + factories = [ + klass + for klass in factory.alchemy.SQLAlchemyModelFactory.__subclasses__() + if klass.__module__.startswith("irrd.") + ] + for factorie in factories: + factorie._meta.sqlalchemy_session = session + + +class AuthUserFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = AuthUser + sqlalchemy_session_persistence = "commit" + + email = factory.Sequence(lambda n: "user-%s@example.com" % n) + name = "name" + totp_secret = SAMPLE_USER_TOTP_TOKEN + + @factory.lazy_attribute + def password(self): + return password_handler.hash(SAMPLE_USER_PASSWORD) + + +class AuthWebAuthnFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = AuthWebAuthn + sqlalchemy_session_persistence = "commit" + + name = "webauthn-key" + credential_id = base64url_to_bytes("ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s") + credential_public_key = base64url_to_bytes( + "pAEDAzkBACBZAQDfV20epzvQP-HtcdDpX-cGzdOxy73WQEvsU7Dnr9UWJophEfpngouvgnRLXaEUn_d8HGkp_HIx8rrpkx4BVs6X_B6ZjhLlezjIdJbLbVeb92BaEsmNn1HW2N9Xj2QM8cH-yx28_vCjf82ahQ9gyAr552Bn96G22n8jqFRQKdVpO-f-bvpvaP3IQ9F5LCX7CUaxptgbog1SFO6FI6ob5SlVVB00lVXsaYg8cIDZxCkkENkGiFPgwEaZ7995SCbiyCpUJbMqToLMgojPkAhWeyktu7TlK6UBWdJMHc3FPAIs0lH_2_2hKS-mGI1uZAFVAfW1X-mzKL0czUm2P1UlUox7IUMBAAE" + ) + credential_sign_count = 0 + + +class AuthMntnerFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = AuthMntner + sqlalchemy_session_persistence = "commit" + + rpsl_mntner_pk = ("TEST-MNT",) + rpsl_mntner_source = ("TEST",) + + @factory.lazy_attribute + def rpsl_mntner_obj_id(self): + rpsl_mntner = ( + AuthMntnerFactory._meta.sqlalchemy_session.query(RPSLDatabaseObject) + .filter(RPSLDatabaseObject.object_class == "mntner") + .one() + ) + return str(rpsl_mntner.pk) + + +class AuthPermissionFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = AuthPermission + sqlalchemy_session_persistence = "commit" diff --git a/irrd/utils/misc.py b/irrd/utils/misc.py index ee40f0f35..9698a6831 100644 --- a/irrd/utils/misc.py +++ b/irrd/utils/misc.py @@ -1,6 +1,9 @@ +import hashlib import itertools from typing import Iterable +from irrd.conf import get_setting + def chunked_iterable(iterable: Iterable, size: int) -> Iterable: """ @@ -12,3 +15,13 @@ def chunked_iterable(iterable: Iterable, size: int) -> Iterable: if not chunk: break yield chunk + + +def secret_key_derive(scope: str): + """ + Return the secret key for a particular scope. + This is derived from the scope, an otherwise meaningless string, + and the user configured secret key. + """ + key_base = scope.encode("utf-8") + get_setting("secret_key").encode("utf-8") + return str(hashlib.sha512(key_base).hexdigest()) diff --git a/irrd/utils/process_support.py b/irrd/utils/process_support.py index c209de7f6..ac6e3fa79 100644 --- a/irrd/utils/process_support.py +++ b/irrd/utils/process_support.py @@ -51,4 +51,4 @@ def sigusr1_handler(signal, frame): code += traceback.format_list(traceback.extract_stack(stack)) logger.info("".join(code)) - signal.signal(signal.SIGUSR1, sigusr1_handler) + # signal.signal(signal.SIGUSR1, sigusr1_handler) diff --git a/irrd/utils/tests/test_email.py b/irrd/utils/tests/test_email.py index fff1def93..cbbcc15c9 100644 --- a/irrd/utils/tests/test_email.py +++ b/irrd/utils/tests/test_email.py @@ -584,7 +584,8 @@ def test_invalid_blank_body(self): class TestSendEmail: - def test_send_email(self, monkeypatch): + def test_send_email(self, monkeypatch, config_override): + config_override({"email": {"from": "irrd@example.net"}}) mock_smtp = Mock() monkeypatch.setattr("irrd.utils.email.SMTP", lambda server: mock_smtp) send_email("Sasha ", "subject", "body") @@ -599,7 +600,7 @@ def test_send_email(self, monkeypatch): assert mock_smtp.mock_calls[1][0] == "quit" def test_send_email_with_recipient_override(self, monkeypatch, config_override): - config_override({"email": {"recipient_override": "override@example.com"}}) + config_override({"email": {"recipient_override": "override@example.com", "from": "irrd@example.net"}}) mock_smtp = Mock() monkeypatch.setattr("irrd.utils.email.SMTP", lambda server: mock_smtp) send_email("Sasha ", "subject", "body") diff --git a/irrd/utils/tests/test_misc.py b/irrd/utils/tests/test_misc.py index f3d5bbb87..86666ef1b 100644 --- a/irrd/utils/tests/test_misc.py +++ b/irrd/utils/tests/test_misc.py @@ -1,6 +1,11 @@ -from ..misc import chunked_iterable +from ..misc import chunked_iterable, secret_key_derive def test_chunked_iterable(): inp = range(10) assert list(chunked_iterable(inp, 3)) == [(0, 1, 2), (3, 4, 5), (6, 7, 8), (9,)] + + +def test_secret_key_derive(config_override): + config_override({"secret_key": "secret"}) + assert secret_key_derive("scope").startswith("eeae975812") diff --git a/irrd/utils/text.py b/irrd/utils/text.py index ab4110ad2..ce4468f14 100644 --- a/irrd/utils/text.py +++ b/irrd/utils/text.py @@ -2,7 +2,7 @@ from typing import Iterator, List, Optional, Set, TextIO, Union from irrd.conf import PASSWORD_HASH_DUMMY_VALUE -from irrd.rpsl.passwords import PASSWORD_HASHERS_ALL +from irrd.rpsl.auth import PASSWORD_HASHERS_ALL re_remove_passwords = re.compile(r"(%s)[^\n]+" % "|".join(PASSWORD_HASHERS_ALL.keys()), flags=re.IGNORECASE) re_remove_last_modified = re.compile(r"^last-modified: [^\n]+\n", flags=re.MULTILINE) diff --git a/irrd/vendor/mock_alchemy/__init__.py b/irrd/vendor/mock_alchemy/__init__.py new file mode 100644 index 000000000..dea883b0a --- /dev/null +++ b/irrd/vendor/mock_alchemy/__init__.py @@ -0,0 +1,2 @@ +"""SQLAlchemy mock helpers.""" +from __future__ import absolute_import, print_function, unicode_literals diff --git a/irrd/vendor/mock_alchemy/comparison.py b/irrd/vendor/mock_alchemy/comparison.py new file mode 100644 index 000000000..f2b5fa0a9 --- /dev/null +++ b/irrd/vendor/mock_alchemy/comparison.py @@ -0,0 +1,178 @@ +"""A module for comparing SQLAlchemy expressions.""" +from __future__ import absolute_import, annotations, print_function, unicode_literals + +import itertools +from collections.abc import Mapping +from typing import Any, Optional +from unittest import mock + +from sqlalchemy import func +from sqlalchemy.sql.expression import column, or_ + +from .utils import match_type + +ALCHEMY_UNARY_EXPRESSION_TYPE = type(column("").asc()) +ALCHEMY_BINARY_EXPRESSION_TYPE = type(column("") == "") +ALCHEMY_BOOLEAN_CLAUSE_LIST = type(or_(column("") == "", column("").is_(None))) +ALCHEMY_FUNC_TYPE = type(func.dummy(column(""))) +ALCHEMY_LABEL_TYPE = type(column("").label("")) +ALCHEMY_TYPES = ( + ALCHEMY_UNARY_EXPRESSION_TYPE, + ALCHEMY_BINARY_EXPRESSION_TYPE, + ALCHEMY_BOOLEAN_CLAUSE_LIST, + ALCHEMY_FUNC_TYPE, + ALCHEMY_LABEL_TYPE, +) + + +class PrettyExpression(object): + """Wrapper around given expression with pretty representations. + + Wraps any expression in order to represent in a string in a pretty + fashion. This also enables easier comparison through string representations. + + Attributes: + expr: Some kind of expression or a PrettyExpression itself. + + For example:: + + >>> c = column('column') + >>> PrettyExpression(c == 5) + BinaryExpression(sql='"column" = :column_1', params={'column_1': 5}) + >>> PrettyExpression(10) + 10 + >>> PrettyExpression(PrettyExpression(15)) + 15 + """ + + __slots__ = ["expr"] + + def __init__(self, e: Any) -> None: + """Create a PrettyExpression using an expression.""" + if isinstance(e, PrettyExpression): + e = e.expr + self.expr = e + + def __repr__(self) -> str: + """Get the string representation of a PrettyExpression.""" + if not isinstance(self.expr, ALCHEMY_TYPES): + return repr(self.expr) + + compiled = self.expr.compile() + + return "{}(sql={!r}, params={!r})".format( + self.expr.__class__.__name__, + match_type(str(compiled).replace("\n", " "), str), + {match_type(k, str): v for k, v in compiled.params.items()}, + ) + + +class ExpressionMatcher(PrettyExpression): + """Matcher for comparing SQLAlchemy expressions. + + Similar to + http://www.voidspace.org.uk/python/mock/examples.html#more-complex-argument-matching + + For example:: + + >>> c = column('column') + >>> c2 = column('column2') + >>> l1 = c.label('foo') + >>> l2 = c.label('foo') + >>> l3 = c.label('bar') + >>> l4 = c2.label('foo') + >>> e1 = c.in_(['foo', 'bar']) + >>> e2 = c.in_(['foo', 'bar']) + >>> e3 = c.in_(['cat', 'dog']) + >>> e4 = c == 'foo' + >>> e5 = func.lower(c) + + >>> ExpressionMatcher(e1) == mock.ANY + True + >>> ExpressionMatcher(e1) == 5 + False + >>> ExpressionMatcher(e1) == e2 + True + >>> ExpressionMatcher(e1) != e2 + False + >>> ExpressionMatcher(e1) == e3 + False + >>> ExpressionMatcher(e1) == e4 + False + >>> ExpressionMatcher(e5) == func.lower(c) + True + >>> ExpressionMatcher(e5) == func.upper(c) + False + >>> ExpressionMatcher(e1) == ExpressionMatcher(e2) + True + >>> ExpressionMatcher(c) == l1 + False + >>> ExpressionMatcher(l1) == l2 + True + >>> ExpressionMatcher(l1) == l3 + True + >>> ExpressionMatcher(l1) == l4 + False + + It also works with nested structures:: + + >>> ExpressionMatcher([c == 'foo']) == [c == 'foo'] + True + >>> a = {'foo': c == 'foo', 'bar': 5, 'hello': 'world'} + >>> ExpressionMatcher(a) == a + True + """ + + def __eq__(self, other: Any) -> bool: + """Compares two expressions using the ExpressionMatcher.""" + if isinstance(other, type(self)): + other = other.expr + + # if the right hand side is mock.ANY, + # mocks comparison will not be used hence + # we hard-code comparison here + if isinstance(self.expr, type(mock.ANY)) or isinstance(other, type(mock.ANY)): + return True + + # handle string comparison bytes vs unicode in dict keys + if isinstance(self.expr, str) and isinstance(other, str): + other = match_type(other, type(self.expr)) + + # compare sqlalchemy public api attributes + if type(self.expr) is not type(other): + return False + + equal = self._equals_alchemy(other) + if equal is not None: + return equal + + expr_compiled = self.expr.compile() + other_compiled = other.compile() + + if str(expr_compiled) != str(other_compiled): + return False + if expr_compiled.params != other_compiled.params: + return False + + return True + + def _equals_alchemy(self, other: Any) -> Optional[bool]: + """Compares for equality in the case of non ALCHEMY_TYPES.""" + if not isinstance(self.expr, ALCHEMY_TYPES): + + def _(v: Any) -> Any: + return type(self)(v) + + if isinstance(self.expr, (list, tuple)): + return all(_(i) == j for i, j in itertools.zip_longest(self.expr, other)) + + elif isinstance(self.expr, Mapping): + same_keys = self.expr.keys() == other.keys() + return same_keys and all(_(self.expr[k]) == other[k] for k in self.expr.keys()) + + else: + return self.expr is other or self.expr == other + + def __ne__(self, other: Any) -> bool: + """Compares an expression to determine inequality.""" + return not (self == other) diff --git a/irrd/vendor/mock_alchemy/mocking.py b/irrd/vendor/mock_alchemy/mocking.py new file mode 100644 index 000000000..1f9c44d70 --- /dev/null +++ b/irrd/vendor/mock_alchemy/mocking.py @@ -0,0 +1,672 @@ +"""A module for basic mocking of SQLAlchemy sessions and calls.""" +from __future__ import absolute_import, print_function, unicode_literals + +from functools import partial +from itertools import chain, takewhile +from typing import ( + Any, + Callable, + Dict, + Iterator, + List, + Optional, + Sequence, + Set, + overload, +) +from unittest import mock + +from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound + +from .comparison import ExpressionMatcher +from .utils import ( + build_identity_map, + copy_and_update, + get_item_attr, + get_scalar, + indexof, + raiser, + setattr_tmp, +) + +Call = type(mock.call) + + +class UnorderedTuple(tuple): + """Same as tuple except in comparison order does not matter. + + A tuple in which order does not matter for equality. It compares + by remove elements from the other tuple. + + For example:: + + >>> UnorderedTuple((1, 2, 3)) == (3, 2, 1) + True + """ + + def __eq__(self, other: tuple) -> bool: + """Compares another tuple for equality.""" + if len(self) != len(other): + return False + + other = list(other) + for i in self: + try: + other.remove(i) + except ValueError: + return False + + return True + + +class UnorderedCall(Call): + """Same as Call except in comparison order of parameters does not matter. + + A ``mock.Call`` subclass that ensures that eqaulity does not depend on order. + This isued to check if SQLAlchemy calls match up regardless of order. For example, + this is useful in the case of filtering when ``.filter(y == 4).filter(y == 2)`` + is the same as ``.filter(y == 2).filter(y == 4)``. + + For example:: + + >>> a = ((1, 2, 3), {'hello': 'world'}) + >>> b = ((3, 2, 1), {'hello': 'world'}) + >>> UnorderedCall(a) == Call(b) + True + """ + + def __eq__(self, other: Call) -> bool: + """Compares another call for equality.""" + _other = list(other) + _other[-2] = UnorderedTuple(other[-2]) + other = Call( + tuple(_other), + **{k.replace("_mock_", ""): v for k, v in vars(other).items()}, + ) + + return super(UnorderedCall, self).__eq__(other) + + +def sqlalchemy_call(call: Call, with_name: bool = False, base_call: Any = Call) -> Any: + """Convert ``mock.call()`` into call. + + Convert ``mock.call()`` into call with all parameters + wrapped with ``ExpressionMatcher``. This is useful for comparing + SQLAlchemy statements for equality. + + Args: + call: The call to convert. + with_name: Whether to convert the name of the call. + base_call: The type of call to convert into. + + Returns: + Returns the converted call of the type ``base_call``. + + For example:: + + >>> args, kwargs = sqlalchemy_call(mock.call(5, foo='bar')) + >>> isinstance(args[0], ExpressionMatcher) + True + >>> isinstance(kwargs['foo'], ExpressionMatcher) + True + """ + try: + args, kwargs = call + except ValueError: + name, args, kwargs = call + else: + name = "" + + args = tuple([ExpressionMatcher(i) for i in args]) + kwargs = {k: ExpressionMatcher(v) for k, v in kwargs.items()} + + if with_name: + return base_call((name, args, kwargs)) + else: + return base_call((args, kwargs), two=True) + + +class AlchemyMagicMock(mock.MagicMock): + """Compares SQLAlchemy expressions for simple asserts. + + MagicMock for SQLAlchemy which can compare alchemys expressions in assertions. + + For example:: + + >>> from sqlalchemy import or_ + >>> from sqlalchemy.sql.expression import column + >>> c = column('column') + >>> s = AlchemyMagicMock() + + >>> _ = s.filter(or_(c == 5, c == 10)) + + >>> _ = s.filter.assert_called_once_with(or_(c == 5, c == 10)) + >>> _ = s.filter.assert_any_call(or_(c == 5, c == 10)) + >>> _ = s.filter.assert_has_calls([mock.call(or_(c == 5, c == 10))]) + + >>> s.reset_mock() + >>> _ = s.filter(c == 5) + >>> _ = s.filter.assert_called_once_with(c == 10) + Traceback (most recent call last): + ... + AssertionError: expected call not found. + Expected: filter(BinaryExpression(sql='"column" = :column_1', \ + params={'column_1': 10})) + Actual: filter(BinaryExpression(sql='"column" = :column_1', \ + params={'column_1': 5})) + """ + + @overload + def __init__( + self, + spec: Optional[Any] = ..., + side_effect: Optional[Any] = ..., + return_value: Any = ..., + wraps: Optional[Any] = ..., + name: Optional[Any] = ..., + spec_set: Optional[Any] = ..., + parent: Optional[Any] = ..., + _spec_state: Optional[Any] = ..., + _new_name: Any = ..., + _new_parent: Optional[Any] = ..., + **kwargs: Any, + ) -> None: + ... # pragma: no cover + + def __init__(self, *args, **kwargs) -> None: + """Creates AlchemyMagicMock that can be used as limited SQLAlchemy session.""" + kwargs.setdefault("__name__", "Session") + super(AlchemyMagicMock, self).__init__(*args, **kwargs) + + def _format_mock_call_signature(self, args: Any, kwargs: Any) -> str: + """Formats the mock call into a string.""" + name = self._mock_name or "mock" + args, kwargs = sqlalchemy_call(mock.call(*args, **kwargs)) + return mock._format_call_signature(name, args, kwargs) + + def assert_called_with(self, *args: Any, **kwargs: Any) -> None: + """Assert for a specific call to have happened.""" + args, kwargs = sqlalchemy_call(mock.call(*args, **kwargs)) + return super(AlchemyMagicMock, self).assert_called_with(*args, **kwargs) + + def assert_any_call(self, *args: Any, **kwargs: Any) -> None: + """Assert for a specific call to have happened.""" + args, kwargs = sqlalchemy_call(mock.call(*args, **kwargs)) + with setattr_tmp( + self, + "call_args_list", + [sqlalchemy_call(i) for i in self.call_args_list], + ): + return super(AlchemyMagicMock, self).assert_any_call(*args, **kwargs) + + def assert_has_calls(self, calls: List[Call], any_order: bool = False) -> None: + """Assert for a list of calls to have happened.""" + calls = [sqlalchemy_call(i) for i in calls] + with setattr_tmp( + self, + "mock_calls", + type(self.mock_calls)([sqlalchemy_call(i) for i in self.mock_calls]), + ): + return super(AlchemyMagicMock, self).assert_has_calls(calls, any_order) + + +class UnifiedAlchemyMagicMock(AlchemyMagicMock): + """A MagicMock that combines SQLALchemy to mock a session. + + MagicMock which unifies common SQLALchemy session functions for easier assertions. + + Attributes: + boundary: A dict of SQLAlchemy functions or statements that get + or retreive data from calls. This dictionary has values + that are the callable functions to process the function calls. + unify: A dict of SQLAlchemy functions or statements that are to + unifying expressions together. This dictionary has values + that are the callable functions to process the function calls. Note + that across query calls data and, as such, these calls are not unified. + Check out the examples for this class for more detail about this + limitation. + mutate: A set of operations that mutate data. The currently supported + operations include ``.delete()``, ``.add()``, and ``.add_all()``. + More operations are planned and this is a future area of work. + + For example:: + + >>> from sqlalchemy.sql.expression import column + >>> c = column('column') + + >>> s = UnifiedAlchemyMagicMock() + >>> s.query(None).filter(c == 'one').filter(c == 'two').all() + [] + >>> s.query(None).filter(c == 'three').filter(c == 'four').all() + [] + >>> s.filter.call_count + 2 + >>> s.filter.assert_any_call(c == 'one', c == 'two') + >>> s.filter.assert_any_call(c == 'three', c == 'four') + + In addition, mock data be specified to stub real DB interactions. + Result-sets are specified per filtering criteria so that unique data + can be returned depending on query/filter/options criteria. + Data is given as a list of ``(criteria, result)`` tuples where ``criteria`` + is a list of calls. + Reason for passing data as a list vs a dict is that calls and SQLAlchemy + expressions are not hashable hence cannot be dict keys. + + For example:: + + >>> from sqlalchemy import Column, Integer, String + >>> from sqlalchemy.ext.declarative import declarative_base + + >>> Base = declarative_base() + + >>> class SomeClass(Base): + ... __tablename__ = 'some_table' + ... pk1 = Column(Integer, primary_key=True) + ... pk2 = Column(Integer, primary_key=True) + ... name = Column(String(50)) + ... def __repr__(self): + ... return str(self.pk1) + + >>> s = UnifiedAlchemyMagicMock(data=[ + ... ( + ... [mock.call.query('foo'), + ... mock.call.filter(c == 'one', c == 'two')], + ... [SomeClass(pk1=1, pk2=1), SomeClass(pk1=2, pk2=2)] + ... ), + ... ( + ... [mock.call.query('foo'), + ... mock.call.filter(c == 'one', c == 'two'), + ... mock.call.order_by(c)], + ... [SomeClass(pk1=2, pk2=2), SomeClass(pk1=1, pk2=1)] + ... ), + ... ( + ... [mock.call.filter(c == 'three')], + ... [SomeClass(pk1=3, pk2=3)] + ... ), + ... ]) + + # .all() + >>> s.query('foo').filter(c == 'one').filter(c == 'two').all() + [1, 2] + >>> s.query('bar').filter(c == 'one').filter(c == 'two').all() + [] + >>> s.query('foo').filter(c == 'one').filter(c == 'two').order_by(c).all() + [2, 1] + >>> s.query('foo').filter(c == 'one').filter(c == 'three').order_by(c).all() + [] + >>> s.query('foo').filter(c == 'three').all() + [3] + >>> s.query(None).filter(c == 'four').all() + [] + + # .iter() + >>> list(s.query('foo').filter(c == 'two').filter(c == 'one')) + [1, 2] + + # .count() + >>> s.query('foo').filter(c == 'two').filter(c == 'one').count() + 2 + + # .first() + >>> s.query('foo').filter(c == 'one').filter(c == 'two').first() + 1 + >>> s.query('bar').filter(c == 'one').filter(c == 'two').first() + + # .one() and scalar + >>> s.query('foo').filter(c == 'three').one() + 3 + >>> s.query('foo').filter(c == 'three').scalar() + 3 + >>> s.query('bar').filter(c == 'one').filter(c == 'two').one_or_none() + + # .get() + >>> s.query('foo').get((1, 1)) + 1 + >>> s.query('foo').get((4, 4)) + >>> s.query('foo').filter(c == 'two').filter(c == 'one').get((1, 1)) + 1 + >>> s.query('foo').filter(c == 'three').get((1, 1)) + 1 + >>> s.query('foo').filter(c == 'three').get((4, 4)) + + # dynamic session + >>> class Model(Base): + ... __tablename__ = 'model_table' + ... pk1 = Column(Integer, primary_key=True) + ... name = Column(String) + ... def __repr__(self): + ... return str(self.pk1) + >>> s = UnifiedAlchemyMagicMock() + >>> s.add(SomeClass(pk1=1, pk2=1)) + >>> s.add_all([SomeClass(pk1=2, pk2=2)]) + >>> s.add_all([SomeClass(pk1=4, pk2=3)]) + >>> s.add_all([Model(pk1=4, name='some_name')]) + >>> s.query(SomeClass).all() + [1, 2, 4] + >>> s.query(SomeClass).get((1, 1)) + 1 + >>> s.query(SomeClass).get((2, 2)) + 2 + >>> s.query(SomeClass).get((3, 3)) + >>> s.query(SomeClass).filter(c == 'one').all() + [1, 2, 4] + >>> s.query(SomeClass).get((4, 3)) + 4 + >>> s.query(SomeClass).get({"pk2": 3, "pk1": 4}) + 4 + >>> s.query(Model).get(4) + 4 + + # .delete() + >>> s = UnifiedAlchemyMagicMock(data=[ + ... ( + ... [mock.call.query('foo'), + ... mock.call.filter(c == 'one', c == 'two')], + ... [SomeClass(pk1=1, pk2=1), SomeClass(pk1=2, pk2=2)] + ... ), + ... ( + ... [mock.call.query('foo'), + ... mock.call.filter(c == 'one', c == 'two'), + ... mock.call.order_by(c)], + ... [SomeClass(pk1=2, pk2=2), SomeClass(pk1=1, pk2=1)] + ... ), + ... ( + ... [mock.call.filter(c == 'three')], + ... [SomeClass(pk1=3, pk2=3)] + ... ), + ... ( + ... [mock.call.query('foo'), + ... mock.call.filter( + ... c == 'one', + ... c == 'two', + ... c == 'three', + ... )], + ... [ + ... SomeClass(pk1=1, pk2=1), + ... SomeClass(pk1=2, pk2=2), + ... SomeClass(pk1=3, pk2=3), + ... ] + ... ), + ... ]) + + >>> s.query('foo').filter(c == 'three').all() + [3] + >>> s.query('foo').all() + [] + >>> s.query('foo').filter(c == 'three').delete() + 1 + >>> s.query('foo').filter(c == 'three').all() + [] + >>> s.query('foo').filter(c == 'one').filter(c == 'two').all() + [1, 2] + >>> a = s.query('foo').filter(c == 'one').filter(c == 'two') + >>> a.filter(c == 'three').all() + [1, 2, 3] + >>> s = UnifiedAlchemyMagicMock() + >>> s.add(SomeClass(pk1=1, pk2=1)) + >>> s.add_all([SomeClass(pk1=2, pk2=2)]) + >>> s.query(SomeClass).all() + [1, 2] + >>> s.query(SomeClass).delete() + 2 + >>> s.query(SomeClass).all() + [] + >>> s = UnifiedAlchemyMagicMock() + >>> s.add_all([SomeClass(pk1=2, pk2=2)]) + >>> s.query(SomeClass).delete() + 1 + >>> s.query(SomeClass).delete() + 0 + >>> s.query(SomeClass).scalar() + None + + Also note that only within same query functions are unified. + After ``.all()`` is called or query is iterated over, future queries + are not unified. + """ + + boundary: Dict[str, Callable] = { + "all": lambda x: x, + "__iter__": lambda x: iter(x), + "count": lambda x: len(x), + "first": lambda x: next(iter(x), None), + "one": lambda x: ( + x[0] + if len(x) == 1 + else ( + raiser(MultipleResultsFound, "Multiple rows were found for one()") + if x + else raiser(NoResultFound, "No row was found for one()") + ) + ), + "one_or_none": lambda x: ( + x[0] + if len(x) == 1 + else ( + raiser( + MultipleResultsFound, + "Multiple rows were found for one_or_none()", + ) + if x + else None + ) + ), + "get": lambda x, idmap: get_item_attr(build_identity_map(x), idmap), + "scalar": lambda x: get_scalar(x), + "update": lambda x, *args, **kwargs: None, + } + unify: Dict[str, Optional[UnorderedCall]] = { + "add_columns": None, + "distinct": None, + "execute": None, + "filter": UnorderedCall, + "filter_by": UnorderedCall, + "group_by": None, + "join": None, + "offset": None, + "options": None, + "order_by": None, + "limit": None, + "query": None, + "scalars": None, + "where": None, + } + + mutate: Set[str] = {"add", "add_all", "delete"} + + @overload + def __init__( + self, + spec: Optional[Any] = ..., + side_effect: Optional[Any] = ..., + return_value: Any = ..., + wraps: Optional[Any] = ..., + name: Optional[Any] = ..., + spec_set: Optional[Any] = ..., + parent: Optional[Any] = ..., + _spec_state: Optional[Any] = ..., + _new_name: Any = ..., + _new_parent: Optional[Any] = ..., + **kwargs: Any, + ) -> None: + ... # pragma: no cover + + def __init__(self, *args, **kwargs) -> None: + """Creates an UnifiedAlchemyMagicMock to mock a SQLAlchemy session.""" + kwargs["_mock_default"] = kwargs.pop("default", []) + kwargs["_mock_data"] = kwargs.pop("data", None) + kwargs.update( + {k: AlchemyMagicMock(side_effect=partial(self._get_data, _mock_name=k)) for k in self.boundary} + ) + + kwargs.update( + { + k: AlchemyMagicMock( + return_value=self, + side_effect=partial(self._unify, _mock_name=k), + ) + for k in self.unify + } + ) + + kwargs.update( + { + k: AlchemyMagicMock( + return_value=None, + side_effect=partial(self._mutate_data, _mock_name=k), + ) + for k in self.mutate + } + ) + + super(UnifiedAlchemyMagicMock, self).__init__(*args, **kwargs) + + def _get_previous_calls(self, calls: Sequence[Call]) -> Iterator: + """Gets the previous calls on the same line.""" + # the calls that end lines + call_enders = list(self.boundary.keys()) + ["delete"] + return iter(takewhile(lambda i: i[0] not in call_enders, reversed(calls))) + + def _get_previous_call(self, name: str, calls: Sequence[Call]) -> Optional[Call]: + """Gets the previous call right before the current call.""" + # get all previous session calls within same session query + previous_calls = self._get_previous_calls(calls) + + # skip last call + next(previous_calls) + + return next(iter(filter(lambda i: i[0] == name, previous_calls)), None) + + @overload + def _unify( + self, + value: Any = ..., + name: Optional[Any] = ..., + parent: Optional[Any] = ..., + two: bool = ..., + from_kall: bool = ..., + ) -> None: + ... # pragma: no cover + + def _unify(self, *args, **kwargs) -> Any: + """Unify the SQLAlchemy expressions.""" + _mock_name = kwargs.pop("_mock_name") + submock = getattr(self, _mock_name) + + previous_method_call = self._get_previous_call(_mock_name, self.method_calls) + previous_mock_call = self._get_previous_call(_mock_name, self.mock_calls) + + if previous_mock_call is None: + return submock.return_value + + # remove immediate call from both filter mock as well as the parent mock object + # as it already registered in self.__call__ before this side-effect is call + submock.call_count -= 1 + submock.call_args_list.pop() + submock.mock_calls.pop() + self.method_calls.pop() + self.mock_calls.pop() + + # remove previous call since we will be inserting new call instead + submock.call_args_list.pop() + submock.mock_calls.pop() + self.method_calls.pop(indexof(previous_method_call, self.method_calls)) + self.mock_calls.pop(indexof(previous_mock_call, self.mock_calls)) + + name, pargs, pkwargs = previous_method_call + args = pargs + args + kwargs = copy_and_update(pkwargs, kwargs) + + submock.call_args = Call((args, kwargs), two=True) + submock.call_args_list.append(Call((args, kwargs), two=True)) + submock.mock_calls.append(Call(("", args, kwargs))) + + self.method_calls.append(Call((name, args, kwargs))) + self.mock_calls.append(Call((name, args, kwargs))) + + return submock.return_value + + def _get_data(self, *args: Any, **kwargs: Any) -> Any: + """Get the data for the SQLAlchemy expression.""" + _mock_name = kwargs.pop("_mock_name") + _mock_default = self._mock_default + _mock_data = self._mock_data + if _mock_data is not None: + previous_calls = [ + sqlalchemy_call(i, with_name=True, base_call=self.unify.get(i[0]) or Call) + for i in self._get_previous_calls(self.mock_calls[:-1]) + ] + sorted_mock_data = sorted(_mock_data, key=lambda x: len(x[0]), reverse=True) + if _mock_name == "get": + query_call = [c for c in previous_calls if c[0] in ["query", "execute"]][0] + results = list(chain(*[result for calls, result in sorted_mock_data if query_call in calls])) + return self.boundary[_mock_name](results, *args, **kwargs) + + else: + for calls, result in sorted_mock_data: + calls = [ + sqlalchemy_call( + i, + with_name=True, + base_call=self.unify.get(i[0]) or Call, + ) + for i in calls + ] + if all(c in previous_calls for c in calls): + return self.boundary[_mock_name](result, *args, **kwargs) + + return self.boundary[_mock_name](_mock_default, *args, **kwargs) + + def _mutate_data(self, *args: Any, **kwargs: Any) -> Optional[int]: + """Alter the data for the SQLAlchemy expression.""" + _mock_name = kwargs.get("_mock_name") + _mock_data = self._mock_data = self._mock_data or [] + if _mock_name == "add": + to_add = args[0] + query_call = mock.call.query(type(to_add)) + + mocked_data = next(iter(filter(lambda i: i[0] == [query_call], _mock_data)), None) + if mocked_data: + mocked_data[1].append(to_add) + else: + _mock_data.append(([query_call], [to_add])) + + elif _mock_name == "add_all": + to_add = args[0] + _kwargs = kwargs.copy() + _kwargs["_mock_name"] = "add" + + for i in to_add: + self._mutate_data(i, *args[1:], **_kwargs) + # delete case + else: + _kwargs = kwargs.copy() + # pretend like all is being called to get data + _kwargs["_mock_name"] = "all" + _mock_name = _kwargs.pop("_mock_name") + _mock_data = self._mock_data + num_deleted = 0 + previous_calls = [ + sqlalchemy_call(i, with_name=True, base_call=self.unify.get(i[0]) or Call) + for i in self._get_previous_calls(self.mock_calls[:-1]) + ] + sorted_mock_data = sorted(_mock_data, key=lambda x: len(x[0]), reverse=True) + temp_mock_data = list() + found_query = False + for calls, result in sorted_mock_data: + calls = [ + sqlalchemy_call( + i, + with_name=True, + base_call=self.unify.get(i[0]) or Call, + ) + for i in calls + ] + if all(c in previous_calls for c in calls) and not found_query: + num_deleted = len(result) + temp_mock_data.append((calls, [])) + found_query = True + else: + temp_mock_data.append((calls, result)) + self._mock_data = temp_mock_data + return num_deleted diff --git a/irrd/vendor/mock_alchemy/unittests.py b/irrd/vendor/mock_alchemy/unittests.py new file mode 100644 index 000000000..fdcc4f205 --- /dev/null +++ b/irrd/vendor/mock_alchemy/unittests.py @@ -0,0 +1,66 @@ +"""A module for asserting SQLAlchemy expressions for unittests.""" +from __future__ import absolute_import, print_function, unicode_literals + +from typing import Any, Optional + +from .comparison import ALCHEMY_TYPES, ExpressionMatcher, PrettyExpression + + +class AlchemyUnittestMixin(object): + """A unittest class for asserting SQLAlchemy expressions. + + Unittest class mixin for asserting that different SQLAlchemy + expressions are the same. + + Uses SQLAlchemyExpressionMatcher to do the comparison. + + For example:: + + >>> from sqlalchemy.sql.expression import column + >>> import unittest + >>> class FooTest(AlchemyUnittestMixin, unittest.TestCase): + ... def test_true(self): + ... c = column('column') + ... self.assertEqual(c == 5, c == 5) + ... def test_false(self): + ... c = column('column') + ... self.assertEqual(c == 5, c == 10) + >>> FooTest('test_true').test_true() + >>> FooTest('test_false').test_false() + Traceback (most recent call last): + ... + AssertionError: BinaryExpression(sql='"column" = :column_1', \ +params={'column_1': 5}) != BinaryExpression(sql='"column" = :column_1', \ +params={'column_1': 10}) + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Creates an AlchemyUnittestMixin object for asserting.""" + super(AlchemyUnittestMixin, self).__init__(*args, **kwargs) + + # add sqlalchemy expression type which will allow to + # use self.assertEqual + for t in ALCHEMY_TYPES: + self.addTypeEqualityFunc(t, "assert_alchemy_expression_equal") + + def assert_alchemy_expression_equal(self, left: Any, right: Any, msg: Optional[str] = None) -> None: + """Assert an SQLAlchemy expression to be equal. + + Assert that two given sqlalchemy expressions are equal + as determined by SQLAlchemyExpressionMatcher + + Args: + left: The left expression to comapre for equality. + right: The right expression to comapre for equality. + msg: The error message to dispaly. + + Raises: + failureException: An exception if the two SQLAlchemy statements + are not equal. The ``msg`` is the displayed error + meassage if provided, otherwise, the left and right + expressions are displayed. + """ + if ExpressionMatcher(left) != right: + raise self.failureException( + msg or "{!r} != {!r}".format(PrettyExpression(left), PrettyExpression(right)) + ) diff --git a/irrd/vendor/mock_alchemy/utils.py b/irrd/vendor/mock_alchemy/utils.py new file mode 100644 index 000000000..9c95203e5 --- /dev/null +++ b/irrd/vendor/mock_alchemy/utils.py @@ -0,0 +1,262 @@ +"""A module for utils for the ``mock-alchemy`` library.""" +from __future__ import absolute_import, print_function, unicode_literals + +from contextlib import contextmanager +from typing import Any, Dict, Sequence, Tuple, Type, Union + +from sqlalchemy import inspect +from sqlalchemy.orm.exc import MultipleResultsFound + + +def match_type(s: Union[bytes, str], t: Union[Type[bytes], Type[str]]) -> Union[bytes, str]: + """Match the string type. + + Matches the string type with the provided type and returns the string + of the desired type. + + Args: + s: The string to match the type with. + t: The type to make the string with. + + Returns: + An object of the desired type of type ``t``. + + For example:: + + >>> assert type(match_type(b'hello', bytes)) is bytes + >>> assert type(match_type(u'hello', str)) is str + >>> assert type(match_type(b'hello', str)) is str + >>> assert type(match_type(u'hello', bytes)) is bytes + """ + if isinstance(s, t): + return s + if t is str: + return s.decode("utf-8") + else: + return s.encode("utf-8") + + +def copy_and_update(target: Dict, updater: Dict) -> Dict: + """Copy and update dictionary. + + Copy dictionary and update it all in one operation. + + Args: + target: The dictionary to copy and update. + updater: The updating dictionary. + + Returns: + Dict: A new dictionary of the ``target`` copied and + updated by ``updater``. + + For example:: + + >>> a = {'foo': 'bar'} + >>> b = copy_and_update(a, {1: 2}) + >>> a is b + False + >>> b == {'foo': 'bar', 1: 2} + True + """ + result = target.copy() + result.update(updater) + return result + + +def indexof(needle: Any, haystack: Sequence[Any]) -> int: + """Gets the index of some item in a sequence. + + Find an index of ``needle`` in ``haystack`` by looking for exact same + item by pointer ids vs usual ``list.index()`` which finds + by object comparison. + + Args: + needle: The object or item to find in the sequence. + haystack: The sequence of items to search for the ``needle``. + + Returns: + The index of the needle in the haystack. + + Raises: + ValueError: If the needle is not found inside the haystack. + + For example:: + + >>> a = {} + >>> b = {} + >>> haystack = [1, a, 2, b] + >>> indexof(b, haystack) + 3 + >>> indexof(None, haystack) + Traceback (most recent call last): + ... + ValueError: None is not in [1, {}, 2, {}] + """ + for i, item in enumerate(haystack): + if needle is item: + return i + raise ValueError("{!r} is not in {!r}".format(needle, haystack)) + + +@contextmanager +def setattr_tmp(obj: object, name: str, value: Any) -> Any: + """Set the atrributes of object temporarily. + + Utility for temporarily setting value in an object. + + Args: + obj: An object to set the attribute of. + name: The name of the attribute. + value: The value to set the attribute to. + + Returns: + A context manager that can be used. + + Yields: + Used for the context manager so that this function can be used + as ``with setattr_tmp``. + + For example:: + + >>> class Foo(object): + ... foo = 'foo' + >>> print(Foo.foo) + foo + >>> with setattr_tmp(Foo, 'foo', 'bar'): + ... print(Foo.foo) + bar + >>> print(Foo.foo) + foo + + >>> Foo.foo = None + >>> with setattr_tmp(Foo, 'foo', 'bar'): + ... print(Foo.foo) + None + """ + original = getattr(obj, name) + + if original is None: + yield + return + + setattr(obj, name, value) + try: + yield + finally: + setattr(obj, name, original) + + +def raiser(exp: Type[Exception], *args: Any, **kwargs: Any) -> Type[Exception]: + """Raises an exception with the given args. + + Utility for raising exceptions + Useful in one-liners. + + Args: + exp: The exception to raise. + args: The args to use for the exception. + kwargs: The kwargs to use for the exception. + + Raises: + exp: The parameterized exception of the specified kind. + + For example:: + + >>> a = lambda x: not x and raiser(ValueError, 'error message') + >>> _ = a(True) + >>> _ = a(False) + Traceback (most recent call last): + ... + ValueError: error message + """ + raise exp(*args, **kwargs) + + +def build_identity_map(items: Sequence[Any]) -> Dict: + """Builds identity map. + + Utility for building identity map from given SQLAlchemy models. + + Args: + items: A sequence of SQLAlchemy objects. + + Returns: + An identity map of the given SQLAlchemy objects. + + For example:: + + >>> from sqlalchemy import Column, Integer, String + >>> from sqlalchemy.ext.declarative import declarative_base + + >>> Base = declarative_base() + + >>> class SomeClass(Base): + ... __tablename__ = 'some_table' + ... pk1 = Column(Integer, primary_key=True) + ... pk2 = Column(Integer, primary_key=True) + ... name = Column(String(50)) + ... def __repr__(self): + ... return str(self.pk1) + + >>> build_identity_map([SomeClass(pk1=1, pk2=2)]) + {(1, 2): 1} + """ + idmap = {} + + for i in items: + mapper = inspect(type(i)).mapper + pk_keys = tuple(mapper.get_property_by_column(c).key for c in mapper.primary_key) + pk = tuple(getattr(i, k) for k in sorted(pk_keys)) + idmap[pk] = i + + return idmap + + +def get_item_attr(idmap: Dict, access: Union[Dict, Tuple, Any]) -> Any: + """Access dictionary in different methods. + + Utility for accessing dict by different key types (for get). + + Args: + idmap: A dictionary of identity map of SQLAlchemy objects. + access: The access pattern which should either be basic data type, dictionary, + or a tuple. If it is dictionary it should map to the names of the primary + keys of the SQLAlchemy objects. If it is a tuple, it should be a set of + keys to search for. If it is not a dict or a tuple, then the objects in + question must have only one primary key of the type passed + (such as a string, integer, etc.). + + Returns: + An SQlAlchemy object that was requested. + + For example:: + >>> idmap = {(1,): 2} + >>> get_item_attr(idmap, 1) + 2 + >>> idmap = {(1,): 2} + >>> get_item_attr(idmap, {"pk": 1}) + 2 + >>> get_item_attr(idmap, (1,)) + 2 + """ + if isinstance(access, dict): + keys = [] + for names in sorted(access): + keys.append(access[names]) + return idmap.get(tuple(keys)) + elif isinstance(access, tuple): + return idmap.get(access) + else: + return idmap.get((access,)) + + +def get_scalar(rows: Sequence[Any]) -> Any: + """Utility for mocking sqlalchemy.orm.Query.scalar().""" + if len(rows) == 1: + try: + return rows[0][0] + except TypeError: + return rows[0] + elif len(rows) > 1: + raise MultipleResultsFound("Multiple rows were found when exactly one was required") + return None diff --git a/irrd/webui/__init__.py b/irrd/webui/__init__.py new file mode 100644 index 000000000..1098977c7 --- /dev/null +++ b/irrd/webui/__init__.py @@ -0,0 +1,19 @@ +import datetime +from pathlib import Path + +from starlette.templating import Jinja2Templates + +import irrd + +UI_DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M" +RATE_LIMIT_POST_200_NAMESPACE = "irrd-http-post-200-response" +MFA_COMPLETE_SESSION_KEY = "auth-mfa-complete" +templates = Jinja2Templates(directory=Path(__file__).parent / "templates") + + +def datetime_format(value: datetime.datetime, format=UI_DEFAULT_DATETIME_FORMAT): + return value.astimezone(datetime.timezone.utc).strftime(format) + + +templates.env.globals["irrd_version"] = irrd.__version__ +templates.env.filters["datetime_format"] = datetime_format diff --git a/irrd/webui/auth/__init__.py b/irrd/webui/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/irrd/webui/auth/decorators.py b/irrd/webui/auth/decorators.py new file mode 100644 index 000000000..e6f7c6b47 --- /dev/null +++ b/irrd/webui/auth/decorators.py @@ -0,0 +1,62 @@ +import functools +from urllib.parse import quote_plus + +from starlette.requests import Request +from starlette.responses import RedirectResponse + +from irrd.webui import MFA_COMPLETE_SESSION_KEY + + +def authentication_required(_func=None, mfa_check=True): + """ + Decorator for endpoints to require authentication. + If the user is not authenticated, will redirect to login pages. + If mfa_check is set, also requires two-factor auth to have passed. + """ + + def decorator_wrapper(func): + @functools.wraps(func) + async def endpoint_wrapper(*args, **kwargs): + request = next( + (arg for arg in list(args) + list(kwargs.values()) if isinstance(arg, Request)), None + ) + next_redir = request.scope.get("raw_path", "") + if next_redir: + next_redir = quote_plus(next_redir, safe="/") + + if not request.auth.is_authenticated: + redir_url = request.url_for("ui:auth:login") + "?next=" + next_redir + return RedirectResponse(redir_url, status_code=302) + + if mfa_check and not request.session.get(MFA_COMPLETE_SESSION_KEY): + redir_url = request.url_for("ui:auth:mfa_authenticate") + "?next=" + next_redir + return RedirectResponse(redir_url, status_code=302) + + return await func(*args, **kwargs) + + return endpoint_wrapper + + if _func is None: + return decorator_wrapper + else: + return decorator_wrapper(_func) + + +def mark_user_mfa_incomplete(func): + """ + Decorator for endpoints to know whether the user has passed MFA. + Passes a user_mfa_incomplete bool to the decorated function. + Typical use is the rare case where login is optional, but logged in users + get special processing. In that case, the logged in user is only counted + if user_mfa_incomplete is False. + """ + + @functools.wraps(func) + async def endpoint_wrapper(*args, **kwargs): + request = next((arg for arg in list(args) + list(kwargs.values()) if isinstance(arg, Request)), None) + user_mfa_incomplete = request.auth.is_authenticated and not request.session.get( + MFA_COMPLETE_SESSION_KEY + ) + return await func(*args, user_mfa_incomplete=user_mfa_incomplete, **kwargs) + + return endpoint_wrapper diff --git a/irrd/webui/auth/endpoints.py b/irrd/webui/auth/endpoints.py new file mode 100644 index 000000000..107fb7822 --- /dev/null +++ b/irrd/webui/auth/endpoints.py @@ -0,0 +1,314 @@ +import logging +import secrets +from urllib.parse import unquote_plus, urlparse + +import wtforms +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response +from starlette_wtf import StarletteForm, csrf_protect + +from irrd.storage.models import AuthUser +from irrd.storage.orm_provider import ORMSessionProvider, session_provider_manager +from irrd.webui import MFA_COMPLETE_SESSION_KEY +from irrd.webui.auth.decorators import authentication_required +from irrd.webui.auth.users import ( + CurrentPasswordForm, + PasswordResetToken, + get_login_manager, + password_handler, + validate_password_strength, +) +from irrd.webui.helpers import ( + client_ip, + message, + rate_limit_post, + send_authentication_change_mail, + send_template_email, +) +from irrd.webui.rendering import render_form, template_context_render + +logger = logging.getLogger(__name__) + +DEFAULT_REDIRECT_URL = "ui:index" + + +def clean_next_url(request: Request, default: str = DEFAULT_REDIRECT_URL): + """ + Prevent an open redirect by cleaning the redirection URL. + This discards everything except the path from the next parameter. + Not very flexible, but sufficient for IRRD needs. + """ + next_param = unquote_plus(request.query_params.get("next", "")) + _, _, next_path, _, _, _ = urlparse(next_param) + return next_path if next_path else request.url_for(default) + + +@rate_limit_post +async def login(request: Request): + if request.method == "GET": + return template_context_render( + "login.html", + request, + { + "errors": None, + }, + ) + + if request.method == "POST": + data = await request.form() + email = data["email"] + password = data["password"] + default_next = "ui:auth:mfa_authenticate" + + user_token = await get_login_manager().login(request, email, password) + if user_token: + logger.info(f"{client_ip(request)}{email}: successfully logged in") + if not user_token.user.has_mfa: + default_next = "ui:index" + request.session[MFA_COMPLETE_SESSION_KEY] = True + return RedirectResponse(clean_next_url(request, default_next), status_code=302) + else: + logger.info(f"{client_ip(request)}user failed login due to invalid account or password") + return template_context_render( + "login.html", + request, + { + "errors": "Invalid account or password.", + }, + ) + + +@authentication_required(mfa_check=False) +async def logout(request: Request): + await get_login_manager().logout(request) + return RedirectResponse(request.url_for("ui:index"), status_code=302) + + +class CreateAccountForm(StarletteForm): + def __init__(self, *args, session_provider: ORMSessionProvider, **kwargs): + super().__init__(*args, **kwargs) + self.session_provider = session_provider + + email = wtforms.EmailField( + "Your email address", + validators=[wtforms.validators.DataRequired(), wtforms.validators.Email()], + ) + name = wtforms.StringField( + "Your name", + validators=[wtforms.validators.DataRequired()], + ) + submit = wtforms.SubmitField("Create account") + + async def validate(self): + if not await super().validate(): + return False + + query = self.session_provider.session.query(AuthUser).filter_by(email=self.email.data) + if await self.session_provider.run(query.count): + self.email.errors.append("An account with this email address already exists.") + return False + + return True + + +@rate_limit_post(any_response_code=True) +@csrf_protect +@session_provider_manager +async def create_account(request: Request, session_provider: ORMSessionProvider) -> Response: + form = await CreateAccountForm.from_formdata(request=request, session_provider=session_provider) + if not form.is_submitted() or not await form.validate(): + return template_context_render("create_account_form.html", request, {"form_html": render_form(form)}) + + new_user = AuthUser( + email=form.email.data, + password=secrets.token_hex(24), + name=form.name.data, + ) + session_provider.session.add(new_user) + session_provider.session.commit() + + token = PasswordResetToken(new_user).generate_token() + send_template_email(form.email.data, "create_account", request, {"user_pk": new_user.pk, "token": token}) + message(request, f"You have been sent an email to confirm your account on {form.email.data}.") + logger.info(f"{client_ip(request)}{form.email.data}: created new account, confirmation pending") + return RedirectResponse(request.url_for("ui:index"), status_code=302) + + +class ResetPasswordRequestForm(StarletteForm): + email = wtforms.EmailField( + "Your email address", + validators=[wtforms.validators.DataRequired(), wtforms.validators.Email()], + ) + submit = wtforms.SubmitField("Reset password") + + +@rate_limit_post(any_response_code=True) +@csrf_protect +@session_provider_manager +async def reset_password_request(request: Request, session_provider: ORMSessionProvider) -> Response: + form = await ResetPasswordRequestForm.from_formdata(request=request) + if not form.is_submitted() or not await form.validate(): + return template_context_render( + "reset_password_request_form.html", request, {"form_html": render_form(form)} + ) + + query = session_provider.session.query(AuthUser).filter_by(email=form.email.data) + user = await session_provider.run(query.one) + + if user: + token = PasswordResetToken(user).generate_token() + send_template_email( + form.email.data, "reset_password_request", request, {"user_pk": user.pk, "token": token} + ) + message( + request, + f"You have been sent an email to reset your password on {form.email.data}, if this account exists.", + ) + logger.info(f"{client_ip(request)}{form.email.data}: password reset email requested") + return RedirectResponse(request.url_for("ui:index"), status_code=302) + + +class ChangePasswordForm(CurrentPasswordForm): + new_password = wtforms.PasswordField( + validators=[wtforms.validators.DataRequired()], + ) + new_password_confirmation = wtforms.PasswordField( + validators=[wtforms.validators.DataRequired()], + ) + submit = wtforms.SubmitField("Change password") + + async def validate(self, current_user: AuthUser): + if not await super().validate(current_user=current_user): + return False + + is_sufficient, tips = validate_password_strength(self.new_password.data) + if not is_sufficient: + self.new_password.errors.append("Passwords is not strong enough. " + tips) + return False + + if self.new_password.data != self.new_password_confirmation.data: + self.new_password_confirmation.errors.append("Passwords do not match.") + return False + + return True + + +@rate_limit_post +@csrf_protect +@session_provider_manager +@authentication_required +async def change_password(request: Request, session_provider: ORMSessionProvider) -> Response: + form = await ChangePasswordForm.from_formdata(request=request) + if not form.is_submitted() or not await form.validate(current_user=request.auth.user): + return template_context_render( + "password_change_form.html", + request, + {"form_html": render_form(form)}, + ) + + request.auth.user.password = password_handler.hash(form.new_password.data) + session_provider.session.add(request.auth.user) + message(request, "Your password has been changed.") + logger.info(f"{client_ip(request)}{request.auth.user.email}: password changed successfully") + send_authentication_change_mail(request.auth.user, request, "Your password was changed.") + return RedirectResponse(request.url_for("ui:index"), status_code=302) + + +class ChangeProfileForm(CurrentPasswordForm): + email = wtforms.EmailField( + validators=[wtforms.validators.DataRequired(), wtforms.validators.Email()], + ) + name = wtforms.StringField( + validators=[wtforms.validators.DataRequired()], + ) + submit = wtforms.SubmitField("Change name/email") + + +@rate_limit_post +@csrf_protect +@session_provider_manager +@authentication_required +async def change_profile(request: Request, session_provider: ORMSessionProvider) -> Response: + form = await ChangeProfileForm.from_formdata( + request=request, email=request.auth.user.email, name=request.auth.user.name + ) + if not form.is_submitted() or not await form.validate(current_user=request.auth.user): + return template_context_render( + "profile_change_form.html", + request, + {"form_html": render_form(form)}, + ) + + request.auth.user.name = form.name.data + old_email = request.auth.user.email + request.auth.user.email = form.email.data + session_provider.session.add(request.auth.user) + message(request, "Your name/e-mail address have been changed.") + logger.info( + f"{client_ip(request)}{request.auth.user.email}: name/email changed successfully (old email" + f" {old_email}" + ) + send_authentication_change_mail( + request.auth.user, + request, + ( + "Your name and/or email address were updated. The current email address on your account is" + f" {request.auth.user.email}." + ), + recipient_override=old_email, + ) + return RedirectResponse(request.url_for("ui:index"), status_code=302) + + +class SetPasswordForm(StarletteForm): + new_password = wtforms.PasswordField( + validators=[wtforms.validators.DataRequired()], + ) + new_password_confirmation = wtforms.PasswordField( + validators=[wtforms.validators.DataRequired()], + ) + submit = wtforms.SubmitField("Set password") + + async def validate(self): + if not await super().validate(): + return False + + is_sufficient, tips = validate_password_strength(self.new_password.data) + if not is_sufficient: + self.new_password.errors.append("Passwords is not strong enough. " + tips) + return False + + if self.new_password.data != self.new_password_confirmation.data: + self.new_password_confirmation.errors.append("Passwords do not match.") + return False + + return True + + +@csrf_protect +@session_provider_manager +async def set_password(request: Request, session_provider: ORMSessionProvider) -> Response: + query = session_provider.session.query(AuthUser).filter( + AuthUser.pk == request.path_params["pk"], + ) + user = await session_provider.run(query.one) + + if not user or not PasswordResetToken(user).validate_token(request.path_params["token"]): + return Response(status_code=404) + + form = await SetPasswordForm.from_formdata(request=request) + initial = int(request.path_params.get("initial", "0")) + if not form.is_submitted() or not await form.validate(): + return template_context_render( + "create_account_confirm_form.html", + request, + {"form_html": render_form(form), "initial": initial}, + ) + + user.password = password_handler.hash(form.new_password.data) + session_provider.session.add(user) + message(request, "Your password has been changed.") + logger.info(f"{client_ip(request)}{user.email}: password (re)set successfully") + if not initial: + send_authentication_change_mail(user, request, "Your password was reset.") + return RedirectResponse(request.url_for("ui:auth:login"), status_code=302) diff --git a/irrd/webui/auth/endpoints_mfa.py b/irrd/webui/auth/endpoints_mfa.py new file mode 100644 index 000000000..0f64d3ca4 --- /dev/null +++ b/irrd/webui/auth/endpoints_mfa.py @@ -0,0 +1,403 @@ +import base64 +import logging +import os +from datetime import datetime, timezone +from typing import Optional, Tuple +from urllib.parse import urlparse + +import pyotp +import webauthn +import wtforms +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse, Response +from starlette_wtf import StarletteForm, csrf_protect +from webauthn import base64url_to_bytes +from webauthn.helpers.structs import ( + AttestationConveyancePreference, + AuthenticationCredential, + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + RegistrationCredential, + UserVerificationRequirement, +) +from wtforms_bootstrap5 import RendererContext + +from irrd.conf import get_setting +from irrd.storage.models import AuthUser, AuthWebAuthn +from irrd.storage.orm_provider import ORMSessionProvider, session_provider_manager +from irrd.webui import MFA_COMPLETE_SESSION_KEY +from irrd.webui.auth.decorators import authentication_required +from irrd.webui.auth.endpoints import clean_next_url +from irrd.webui.auth.users import CurrentPasswordForm +from irrd.webui.helpers import ( + client_ip, + message, + rate_limit_post, + send_authentication_change_mail, +) +from irrd.webui.rendering import render_form, template_context_render + +logger = logging.getLogger(__name__) + +TOTP_REGISTRATION_SECRET_SESSION_KEY = "totp_registration_secret" +WN_CHALLENGE_SESSION_KEY = "webauthn_current_challenge" +WN_RP_NAME = "IRRD" +ENV_WEBAUTHN_TESTING_RP_OVERRIDE = "ENV_WEBAUTHN_TESTING_RP_OVERRIDE" +ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE = "ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE" + + +def get_webauthn_origin_rpid() -> Tuple[str, str]: + """ + Determine the WebAuthn origin and Relying Party ID. + This is either taken from env for tests, or from + the full server URL in the config. + """ + if ENV_WEBAUTHN_TESTING_RP_OVERRIDE in os.environ: + origin, rpid = os.environ[ENV_WEBAUTHN_TESTING_RP_OVERRIDE].split(",")[:2] + return origin, rpid + url_parsed = urlparse(get_setting("server.http.url")) + origin = f"{url_parsed.scheme}://{url_parsed.netloc}" + rpid = url_parsed.netloc.split(":")[0] + return origin, rpid + + +def webauthn_challenge_override() -> Optional[bytes]: + """ + Override option for the WebAuthn challenge. Used only in tests. + """ + try: + return base64url_to_bytes(os.environ[ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE]) + except KeyError: # pragma: no cover + return None + + +@authentication_required +async def mfa_status(request: Request) -> Response: + context = { + "has_mfa": request.auth.user.has_mfa, + "has_totp": request.auth.user.has_totp, + "webauthns": request.auth.user.webauthns, + } + return template_context_render("mfa_status.html", request, context) + + +class TOTPAuthenticateForm(StarletteForm): + token = wtforms.StringField( + label="Enter the current token from your app", + validators=[wtforms.validators.InputRequired()], + ) + submit = wtforms.SubmitField("Authenticate with one time password") + + async def validate(self, totp: pyotp.totp.TOTP, last_used: str): + if not await super().validate(): + return False + + self.token.data = self.token.data.replace(" ", "") + + if not totp.verify(self.token.data, valid_window=1): + self.token.errors.append("Incorrect token.") + logger.info("user provided incorrect TOTP token") + return False + + if self.token.data == last_used and not os.environ["TESTING"]: # pragma: no cover + self.token.errors.append("Token already used. Wait for the next token.") + logger.info("user attempted to reuse previous TOTP token") + return False + + return True + + +@rate_limit_post +@authentication_required(mfa_check=False) +@session_provider_manager +async def mfa_authenticate(request: Request, session_provider: ORMSessionProvider) -> Response: + """ + MFA authentication page for both TOTP and WebAuthn. + For the TOTP flow, this endpoint processes the form POST request and checks it. + For WebAuthn, a JSON post is made to webauthn_verify_authentication_response by JS. + """ + next_url = clean_next_url(request) + webauthn_options_json = None + totp_form_html = None + _, wn_rpid = get_webauthn_origin_rpid() + + if request.auth.user.has_webauthn: + credentials = [ + PublicKeyCredentialDescriptor(id=auth.credential_id) for auth in request.auth.user.webauthns + ] + options = webauthn.generate_authentication_options( + rp_id=wn_rpid, + user_verification=UserVerificationRequirement.PREFERRED, + allow_credentials=credentials, + challenge=webauthn_challenge_override(), + ) + + request.session[WN_CHALLENGE_SESSION_KEY] = base64.b64encode(options.challenge).decode("ascii") + webauthn_options_json = webauthn.options_to_json(options) + + if request.auth.user.has_totp: + totp = pyotp.totp.TOTP(request.auth.user.totp_secret) + form = await TOTPAuthenticateForm.from_formdata(request=request) + if form.is_submitted(): + logger.info(f"{client_ip(request)}{request.auth.user.email}: attempting to log in with TOTP") + if await form.validate(totp=totp, last_used=request.auth.user.totp_last_used): + try: + del request.session[WN_CHALLENGE_SESSION_KEY] + except KeyError: + pass + request.session[MFA_COMPLETE_SESSION_KEY] = True + request.auth.user.totp_last_used = form.token.data + session_provider.session.add(request.auth.user) + logger.info( + f"{client_ip(request)}{request.auth.user.email}: completed" + " TOTP authentication successfully" + ) + return RedirectResponse(next_url, status_code=302) + # Intentional non-horizontal form for consistency with WebAuthn button + totp_form_html = RendererContext().render(form) + + return template_context_render( + "mfa_authenticate.html", + request, + { + "has_totp": request.auth.user.has_totp, + "has_webauthn": request.auth.user.has_webauthn, + "webauthn_options_json": webauthn_options_json, + "totp_form_html": totp_form_html, + "next": next_url, + }, + ) + + +@session_provider_manager +@authentication_required(mfa_check=False) +# No CSRF protection needed: protected by webauthn challenge +async def webauthn_verify_authentication_response( + request: Request, session_provider: ORMSessionProvider +) -> Response: + wn_origin, wn_rpid = get_webauthn_origin_rpid() + try: + expected_challenge = base64.b64decode(request.session[WN_CHALLENGE_SESSION_KEY]) + credential = AuthenticationCredential.parse_raw(await request.body()) + query = session_provider.session.query(AuthWebAuthn).filter_by( + user=request.auth.user, credential_id=credential.raw_id + ) + authn = await session_provider.run(query.one) + + verification = webauthn.verify_authentication_response( + credential=credential, + expected_challenge=expected_challenge, + expected_rp_id=wn_rpid, + expected_origin=wn_origin, + credential_public_key=authn.credential_public_key, + credential_current_sign_count=authn.credential_sign_count, + require_user_verification=False, + ) + except Exception as err: + logger.info( + ( + f"{client_ip(request)}{request.auth.user.email}: unable to verify security token" + f" authentication response: {err}" + ), + exc_info=err, + ) + return JSONResponse({"verified": False}) + + authn.credential_sign_count = verification.new_sign_count + authn.last_used = datetime.now(timezone.utc) + session_provider.session.add(authn) + + del request.session[WN_CHALLENGE_SESSION_KEY] + request.session[MFA_COMPLETE_SESSION_KEY] = True + logger.info( + f"{client_ip(request)}{request.auth.user.email}: authenticated successfully with security token" + f" {authn.pk}" + ) + return JSONResponse({"verified": True}) + + +@authentication_required +async def webauthn_register(request: Request) -> Response: + existing_credentials = [ + PublicKeyCredentialDescriptor(id=auth.credential_id) for auth in request.auth.user.webauthns + ] + _, wn_rpid = get_webauthn_origin_rpid() + + options = webauthn.generate_registration_options( + rp_name=WN_RP_NAME, + rp_id=wn_rpid, + # An assigned random identifier; + # never anything user-identifying like an email address + user_id=str(request.auth.user.pk), + # A user-visible hint of which account this credential belongs to + user_name=request.auth.user.email, + authenticator_selection=AuthenticatorSelectionCriteria( + user_verification=UserVerificationRequirement.PREFERRED, + ), + attestation=AttestationConveyancePreference.NONE, + exclude_credentials=existing_credentials, + challenge=webauthn_challenge_override(), + ) + + # Remember the challenge for later, you'll need it in the next step + request.session[WN_CHALLENGE_SESSION_KEY] = base64.b64encode(options.challenge).decode("ascii") + + webauthn_options_json = webauthn.options_to_json(options) + return template_context_render( + "webauthn_register.html", request, {"webauthn_options_json": webauthn_options_json} + ) + + +@session_provider_manager +@authentication_required +# No CSRF protection needed: protected by webauthn challenge +async def webauthn_verify_registration_response( + request: Request, session_provider: ORMSessionProvider +) -> Response: + wn_origin, wn_rpid = get_webauthn_origin_rpid() + try: + expected_challenge = base64.b64decode(request.session[WN_CHALLENGE_SESSION_KEY]) + body = await request.json() + credential = RegistrationCredential.parse_raw(body["registration_response"]) + verification = webauthn.verify_registration_response( + credential=credential, + expected_challenge=expected_challenge, + expected_rp_id=wn_rpid, + expected_origin=wn_origin, + require_user_verification=False, + ) + except Exception as err: + logger.info( + ( + f"{client_ip(request)}{request.auth.user.email}: unable to verify security" + f"token registration response: {err}" + ), + exc_info=err, + ) + return JSONResponse({"success": False}) + + new_auth = AuthWebAuthn( + user_id=str(request.auth.user.pk), + name=body["name"], + credential_id=verification.credential_id, + credential_public_key=verification.credential_public_key, + credential_sign_count=verification.sign_count, + ) + session_provider.session.add(new_auth) + del request.session[WN_CHALLENGE_SESSION_KEY] + message(request, "Your security token has been added to your account. You may need to re-authenticate.") + logger.info(f"{client_ip(request)}{request.auth.user.email}: added security token {new_auth.pk}") + send_authentication_change_mail(request.auth.user, request, "A security token was added to your account.") + + return JSONResponse({"success": True}) + + +class WebAuthnRemoveForm(CurrentPasswordForm): + submit = wtforms.SubmitField("Remove this security token") + + +@rate_limit_post +@csrf_protect +@session_provider_manager +@authentication_required +async def webauthn_remove(request: Request, session_provider: ORMSessionProvider) -> Response: + query = session_provider.session.query(AuthWebAuthn) + query = query.filter( + AuthWebAuthn.pk == request.path_params["webauthn"], AuthWebAuthn.user_id == str(request.auth.user.pk) + ) + target = await session_provider.run(query.one) + + if not target: + return Response(status_code=404) + + form = await WebAuthnRemoveForm.from_formdata(request=request) + if not form.is_submitted() or not await form.validate(current_user=request.auth.user): + return template_context_render( + "webauthn_remove.html", + request, + {"target": target, "form_html": render_form(form)}, + ) + + session_provider.session.delete(target) + message(request, "The security token has been removed.") + logger.info(f"{client_ip(request)}{request.auth.user.email}: removed security token {target.pk}") + send_authentication_change_mail( + request.auth.user, request, "A security token was removed from your account." + ) + return RedirectResponse(request.url_for("ui:auth:mfa_status"), status_code=302) + + +class TOTPRegisterForm(CurrentPasswordForm): + token = wtforms.StringField( + label="Enter the current token from your app", + validators=[wtforms.validators.InputRequired()], + ) + submit = wtforms.SubmitField("Enable one time password") + + async def validate(self, current_user: AuthUser, totp: Optional[pyotp.totp.TOTP] = None): + if not await super().validate(current_user): + return False + + if not totp or not totp.verify(self.token.data.replace(" ", ""), valid_window=1): + self.token.errors.append("Incorrect token.") + return False + + return True + + +@authentication_required +@session_provider_manager +async def totp_register(request: Request, session_provider: ORMSessionProvider) -> Response: + form = await TOTPRegisterForm.from_formdata(request=request) + totp_secret = request.session.get(TOTP_REGISTRATION_SECRET_SESSION_KEY, pyotp.random_base32()) + totp = pyotp.totp.TOTP(totp_secret) + _, wn_rpid = get_webauthn_origin_rpid() + + if not form.is_submitted() or not await form.validate(current_user=request.auth.user, totp=totp): + totp_secret = pyotp.random_base32() + request.session[TOTP_REGISTRATION_SECRET_SESSION_KEY] = totp_secret + totp_url = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=request.auth.user.email, issuer_name=f"IRRD on {wn_rpid}" + ) + + return template_context_render( + "totp_register.html", + request, + {"secret": totp_secret, "totp_url": totp_url, "form_html": render_form(form)}, + ) + + request.auth.user.totp_secret = totp_secret + session_provider.session.add(request.auth.user) + message(request, "One time passwords have been enabled. You may need to re-authenticate.") + logger.info(f"{client_ip(request)}{request.auth.user.email}: configured new TOTP on account") + send_authentication_change_mail( + request.auth.user, request, "One time password was added to your account." + ) + return RedirectResponse(request.url_for("ui:auth:mfa_status"), status_code=302) + + +class TOTPRemoveForm(CurrentPasswordForm): + submit = wtforms.SubmitField("Remove one time password (TOTP)") + + +@rate_limit_post +@csrf_protect +@session_provider_manager +@authentication_required +async def totp_remove(request: Request, session_provider: ORMSessionProvider) -> Response: + form = await TOTPRemoveForm.from_formdata(request=request) + if not form.is_submitted() or not await form.validate(current_user=request.auth.user): + return template_context_render( + "totp_remove.html", + request, + {"form_html": render_form(form)}, + ) + + request.auth.user.totp_secret = None + session_provider.session.add(request.auth.user) + message(request, "The one time password been removed.") + logger.info(f"{client_ip(request)}{request.auth.user.email}: removed TOTP from account") + send_authentication_change_mail( + request.auth.user, request, "One time password was removed from your account." + ) + return RedirectResponse(request.url_for("ui:auth:mfa_status"), status_code=302) diff --git a/irrd/webui/auth/routes.py b/irrd/webui/auth/routes.py new file mode 100644 index 000000000..ad4ccd948 --- /dev/null +++ b/irrd/webui/auth/routes.py @@ -0,0 +1,59 @@ +from starlette.routing import Route + +from .endpoints import ( + change_password, + change_profile, + create_account, + login, + logout, + reset_password_request, + set_password, +) +from .endpoints_mfa import ( + mfa_authenticate, + mfa_status, + totp_register, + totp_remove, + webauthn_register, + webauthn_remove, + webauthn_verify_authentication_response, + webauthn_verify_registration_response, +) + +AUTH_ROUTES = [ + Route("/create/", create_account, name="create_account", methods=["GET", "POST"]), + Route("/login/", login, name="login", methods=["GET", "POST"]), + Route("/logout/", logout, name="logout"), + Route( + "/set-password/{pk:uuid}/{token}/{initial:int}/", + set_password, + name="set_password", + methods=["GET", "POST"], + ), + Route("/reset-password/", reset_password_request, name="reset_password_request", methods=["GET", "POST"]), + Route("/change-password/", change_password, name="change_password", methods=["GET", "POST"]), + Route("/change-profile/", change_profile, name="change_profile", methods=["GET", "POST"]), + Route("/mfa-status/", mfa_status, name="mfa_status"), + Route("/totp-register/", totp_register, name="totp_register", methods=["GET", "POST"]), + Route("/totp-remove/", totp_remove, name="totp_remove", methods=["GET", "POST"]), + Route( + "/webauthn-remove/{webauthn:uuid}/", + webauthn_remove, + name="webauthn_remove", + methods=["GET", "POST"], + ), + Route("/webauthn-register/", webauthn_register, name="webauthn_register"), + Route( + "/webauthn-verify-registration-response/", + webauthn_verify_registration_response, + name="webauthn_verify_registration_response", + methods=["POST"], + ), + Route("/mfa-authenticate/", mfa_authenticate, name="mfa_authenticate", methods=["GET", "POST"]), + Route( + "/webauthn-verify-authentication-response/", + webauthn_verify_authentication_response, + name="webauthn_verify_authentication_response", + methods=["POST"], + ), +] diff --git a/irrd/webui/auth/users.py b/irrd/webui/auth/users.py new file mode 100644 index 000000000..0a824bcb8 --- /dev/null +++ b/irrd/webui/auth/users.py @@ -0,0 +1,166 @@ +import hashlib +import logging +import secrets +import sys +from base64 import urlsafe_b64decode, urlsafe_b64encode +from datetime import date, timedelta +from typing import Optional, Tuple, Union + +import passlib +import wtforms +from imia import ( + AuthenticationMiddleware, + LoginManager, + SessionAuthenticator, + UserLike, + UserProvider, +) +from sqlalchemy.orm import joinedload +from starlette.middleware import Middleware +from starlette.requests import HTTPConnection +from starlette_wtf import StarletteForm +from zxcvbn import zxcvbn + +from irrd.storage.models import AuthUser +from irrd.storage.orm_provider import ORMSessionProvider +from irrd.utils.misc import secret_key_derive + +logger = logging.getLogger(__name__) + +WEBAUTH_MIN_ZXCVBN_SCORE = 2 +WEBAUTH_MAX_PASSWORD_LEN = 1000 + + +class AuthProvider(UserProvider): + async def find_by_id(self, connection: HTTPConnection, identifier: str) -> Optional[UserLike]: + session_provider = ORMSessionProvider() + target = session_provider.session.query(AuthUser).filter_by(email=identifier).options(joinedload("*")) + user = await session_provider.run(target.one) + session_provider.session.expunge_all() + session_provider.commit_close() + return user + + async def find_by_username( + self, connection: HTTPConnection, username_or_email: str + ) -> Optional[UserLike]: + return await self.find_by_id(connection, username_or_email) + + async def find_by_token(self, connection: HTTPConnection, token: str) -> Optional[UserLike]: + return None # pragma: no cover + + +class PasswordHandler: + def verify(self, plain: str, hashed: str) -> bool: + try: + return self._get_hasher().verify(plain, hashed) + except ValueError: # pragma: no cover + return False + + def hash(self, plain: str): + return self._get_hasher().hash(plain) + + def _get_hasher(self): + return passlib.hash.md5_crypt if getattr(sys, "_called_from_test", None) else passlib.hash.bcrypt + + +user_provider = AuthProvider() +password_handler = PasswordHandler() + + +def get_login_manager() -> LoginManager: + return LoginManager(user_provider, password_handler, secret_key_derive("web.login_manager")) + + +authenticators = [ + SessionAuthenticator(user_provider=user_provider), +] + +auth_middleware = Middleware(AuthenticationMiddleware, authenticators=authenticators) + + +def verify_password(user: AuthUser, plain: str) -> bool: + return password_handler.verify(plain, user.get_hashed_password()) + + +def validate_password_strength(plain: str) -> Tuple[bool, str]: + if len(plain) > WEBAUTH_MAX_PASSWORD_LEN: + return False, "This password is too long." + evaluation = zxcvbn(plain[:100]) + if evaluation["score"] < WEBAUTH_MIN_ZXCVBN_SCORE: + return False, " ".join([evaluation["feedback"]["warning"]] + evaluation["feedback"]["suggestions"]) + return True, "" + + +class CurrentPasswordForm(StarletteForm): + """ + Base form for cases where users need to enter their current password for verification. + Ensures the current password field is always the last before submit. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._fields.move_to_end("current_password") + self._fields.move_to_end("submit") + + current_password = wtforms.PasswordField( + "Your current password (for verification)", + validators=[wtforms.validators.DataRequired()], + ) + + async def validate(self, current_user: AuthUser): + if not await super().validate(): + return False + + if not verify_password(current_user, self.current_password.data): + logger.info( + f"{current_user.email}: entered incorrect current password while attempting an authenticated" + " action" + ) + self.current_password.errors.append("Incorrect password.") + return False + + return True + + +PASSWORD_RESET_TOKEN_ROOT = date(2022, 1, 1) +PASSWORD_RESET_VALIDITY_DAYS = 2 + + +class PasswordResetToken: + """ + Generate or validate a password reset token. + The reset token is derived from: + - the user key, which is derived from: + - user PK + - last change to the User object + - current hashed password + - the configured secret key + - the expiry day in number of days since PASSWORD_RESET_TOKEN_ROOT + This automatically invalidates the token on any change to the user. + """ + + def __init__(self, user: AuthUser): + self.user_key = str(user.pk) + str(user.updated) + user.password + + def generate_token(self) -> str: + expiry_date = date.today() + timedelta(days=PASSWORD_RESET_VALIDITY_DAYS) + expiry_days = expiry_date - PASSWORD_RESET_TOKEN_ROOT + + hash_str = urlsafe_b64encode(self._hash(expiry_days.days)).decode("ascii") + return str(expiry_days.days) + "-" + hash_str + + def validate_token(self, token: str) -> bool: + try: + expiry_days, input_hash_encoded = token.split("-", 1) + expiry_date = PASSWORD_RESET_TOKEN_ROOT + timedelta(days=int(expiry_days)) + + expected_hash = self._hash(expiry_days) + input_hash = urlsafe_b64decode(input_hash_encoded) + + return expiry_date >= date.today() and secrets.compare_digest(input_hash, expected_hash) + except ValueError: + return False + + def _hash(self, expiry_days: Union[int, str]) -> bytes: + hash_data = secret_key_derive("web.password_reset_token") + self.user_key + str(expiry_days) + return hashlib.sha224(hash_data.encode("utf-8")).digest() diff --git a/irrd/webui/endpoints.py b/irrd/webui/endpoints.py new file mode 100644 index 000000000..bc99f5a4f --- /dev/null +++ b/irrd/webui/endpoints.py @@ -0,0 +1,173 @@ +from collections import defaultdict + +from asgiref.sync import sync_to_async +from starlette.requests import Request +from starlette.responses import Response +from starlette_wtf import csrf_protect, csrf_token + +from irrd.conf import get_setting +from irrd.storage.models import AuthUser, RPSLDatabaseObject +from irrd.storage.orm_provider import ORMSessionProvider, session_provider_manager +from irrd.storage.queries import RPSLDatabaseQuery +from irrd.updates.handler import ChangeSubmissionHandler +from irrd.webui.auth.decorators import authentication_required, mark_user_mfa_incomplete +from irrd.webui.helpers import filter_auth_hash_non_mntner +from irrd.webui.rendering import template_context_render + + +async def index(request: Request) -> Response: + """Index page with an explanation of this site.""" + mirrored_sources = [ + name for name, settings in get_setting("sources").items() if not settings.get("authoritative") + ] + return template_context_render( + "index.html", + request, + {"mirrored_sources": mirrored_sources}, + ) + + +@session_provider_manager +@authentication_required +async def user_permissions(request: Request, session_provider: ORMSessionProvider) -> Response: + # The user detail page needs a rich and bound instance of AuthUser + query = session_provider.session.query(AuthUser).filter_by(email=request.auth.user.email) + bound_user = await session_provider.run(query.one) + return template_context_render("user_permissions.html", request, {"user": bound_user}) + + +@session_provider_manager +@authentication_required +async def maintained_objects(request: Request, session_provider: ORMSessionProvider) -> Response: + """Show a user all objects with a mnt-by on which they have access.""" + user_mntners = [ + (mntner.rpsl_mntner_pk, mntner.rpsl_mntner_source) for mntner in request.auth.user.mntners + ] + if not user_mntners: + return template_context_render( + "maintained_objects.html", + request, + { + "objects": None, + }, + ) + user_mntbys, user_sources = zip(*user_mntners) + q = RPSLDatabaseQuery().lookup_attrs_in(["mnt-by"], user_mntbys).sources(user_sources) + query_result = list(session_provider.database_handler.execute_query(q)) + objects = filter( + lambda obj: any([(mntby, obj["source"]) in user_mntners for mntby in obj["parsed_data"]["mnt-by"]]), + query_result, + ) + + return template_context_render( + "maintained_objects.html", + request, + { + "objects": list(objects), + }, + ) + + +@mark_user_mfa_incomplete +@session_provider_manager +async def rpsl_detail(request: Request, user_mfa_incomplete: bool, session_provider: ORMSessionProvider): + """Details for a single RPSL object. Auth hashes filtered by default.""" + if request.method == "GET": + query = session_provider.session.query(RPSLDatabaseObject).filter( + RPSLDatabaseObject.rpsl_pk == str(request.path_params["rpsl_pk"].upper()), + RPSLDatabaseObject.object_class == str(request.path_params["object_class"].lower()), + RPSLDatabaseObject.source == str(request.path_params["source"].upper()), + ) + rpsl_object = await session_provider.run(query.one) + if rpsl_object: + rpsl_object.object_text_display = filter_auth_hash_non_mntner( + None if user_mfa_incomplete else request.auth.user, rpsl_object + ) + + return template_context_render( + "rpsl_detail.html", + request, + { + "object": rpsl_object, + }, + ) + + +@csrf_protect +@mark_user_mfa_incomplete +@session_provider_manager +async def rpsl_update( + request: Request, user_mfa_incomplete: bool, session_provider: ORMSessionProvider +) -> Response: + """ + Web form for submitting RPSL updates. + Essentially a wrapper around the same submission handlers as emails, + but with pre-authentication through the logged in user or override. + Can also be used anonymously. + """ + active_user = request.auth.user if request.auth.is_authenticated and not user_mfa_incomplete else None + mntner_perms = defaultdict(list) + if active_user: + for mntner in request.auth.user.mntners_user_management: + mntner_perms[mntner.rpsl_mntner_source].append((mntner.rpsl_mntner_pk, True)) + for mntner in request.auth.user.mntners_no_user_management: + mntner_perms[mntner.rpsl_mntner_source].append((mntner.rpsl_mntner_pk, False)) + + if request.method == "GET": + existing_data = "" + if all([key in request.path_params for key in ["rpsl_pk", "object_class", "source"]]): + query = session_provider.session.query(RPSLDatabaseObject).filter( + RPSLDatabaseObject.rpsl_pk == str(request.path_params["rpsl_pk"].upper()), + RPSLDatabaseObject.object_class == str(request.path_params["object_class"].lower()), + RPSLDatabaseObject.source == str(request.path_params["source"].upper()), + ) + obj = await session_provider.run(query.one) + if obj: + existing_data = filter_auth_hash_non_mntner(active_user, obj) + + return template_context_render( + "rpsl_form.html", + request, + { + "existing_data": existing_data, + "status": None, + "report": None, + "mntner_perms": mntner_perms, + "csrf_token": csrf_token(request), + }, + ) + + elif request.method == "POST": + form_data = await request.form() + request_meta = { + "HTTP-client-IP": request.client.host if request.client else "", + "HTTP-User-Agent": request.headers.get("User-Agent"), + } + + if active_user: + request_meta["HTTP-User-Email"] = active_user.email + request_meta["HTTP-User-ID"] = active_user.pk + + # ChangeSubmissionHandler builds its own DB connection + # and therefore needs wrapping in a thread + @sync_to_async + def save(): + return ChangeSubmissionHandler().load_text_blob( + object_texts_blob=form_data["data"], + request_meta=request_meta, + internal_authenticated_user=active_user, + ) + + handler = await save() + return template_context_render( + "rpsl_form.html", + request, + { + "existing_data": form_data["data"], + "status": handler.status(), + "report": handler.submitter_report_human(), + "mntner_perms": mntner_perms, + "csrf_token": csrf_token(request), + }, + ) + return Response(status_code=405) # pragma: no cover diff --git a/irrd/webui/endpoints_mntners.py b/irrd/webui/endpoints_mntners.py new file mode 100644 index 000000000..77ef6f54d --- /dev/null +++ b/irrd/webui/endpoints_mntners.py @@ -0,0 +1,445 @@ +import logging +import secrets +import textwrap +from typing import Optional + +import wtforms +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response +from starlette_wtf import StarletteForm, csrf_protect + +from irrd.conf import get_setting +from irrd.rpsl.rpsl_objects import RPSLMntner +from irrd.storage.models import ( + AuthMntner, + AuthPermission, + AuthUser, + JournalEntryOrigin, + RPSLDatabaseObject, +) +from irrd.storage.orm_provider import ORMSessionProvider, session_provider_manager +from irrd.utils.email import send_email +from irrd.webui.auth.decorators import authentication_required +from irrd.webui.auth.users import CurrentPasswordForm +from irrd.webui.helpers import client_ip, message, rate_limit_post, send_template_email +from irrd.webui.rendering import render_form, template_context_render + +logger = logging.getLogger(__name__) + + +class PermissionAddForm(CurrentPasswordForm): + def __init__(self, *args, session_provider: ORMSessionProvider, **kwargs): + super().__init__(*args, **kwargs) + self.new_user = None + self.session_provider = session_provider + + new_user_email = wtforms.EmailField( + "Email address of the newly authorised user", + validators=[wtforms.validators.DataRequired()], + ) + confirm = wtforms.BooleanField( + "Give this user access to modify all objects maintained by this mntner", + validators=[wtforms.validators.DataRequired()], + ) + user_management = wtforms.BooleanField( + ( + "Give this user access to user management, including adding and removing other users (including" + " myself)" + ), + ) + submit = wtforms.SubmitField("Authorise this user") + + async def validate(self, current_user: AuthUser, mntner: Optional[AuthMntner] = None): + if not await super().validate(current_user=current_user): + return False + + query = self.session_provider.session.query(AuthUser).filter_by(email=self.new_user_email.data) + self.new_user = await self.session_provider.run(query.one) + + if not self.new_user: + self.new_user_email.errors.append("Unknown user account.") + return False + + query = self.session_provider.session.query(AuthPermission).filter_by( + mntner=mntner, user=self.new_user + ) + existing_perms = await self.session_provider.run(query.count) + + if existing_perms: + self.new_user_email.errors.append("This user already has permissions on this mntner.") + return False + + return True + + +@rate_limit_post +@csrf_protect +@session_provider_manager +@authentication_required +async def permission_add(request: Request, session_provider: ORMSessionProvider) -> Response: + """ + Add a new permission for an existing user on a migrated mntner. + """ + query = session_provider.session.query(AuthMntner).join(AuthPermission) + query = query.filter( + AuthMntner.pk == request.path_params["mntner"], + AuthPermission.user_id == str(request.auth.user.pk), + AuthPermission.user_management.is_(True), + ) + mntner = await session_provider.run(query.one) + + if not mntner or not mntner.migration_complete: + return Response(status_code=404) + + form = await PermissionAddForm.from_formdata(request=request, session_provider=session_provider) + if not form.is_submitted() or not await form.validate(current_user=request.auth.user, mntner=mntner): + form_html = render_form(form) + return template_context_render( + "permission_form.html", request, {"form_html": form_html, "mntner": mntner} + ) + + new_permission = AuthPermission( + user_id=str(form.new_user.pk), + mntner_id=str(mntner.pk), + user_management=bool(form.user_management.data), + ) + session_provider.session.add(new_permission) + message_text = ( + f"A permission for {form.new_user.name} ({form.new_user.email}) on " + f"{mntner.rpsl_mntner_pk} has been added." + ) + message(request, message_text) + await notify_mntner(session_provider, request.auth.user, mntner, explanation=message_text) + logger.info( + f"{client_ip(request)}{request.auth.user.email}: added permission {new_permission.pk} on mntner" + f" {mntner.rpsl_mntner_pk} for user {form.new_user.email}" + ) + + return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) + + +class PermissionDeleteForm(CurrentPasswordForm): + confirm = wtforms.BooleanField( + "Remove this user's access to this mntner", validators=[wtforms.validators.DataRequired()] + ) + confirm_self_delete = wtforms.BooleanField( + "I understand I am deleting my own permission on this mntner, and will immediately lose access", + validators=[wtforms.validators.DataRequired()], + ) + submit = wtforms.SubmitField("Remove this user's authorisation") + + +@rate_limit_post +@csrf_protect +@session_provider_manager +@authentication_required +async def permission_delete(request: Request, session_provider: ORMSessionProvider) -> Response: + """ + Remove a permission for a user on a mntner. + Users can not delete the last permission, and must provide extra confirmation + when deleting their own permission. + """ + query = session_provider.session.query(AuthPermission) + user_mntner_pks = [perm.mntner_id for perm in request.auth.user.permissions if perm.user_management] + query = query.filter( + AuthPermission.pk == request.path_params["permission"], + AuthPermission.mntner_id.in_(user_mntner_pks), + ) + permission = await session_provider.run(query.one) + + if not permission or not permission.mntner.migration_complete: + return Response(status_code=404) + + if len(permission.mntner.permissions) == 1: + return template_context_render( + "permission_delete.html", request, {"refused_last_permission": True, "permission": permission} + ) + + form = await PermissionDeleteForm.from_formdata(request=request) + if request.auth.user != permission.user: + del form.confirm_self_delete + + if not form.is_submitted() or not await form.validate(current_user=request.auth.user): + form_html = render_form(form) + return template_context_render( + "permission_delete.html", request, {"form_html": form_html, "permission": permission} + ) + + message_text = ( + f"The permission for {permission.user.name} ({permission.user.email}) on" + f" {permission.mntner.rpsl_mntner_pk} has been deleted." + ) + await notify_mntner(session_provider, request.auth.user, permission.mntner, explanation=message_text) + session_provider.session.query(AuthPermission).filter( + AuthPermission.pk == request.path_params["permission"] + ).delete() + message(request, message_text) + logger.info( + f"{client_ip(request)}{request.auth.user.email}: removed permission {permission.pk} on mntner" + f" {permission.mntner.rpsl_mntner_pk} for user {permission.user.email}" + ) + + return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) + + +class MntnerMigrateInitiateForm(StarletteForm): + def __init__(self, *args, session_provider: ORMSessionProvider, **kwargs): + super().__init__(*args, **kwargs) + self.session_provider = session_provider + self.rpsl_mntner = None + self.rpsl_mntner_db_pk = None + auth_sources = [ + name for name, settings in get_setting("sources").items() if settings.get("authoritative") + ] + self.mntner_source.choices = sorted([(source, source) for source in auth_sources]) + + mntner_key = wtforms.StringField( + "Mntner name", + description="The name (primary key) of the mntner to migrate.", + validators=[wtforms.validators.DataRequired()], + filters=[lambda x: x.upper() if x else None], + ) + mntner_source = wtforms.SelectField( + "Mntner source", + description="The RPSL database for your mntner.", + validators=[wtforms.validators.DataRequired()], + ) + mntner_password = wtforms.StringField( + "Mntner password", + description="One of the current passwords on the mntner", + validators=[wtforms.validators.DataRequired()], + ) + confirm = wtforms.BooleanField( + "I understand that this migration can not be reversed", validators=[wtforms.validators.DataRequired()] + ) + submit = wtforms.SubmitField("Migrate this mntner") + + async def validate(self): + if not await super().validate(): + return False + + query = self.session_provider.session.query(RPSLDatabaseObject).outerjoin(AuthMntner) + query = query.filter( + RPSLDatabaseObject.rpsl_pk == self.mntner_key.data, + RPSLDatabaseObject.source == self.mntner_source.data, + RPSLDatabaseObject.object_class == "mntner", + ) + mntner_obj = await self.session_provider.run(query.one) + if not mntner_obj: + self.mntner_key.errors.append("Unable to find this mntner object.") + return False + if mntner_obj.auth_mntner: + self.mntner_key.errors.append( + "This maintainer was already migrated or a migration is in progress." + ) + return False + self.rpsl_mntner_db_pk = mntner_obj.pk + self.rpsl_mntner = RPSLMntner(mntner_obj.object_text, strict_validation=False) + + if not self.rpsl_mntner.verify_auth(passwords=[self.mntner_password.data]): + logger.info( + f"invalid password provided for mntner {self.rpsl_mntner.pk()} " + " while attempting to start migration" + ) + self.mntner_password.errors.append("Invalid password for the methods on this mntner object.") + return False + + return True + + +@rate_limit_post +@csrf_protect +@session_provider_manager +@authentication_required +async def mntner_migrate_initiate(request: Request, session_provider: ORMSessionProvider) -> Response: + """ + Initiate the migration of a mntner. + Current mntner is authenticated by a password and mail confirmation on admin-c. + Current user gets permission with user_management if successful. + A random secret token is stored in the DB and mailed to the admin-c's. + + Migration itself consists of creating an AuthMntner. + """ + if not get_setting("auth.irrd_internal_migration_enabled"): + return template_context_render("mntner_migrate_initiate.html", request, {}) + + form = await MntnerMigrateInitiateForm.from_formdata(request=request, session_provider=session_provider) + if not form.is_submitted() or not await form.validate(): + form_html = render_form(form) + return template_context_render("mntner_migrate_initiate.html", request, {"form_html": form_html}) + + new_auth_mntner = AuthMntner( + rpsl_mntner_pk=form.rpsl_mntner.pk(), + rpsl_mntner_obj_id=str(form.rpsl_mntner_db_pk), + rpsl_mntner_source=form.mntner_source.data, + migration_token=secrets.token_urlsafe(24), + ) + session_provider.session.add(new_auth_mntner) + session_provider.session.commit() + + new_permission = AuthPermission( + user_id=str(request.auth.user.pk), + mntner_id=str(new_auth_mntner.pk), + user_management=True, + ) + session_provider.session.add(new_permission) + + await send_mntner_migrate_initiate_mail(session_provider, request, new_auth_mntner, form.rpsl_mntner) + message(request, "The mntner's admin-c's have been sent a confirmation email to complete the migration.") + logger.info( + f"{client_ip(request)}{request.auth.user.email}: initiated migration of {form.rpsl_mntner.pk()}," + " pending confirmation" + ) + return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) + + +class MntnerMigrateCompleteForm(StarletteForm): + def __init__(self, *args, auth_mntner: AuthMntner, **kwargs): + super().__init__(*args, **kwargs) + self.auth_mntner = auth_mntner + self.rpsl_mntner_obj = None + + mntner_password = wtforms.StringField( + "Mntner password", + description="One of the current passwords on the mntner", + validators=[wtforms.validators.DataRequired()], + ) + confirm = wtforms.BooleanField( + "I understand that this migration can not be reversed", validators=[wtforms.validators.DataRequired()] + ) + submit = wtforms.SubmitField("Migrate this mntner") + + async def validate(self): + if not await super().validate(): + return False + + self.rpsl_mntner_obj = RPSLMntner( + self.auth_mntner.rpsl_mntner_obj.object_text, strict_validation=False + ) + if not self.rpsl_mntner_obj.verify_auth(passwords=[self.mntner_password.data]): + logger.info( + f"invalid password provided for mntner {self.auth_mntner.rpsl_mntner_pk} while attempting to" + " confirm migration" + ) + self.mntner_password.errors.append("Invalid password for the methods on this mntner object.") + return False + + return True + + +@rate_limit_post +@csrf_protect +@session_provider_manager +@authentication_required +async def mntner_migrate_complete(request: Request, session_provider: ORMSessionProvider) -> Response: + """ + Complete maintainer migration that was previously initiated. + Must be done by same user, and again requires existing mntner password. + + Completion consists of removing the migration token on the AuthMntner + and adding IRRD-INTERNAL-AUTH to the auth: lines in the RPSL object. + """ + query = session_provider.session.query(AuthMntner).join(AuthPermission) + query = query.filter( + AuthMntner.pk == str(request.path_params["pk"]), + AuthMntner.migration_token == request.path_params["token"], + AuthPermission.user_id == str(request.auth.user.pk), + AuthPermission.user_management.is_(True), + ) + auth_mntner = await session_provider.run(query.one) + + if not auth_mntner: + return Response(status_code=404) + form = await MntnerMigrateCompleteForm.from_formdata(request=request, auth_mntner=auth_mntner) + if not form.is_submitted() or not await form.validate(): + form_html = render_form(form) + return template_context_render( + "mntner_migrate_complete.html", request, {"form_html": form_html, "auth_mntner": auth_mntner} + ) + + form.auth_mntner.migration_token = None + session_provider.session.add(form.auth_mntner) + + form.rpsl_mntner_obj.add_irrd_internal_auth() + session_provider.database_handler.upsert_rpsl_object( + form.rpsl_mntner_obj, origin=JournalEntryOrigin.unknown + ) + + msg = textwrap.dedent( + """ + The maintainer has been migrated to IRRD internal authentication. + Existing authentication methods have been kept. + """ + ) + await notify_mntner(session_provider, request.auth.user, auth_mntner, explanation=msg) + + message(request, f"The mntner {auth_mntner.rpsl_mntner_pk} has been migrated.") + logger.info( + f"{client_ip(request)}{request.auth.user.email}: completed migration of {auth_mntner.rpsl_mntner_pk}" + ) + return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302) + + +async def notify_mntner(session_provider, user: AuthUser, mntner: AuthMntner, explanation: str): + """ + Notify a mntner's contact of changes made to authentication. + + This is analogous to the notifications sent from irrd.updates.ChangeRequest, + for completed migrations and permission add/remove. + Mails are sent to the notify and mnt-nfy contacts. + """ + query = session_provider.session.query(RPSLDatabaseObject).outerjoin(AuthMntner) + query = query.filter( + RPSLDatabaseObject.pk == str(mntner.rpsl_mntner_obj_id), + ) + rpsl_mntner = await session_provider.run(query.one) + recipients = set(rpsl_mntner.parsed_data.get("mnt-nfy", []) + rpsl_mntner.parsed_data.get("notify", [])) + + subject = f"Notification of {mntner.rpsl_mntner_source} database changes" + body = get_setting("email.notification_header", "").format(sources_str=mntner.rpsl_mntner_source) + body += textwrap.dedent( + f""" + This message is auto-generated. + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Internal authentication was changed for + mntner {mntner.rpsl_mntner_pk} in source {mntner.rpsl_mntner_source} + by user {user.name} ({user.email}). + """ + ) + body += f"\n{explanation.strip()}\n" + body += textwrap.dedent( + """ + Note that this change is not visible in the RPSL object, + as these authentication settings are stored internally in IRRD. + """ + ) + for recipient in recipients: + send_email(recipient, subject, body) + + +async def send_mntner_migrate_initiate_mail( + session_provider, request: Request, new_auth_mntner: AuthMntner, affected_mntner: RPSLMntner +): + """ + Send the mntner migration initiation mail. + Looks at admin-c of the existing mntner, and then looks up all emails + of all contacts, and mails them the token. + """ + admin_cs = affected_mntner.parsed_data["admin-c"] + query = session_provider.session.query(RPSLDatabaseObject) + query = query.filter( + RPSLDatabaseObject.rpsl_pk.in_(admin_cs), + RPSLDatabaseObject.source == affected_mntner.source(), + ) + recipients = { + email + for admin_c_obj in await session_provider.run(query.all) + for email in admin_c_obj.parsed_data.get("e-mail", []) + } + for recipient in recipients: + send_template_email( + recipient, + "mntner_migrate_initiate", + request, + {"auth_mntner": new_auth_mntner, "user": request.auth.user}, + ) diff --git a/irrd/webui/helpers.py b/irrd/webui/helpers.py new file mode 100644 index 000000000..d10097fe9 --- /dev/null +++ b/irrd/webui/helpers.py @@ -0,0 +1,134 @@ +import functools +import logging +from typing import Any, Dict, Optional + +import limits +from starlette.requests import Request +from starlette.responses import Response + +from irrd.conf import get_setting +from irrd.storage.models import AuthUser, RPSLDatabaseObject +from irrd.utils.email import send_email +from irrd.utils.text import remove_auth_hashes +from irrd.webui import RATE_LIMIT_POST_200_NAMESPACE, templates + +logger = logging.getLogger(__name__) + + +def rate_limit_post(_func=None, any_response_code=False): + """ + Rate limiting decorator for POST to HTTP endpoints. + + Hits are counted for any POST request, when the response is 200 + (typical for failed form submissions) or when any_response_code + is set. If the limit is exceeded, further POST requests are rejected. + Typical use is any form that requires a user's current password. + As this is a broad hit filter, do not set too strict. + + No impact on GET requests. + All endpoints share the same rate limiter. + """ + + def decorator_wrapper(func): + @functools.wraps(func) + async def endpoint_wrapper(*args, **kwargs): + rate_limit = limits.parse(get_setting("auth.webui_auth_failure_rate_limit")) + request = next( + (arg for arg in list(args) + list(kwargs.values()) if isinstance(arg, Request)), None + ) + + if request and request.method == "POST": + limiter = request.app.state.rate_limiter + permitted = await limiter.test(rate_limit, RATE_LIMIT_POST_200_NAMESPACE, request.client.host) + if not permitted: + logger.info(f"{client_ip(request)}rejecting request due to rate limiting") + return Response("Request denied due to rate limiting", status_code=403) + + response = await func(*args, **kwargs) + + if any_response_code or response.status_code == 200: + await limiter.hit(rate_limit, RATE_LIMIT_POST_200_NAMESPACE, request.client.host) + else: + response = await func(*args, **kwargs) + + return response + + return endpoint_wrapper + + if _func is None: + return decorator_wrapper + else: + return decorator_wrapper(_func) + + +# From https://github.com/accent-starlette/starlette-core/ +def message(request: Request, message: Any, category: str = "success") -> None: + """ + Save a message on the request, to be rendered in the next template render. + """ + if category not in ["info", "success", "danger", "warning"]: + raise ValueError(f"Unknown category: {category}") # pragma: no cover + if "_messages" not in request.session: + request.session["_messages"] = [] + request.session["_messages"].append({"message": message, "category": category}) + + +# From https://github.com/accent-starlette/starlette-core/ +def get_messages(request: Request): + return request.session.pop("_messages") if "_messages" in request.session else [] + + +def send_template_email( + recipient: str, template_key: str, request: Optional[Request], template_kwargs: Dict[str, Any] +) -> None: + """ + Send an email rendered from a template. + Expects {key}_mail_subject.txt and {key}_mail.txt as templates. + """ + subject = templates.get_template(f"{template_key}_mail_subject.txt").render( + request=request, **template_kwargs + ) + body = templates.get_template(f"{template_key}_mail.txt").render(request=request, **template_kwargs) + send_email(recipient, subject, body) + logger.info(f"{client_ip(request)}email sent to {recipient}: {subject}") + + +def send_authentication_change_mail( + user: AuthUser, request: Optional[Request], msg: str, recipient_override: Optional[str] = None +) -> None: + """ + Email a user that authentication data has changed. + Used for password changes, 2FA changes, etc. + """ + recipient = recipient_override if recipient_override else user.email + send_template_email( + recipient, + "authentication_change", + request, + {"url": get_setting("server.http.url"), "user": user, "msg": msg}, + ) + + +def filter_auth_hash_non_mntner(user: Optional[AuthUser], rpsl_object: RPSLDatabaseObject) -> str: + """ + Filter the auth hashes from rpsl_object unless user is a user_management mntner for it. + Returns the modified text, and sets hashes_hidden on the object. + """ + if user and user.get_id(): + user_mntners = [ + (mntner.rpsl_mntner_pk, mntner.rpsl_mntner_source) for mntner in user.mntners_user_management + ] + + if rpsl_object.object_class != "mntner" or (rpsl_object.rpsl_pk, rpsl_object.source) in user_mntners: + rpsl_object.hashes_hidden = False + return rpsl_object.object_text + + rpsl_object.hashes_hidden = True + return remove_auth_hashes(rpsl_object.object_text) + + +def client_ip(request: Optional[Request]) -> str: + """Small wrapper to get the client IP from a request.""" + if request and request.client: + return f"{request.client.host}: " + return "" # pragma: no cover diff --git a/irrd/webui/rendering.py b/irrd/webui/rendering.py new file mode 100644 index 000000000..4485b6759 --- /dev/null +++ b/irrd/webui/rendering.py @@ -0,0 +1,58 @@ +import wtforms +import wtforms_bootstrap5 +from markupsafe import Markup +from starlette.responses import Response + +from irrd.conf import get_setting +from irrd.webui import templates +from irrd.webui.helpers import get_messages + + +def template_context_render(template_name, request, context) -> Response: + """ + Render a template with context to a Response. + Adds a few items to the context, then returns a TemplateResponse. + """ + context["request"] = request + context["messages"] = get_messages(request) + context["irrd_internal_migration_enabled"] = get_setting("auth.irrd_internal_migration_enabled") + + context["auth_sources"] = [ + name for name, settings in get_setting("sources", {}).items() if settings.get("authoritative") + ] + + if "user" not in context: + context["user"] = request.auth.user if request.auth.is_authenticated else None + + return templates.TemplateResponse(template_name, context) + + +def render_form(form: wtforms.Form) -> Markup: + """ + Render the form in a nice horizontal format. + """ + # the defaults for checkboxes and submits are weird and the API limited, + # hence this hacky fix + checkboxes = [field.name for field in form if isinstance(field.widget, wtforms.widgets.CheckboxInput)] + submits = [field.name for field in form if isinstance(field.widget, wtforms.widgets.SubmitInput)] + return ( + wtforms_bootstrap5.RendererContext() + .form() + .default_field( + row_class="row mb-3", + label_class="form-label col-sm-3 col-form-label", + field_wrapper_class="col-sm-9", + field_wrapper_enabled=True, + ) + .field( + *checkboxes, + wrapper_class="offset-sm-3 col-sm-9", + wrapper_enabled=True, + field_wrapper_enabled=False, + ) + .field( + *submits, + field_wrapper_class="offset-sm-3 col-sm-9", + field_wrapper_enabled=True, + ) + ).render(form) diff --git a/irrd/webui/routes.py b/irrd/webui/routes.py new file mode 100644 index 000000000..291e58e80 --- /dev/null +++ b/irrd/webui/routes.py @@ -0,0 +1,55 @@ +from starlette.routing import Mount, Route + +from irrd.webui.auth.routes import AUTH_ROUTES +from irrd.webui.endpoints import ( + index, + maintained_objects, + rpsl_detail, + rpsl_update, + user_permissions, +) +from irrd.webui.endpoints_mntners import ( + mntner_migrate_complete, + mntner_migrate_initiate, + permission_add, + permission_delete, +) + +UI_ROUTES = [ + Route("/", index, name="index"), + Route("/maintained-objects/", maintained_objects, name="maintained_objects"), + Route( + "/rpsl/update/{source}/{object_class}/{rpsl_pk:path}/", + rpsl_update, + name="rpsl_update", + methods=["GET", "POST"], + ), + Route("/rpsl/update/", rpsl_update, name="rpsl_update", methods=["GET", "POST"]), + Route("/rpsl/{source}/{object_class}/{rpsl_pk:path}/", rpsl_detail, name="rpsl_detail"), + Route( + "/migrate-mntner/", + mntner_migrate_initiate, + name="mntner_migrate_initiate", + methods=["GET", "POST"], + ), + Route( + "/migrate-mntner/complete/{pk:uuid}/{token}/", + mntner_migrate_complete, + name="mntner_migrate_complete", + methods=["GET", "POST"], + ), + Route("/user/", user_permissions, name="user_permissions"), + Route( + "/permission/add/{mntner:uuid}/", + permission_add, + name="permission_add", + methods=["GET", "POST"], + ), + Route( + "/permission/delete/{permission:uuid}/", + permission_delete, + name="permission_delete", + methods=["GET", "POST"], + ), + Mount("/auth", name="auth", routes=AUTH_ROUTES), +] diff --git a/irrd/webui/templates/authentication_change_mail.txt b/irrd/webui/templates/authentication_change_mail.txt new file mode 100644 index 000000000..d4d038f71 --- /dev/null +++ b/irrd/webui/templates/authentication_change_mail.txt @@ -0,0 +1,7 @@ +There has been a change in the settings for your account +{{ user.email }} on the IRRD instance on {{ url }}: + +{{ msg }} + +If you did not request this change, contact the operators +of this instance immediately. diff --git a/irrd/webui/templates/authentication_change_mail_subject.txt b/irrd/webui/templates/authentication_change_mail_subject.txt new file mode 100644 index 000000000..3a3c1fa01 --- /dev/null +++ b/irrd/webui/templates/authentication_change_mail_subject.txt @@ -0,0 +1 @@ +Account settings changed on {{ url }} diff --git a/irrd/webui/templates/base.html b/irrd/webui/templates/base.html new file mode 100644 index 000000000..a74039aaf --- /dev/null +++ b/irrd/webui/templates/base.html @@ -0,0 +1,83 @@ + + + + + + IRRD {{ irrd_version }} + + + +{% macro nav_link(endpoint, name) %} + {% if request.url.__str__().startswith(url_for(endpoint)) %} + + {% else %} + + {% endif %} +{% endmacro %} + + + +
+ {% for message in messages %} + + {% endfor %} + {% if user and user.override and not user.has_mfa %} + + {% endif %} + {% block content required %}{% endblock %} +
+ + + diff --git a/irrd/webui/templates/create_account_confirm_form.html b/irrd/webui/templates/create_account_confirm_form.html new file mode 100644 index 000000000..57838d85c --- /dev/null +++ b/irrd/webui/templates/create_account_confirm_form.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block content %} +

{% if initial %}Create account{% else %}Reset password{% endif %}

+
+
+ {{ form_html }} +
+
+ {% if initial %} + To finish creating your account, + set a password. + {% else %} + To finish resetting your account, + enter your new password. + {% endif %} + There are minimum difficulty requirements for your + password, which you can meet by making your password + long or complex enough. +
+
+{% endblock %} diff --git a/irrd/webui/templates/create_account_form.html b/irrd/webui/templates/create_account_form.html new file mode 100644 index 000000000..abe92aebb --- /dev/null +++ b/irrd/webui/templates/create_account_form.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} +

Create account

+
+
+ {{ form_html }} +
+
+ After creating your account, IRRD will send you a + mail to confirm your email address. + After confirming, you can choose to migrate + an existing maintainer. +
+
+{% endblock %} diff --git a/irrd/webui/templates/create_account_mail.txt b/irrd/webui/templates/create_account_mail.txt new file mode 100644 index 000000000..6f2b56caf --- /dev/null +++ b/irrd/webui/templates/create_account_mail.txt @@ -0,0 +1,6 @@ +You, or someone using your address, has created an IRRD +account on this instance. To confirm the account, go to: + +{{ url_for("ui:auth:set_password", pk=user_pk, token=token, initial=1 ) }} + +If you did not request this, you can ignore this email. diff --git a/irrd/webui/templates/create_account_mail_subject.txt b/irrd/webui/templates/create_account_mail_subject.txt new file mode 100644 index 000000000..01d4504cc --- /dev/null +++ b/irrd/webui/templates/create_account_mail_subject.txt @@ -0,0 +1 @@ +Confirm your IRRD account diff --git a/irrd/webui/templates/index.html b/irrd/webui/templates/index.html new file mode 100644 index 000000000..44cb73e7b --- /dev/null +++ b/irrd/webui/templates/index.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% block content %} +

IRRD {{ irrd_version }}

+

+ {% if auth_sources %} + This IRRD instance is authoritative for {{ ', '.join(auth_sources) }}, + and mirrors {{ ', '.join(mirrored_sources) }}. + {% else %} + This IRRD instance is not authoritative for any sources, + and mirrors {{ ', '.join(mirrored_sources) }}. + {% endif %} +

+

Key resources

+
+
+ IRRD documentation (external)
+
+ Make sure the version you are reading matches with the version of this instance. +
+
+ Object submission
+
to submit updates + to RPSL objects. This uses the same format as email submissions. +
+
+ GraphQL query interface
+
+ This is the newest and most flexible query option, supporting complex RPSL queries + that can combine any set of criteria and supports related object queries, + where you can explore the graph of IRR data. RPSL attributes are returned in a + structured format, which means you do not need to parse RPSL objects in most cases. +
+
+ {% if irrd_internal_migration_enabled %} +

Migrating a maintainer

+

+ If you have access to an existing maintainer, you can migrate it + to IRRD internal authentication through this portal, after creating an account. + The benefits of IRRD internal authentication are: +

+ + {% endif %} + +

Other web services

+
+
+ Classic whois query interface on /v1/whois/ +
+
+ For example, use + /v1/whois/?q=!v + for a whois query for the current version. The queries and output + are the same as you would run on port 43. +
+
+ WebSocket-based event stream +
+
+ An event stream over WebSocket with push messages for all changes + to IRR objects and an initial download to synchronise state. + This is typically restricted to a limited set of users. +
+
+ Source status page on /v1/status/ +
+
+ This page provides an overview of IRRD's configured sources and their status. + Access to this may be restricted. +
+
+ + +{% endblock %} diff --git a/irrd/webui/templates/login.html b/irrd/webui/templates/login.html new file mode 100644 index 000000000..d81074067 --- /dev/null +++ b/irrd/webui/templates/login.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block content %} +

Log in

+
+
+ {% if errors %} + + {% endif %} +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+{% endblock %} diff --git a/irrd/webui/templates/maintained_objects.html b/irrd/webui/templates/maintained_objects.html new file mode 100644 index 000000000..6b2d975c1 --- /dev/null +++ b/irrd/webui/templates/maintained_objects.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block content %} +

My objects

+ {% if objects %} +

+ The following objects are maintained by maintainers for which you have access. +

+ + + + + + + + + + + + + {% for object in objects %} + + + + + + + + + {% endfor %} + +
PKClassmnt-byLast updateSource
+ + {{ object.rpsl_pk }} + + {{ object.object_class }} + {% for mntner in object.parsed_data['mnt-by'] %} + + {{ mntner }}{{ ", " if not loop.last else "" }} + {% endfor %} + {{ object.updated|datetime_format }}{{ object.source }} + + [Edit] + +
+ {% else %} + You do not have access to any maintainers. + {% endif %} +{% endblock %} diff --git a/irrd/webui/templates/mfa_authenticate.html b/irrd/webui/templates/mfa_authenticate.html new file mode 100644 index 000000000..c96959c27 --- /dev/null +++ b/irrd/webui/templates/mfa_authenticate.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block content %} +

Second factor authentication

+
+
+

+ Your need to authenticate with your two-factor authentication. + If you have lost access to all your two-factor methods, contact + the operator of this IRRD instance. +

+ + {% if has_webauthn %} +

Security token

+ + {% endif %} + {% if has_totp and has_webauthn %} +
{% endif %} + {% if has_totp %} + {{ totp_form_html }} + {% endif %} +
+
+ {% if has_webauthn %} + + + {% endif %} +{% endblock %} diff --git a/irrd/webui/templates/mfa_status.html b/irrd/webui/templates/mfa_status.html new file mode 100644 index 000000000..2be2c8081 --- /dev/null +++ b/irrd/webui/templates/mfa_status.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block content %} +

Two-factor authentication status

+

+ Your account is {{ "" if has_mfa else "not" }} configured + to use two-factor authentication. +

+

+ IRRD supports Webauthn security tokens (SoloKeys, YubiKey, Passkey, etc) + and one time password (TOTP). +

+

Security tokens

+ + + + + + + + + + + {% for webauthn in webauthns %} + + + + + + + {% endfor %} + +
NameAddedLast used
{{ webauthn.name }}{{ webauthn.created|datetime_format }}{{ webauthn.last_used|datetime_format }} + + [Remove] + +
+ + + Register a new security token + + + +

One time password (TOTP)

+

Security tokens are safer than one time passwords.

+ {% if has_totp %} +

One time password is enabled for your account.

+ + Remove one time password + + + {% else %} +

One time password is not enabled for your account.

+ + Enable one time password + + {% endif %} + +{% endblock %} diff --git a/irrd/webui/templates/mntner_migrate_complete.html b/irrd/webui/templates/mntner_migrate_complete.html new file mode 100644 index 000000000..488cd3ad4 --- /dev/null +++ b/irrd/webui/templates/mntner_migrate_complete.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block content %} +

+ Finish migrate mntner {{ auth_mntner.rpsl_mntner_pk }} + in {{ auth_mntner.rpsl_mntner_source }} +

+
+
+ {{ form_html }} +
+
+ Finish the migration of your mntner by re-entering a current + password on the mntner and confirming the migration. + After this, you can authorise other users on the mntner. + Existing authentication methods will be kept, + but are discouraged. +
+
+{% endblock %} diff --git a/irrd/webui/templates/mntner_migrate_initiate.html b/irrd/webui/templates/mntner_migrate_initiate.html new file mode 100644 index 000000000..8048b8a7d --- /dev/null +++ b/irrd/webui/templates/mntner_migrate_initiate.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block content %} +

Migrate mntner

+ {% if irrd_internal_migration_enabled %} +
+
+ {{ form_html }} +
+
+ After you complete this step correctly, IRRD will + send a confirmation link to all admin-c contact + listed for this mntner. + You need to open this link to complete the migration. +
+
+ {% else %} +

Migrating mntners is not enabled on this instance.

+ {% endif %} +{% endblock %} diff --git a/irrd/webui/templates/mntner_migrate_initiate_mail.txt b/irrd/webui/templates/mntner_migrate_initiate_mail.txt new file mode 100644 index 000000000..aa60ecd5c --- /dev/null +++ b/irrd/webui/templates/mntner_migrate_initiate_mail.txt @@ -0,0 +1,11 @@ +You are receiving this email because you are an admin-c for {{ auth_mntner.rpsl_mntner_pk }} + +The user {{ user.email }} has requested to migrate mntner +{{ auth_mntner.rpsl_mntner_pk }} in {{ auth_mntner.rpsl_mntner_source }} +to IRRD's internal authentication. The mentioned user will have full +access to this mntner object, and can add permissions for other users. + +Existing authentication methods will be kept after this migration. + +To proceed with this migration, the same user must go to the following page: +{{ url_for("ui:mntner_migrate_complete", pk=auth_mntner.pk, token=auth_mntner.migration_token ) }} diff --git a/irrd/webui/templates/mntner_migrate_initiate_mail_subject.txt b/irrd/webui/templates/mntner_migrate_initiate_mail_subject.txt new file mode 100644 index 000000000..f228bdbfc --- /dev/null +++ b/irrd/webui/templates/mntner_migrate_initiate_mail_subject.txt @@ -0,0 +1 @@ +Confirm the migration of mntner {{ auth_mntner.rpsl_mntner_pk }} in {{ auth_mntner.rpsl_mntner_source }} diff --git a/irrd/webui/templates/password_change_form.html b/irrd/webui/templates/password_change_form.html new file mode 100644 index 000000000..25835e159 --- /dev/null +++ b/irrd/webui/templates/password_change_form.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} +

Change your password

+
+
+ {{ form_html }} +
+
+ There are minimum difficulty requirements for your + password, which you can meet by making your password + long or complex enough. + +
+
+{% endblock %} diff --git a/irrd/webui/templates/permission_delete.html b/irrd/webui/templates/permission_delete.html new file mode 100644 index 000000000..879df6a34 --- /dev/null +++ b/irrd/webui/templates/permission_delete.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block content %} +

Remove permission for {{ permission.user.email }} on {{ permission.mntner.rpsl_mntner_pk }}

+
+ {% if refused_last_permission %} +
+ You can not remove this permission on this mntner, + because it is the last one remaining. +
+ {% else %} +
+ {{ form_html }} +
+ {% endif %} +
+{% endblock %} diff --git a/irrd/webui/templates/permission_form.html b/irrd/webui/templates/permission_form.html new file mode 100644 index 000000000..5cf792bfb --- /dev/null +++ b/irrd/webui/templates/permission_form.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block content %} +

Authorise another user on {{ mntner.rpsl_mntner_pk }}

+
+
+ {{ form_html }} +
+
+

+ You are about to authorise a user on mntner {{ mntner.rpsl_mntner_pk }}. + This user will be able to edit any RPSL objects that have this mntner in + their mnt-by. The user must have already created their account. +

+

+ If you assign user management permissions, this user can also + change the {{ mntner.rpsl_mntner_pk }} object itself, + and add or remove other users. +

+
+
+{% endblock %} diff --git a/irrd/webui/templates/profile_change_form.html b/irrd/webui/templates/profile_change_form.html new file mode 100644 index 000000000..550cd9ea5 --- /dev/null +++ b/irrd/webui/templates/profile_change_form.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block content %} +

Change your name/email

+
+
+ {{ form_html }} +
+
+{% endblock %} diff --git a/irrd/webui/templates/reset_password_request_form.html b/irrd/webui/templates/reset_password_request_form.html new file mode 100644 index 000000000..d1f5b4a99 --- /dev/null +++ b/irrd/webui/templates/reset_password_request_form.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block content %} +

Reset password

+
+
+ {{ form_html }} +
+
+ If you have lost your password, you can request a reset link + here. Note that if you have lost access to all your two-factor + methods (one time password, security token), you can not + reset those through this process: you have to contact + the operator of this IRRD instance. +
+
+{% endblock %} diff --git a/irrd/webui/templates/reset_password_request_mail.txt b/irrd/webui/templates/reset_password_request_mail.txt new file mode 100644 index 000000000..a6691ea61 --- /dev/null +++ b/irrd/webui/templates/reset_password_request_mail.txt @@ -0,0 +1,5 @@ +You have asked for a password reset for your IRRD account. + +To complete your reset, go to: +{{ url_for("ui:auth:set_password", pk=user_pk, token=token, initial=0 ) }} + diff --git a/irrd/webui/templates/reset_password_request_mail_subject.txt b/irrd/webui/templates/reset_password_request_mail_subject.txt new file mode 100644 index 000000000..c710ae9e4 --- /dev/null +++ b/irrd/webui/templates/reset_password_request_mail_subject.txt @@ -0,0 +1 @@ +IRRD password reset confirmation diff --git a/irrd/webui/templates/rpsl_detail.html b/irrd/webui/templates/rpsl_detail.html new file mode 100644 index 000000000..245ce0b28 --- /dev/null +++ b/irrd/webui/templates/rpsl_detail.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block content %} +

+ {{ object.rpsl_pk }} + {% if object %} + + Edit + + {% endif %} +

+ {% if object %} +
{{ object.object_text_display }}
+ {% if object.hashes_hidden %} +

+ As you are not (sufficiently) authorised on this mntner, any password hashes have been removed. +

+ {% endif %} + {% else %} +

No object found with this key.

+ {% endif %} +{% endblock %} diff --git a/irrd/webui/templates/rpsl_form.html b/irrd/webui/templates/rpsl_form.html new file mode 100644 index 000000000..8f8d85f0d --- /dev/null +++ b/irrd/webui/templates/rpsl_form.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block content %} +

Change/create/delete object(s){% if status %}: {{ status }}{% endif %}

+ {% if report %} +
{{ report }}
+ {% endif %} + {% if not status or status != 'SUCCESS' %} +
+
+
+ +
+ + +
+
+ +
+
+
+
+

+ In this form, you can submit changes to RPSL objects in plain text. + This form is identical to email submissions, which means you + can use the pseudo-attributes delete for deletions + or password for password authentication. + PGP is not supported. + See the IRRD documentation for more details. +

+ {% if user and user.override %} +

+ You have override permissions for all authoritative + objects in this IRRD instance. +

+ {% elif mntner_perms %} +

+ Your user is already authorised for the following + maintainers without needing to enter their password: +

+ {% for source, mntners in mntner_perms.items() %} + In {{ source }}: +
    + {% for mntner, user_management in mntners %} +
  • + {{ mntner }} + {% if not user_management %}(you can not update this mntner itself){% endif %} +
  • + {% endfor %} +
+ {% endfor %} + {% endif %} +
+
+ {% endif %} +{% endblock %} diff --git a/irrd/webui/templates/totp_register.html b/irrd/webui/templates/totp_register.html new file mode 100644 index 000000000..ec583c6b5 --- /dev/null +++ b/irrd/webui/templates/totp_register.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block content %} +

Enable one time password (TOTP)

+
+
+

+ To set up your one time password, scan the QR code with + an app like Authy, 1Password or Google Authenticator. +

+ +

+ Your TOTP secret, in case you want to configure this manually, is: + {{ secret }} +

+ {{ form_html }} +
+
+ + +{% endblock %} diff --git a/irrd/webui/templates/totp_remove.html b/irrd/webui/templates/totp_remove.html new file mode 100644 index 000000000..473245ee4 --- /dev/null +++ b/irrd/webui/templates/totp_remove.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +

Remove one time password (TOTP)

+
+
+ {{ form_html }} +
+
+ You are about to remove the one time password (TOTP) from your account. + Note that you are strongly recommended to have at least one + two-factor authentication method on your account. +
+
+{% endblock %} diff --git a/irrd/webui/templates/user_permissions.html b/irrd/webui/templates/user_permissions.html new file mode 100644 index 000000000..f4595d9db --- /dev/null +++ b/irrd/webui/templates/user_permissions.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block content %} +

Maintainer permissions

+ + {% if user.override %} +

+ You have override access and can update any authoritative object. +

+ {% endif %} + + {% for permission in user.permissions %} +

mntner {{ permission.mntner.rpsl_mntner_pk }}

+ {% if permission.mntner.migration_complete %} + {% if permission.user_management %} +

+ You have user management permissions on this mntner, + which means you can add or remove access for other users. + You can also update any objects maintained by this maintainer. +

+ {% else %} +

+ You do not have user management permissions on this mntner, + which means you can only update objects maintained by this + maintainer, except this mntner object itself. +

+ {% endif %} +
All users with access to {{ permission.mntner.rpsl_mntner_pk }}
+ + + + + + + {% if permission.user_management %} + + {% endif %} + + + + + {% for mntner_perm in permission.mntner.permissions %} + + + + + {% if permission.user_management %} + + {% endif %} + + {% endfor %} + +
NameEmailUser management
{{ mntner_perm.user.name }}{{ mntner_perm.user.email }}{{ "Yes" if mntner_perm.user_management else "No" }} + Remove + access +
+ {% if permission.user_management %} + + Add access for another user + + {% endif %} + {% else %} + The migration for this mntner is not yet complete. + {% endif %} + {% endfor %} +{% endblock %} diff --git a/irrd/webui/templates/webauthn_register.html b/irrd/webui/templates/webauthn_register.html new file mode 100644 index 000000000..5c604e3ac --- /dev/null +++ b/irrd/webui/templates/webauthn_register.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block content %} +

Register new security token

+
+
+ {{ webauthn_options_json }} + +
+
+ + +
+
+ +
+
+
+ + +{% endblock %} diff --git a/irrd/webui/templates/webauthn_remove.html b/irrd/webui/templates/webauthn_remove.html new file mode 100644 index 000000000..97a379182 --- /dev/null +++ b/irrd/webui/templates/webauthn_remove.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block content %} +

Remove security token {{ target.name }}

+
+
+ {{ form_html }} +
+
+{% endblock %} diff --git a/irrd/webui/tests/__init__.py b/irrd/webui/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/irrd/webui/tests/conftest.py b/irrd/webui/tests/conftest.py new file mode 100644 index 000000000..185c32944 --- /dev/null +++ b/irrd/webui/tests/conftest.py @@ -0,0 +1,103 @@ +import pyotp +import pytest +from starlette.testclient import TestClient + +from irrd.server.http.app import app +from irrd.utils.factories import ( + SAMPLE_USER_PASSWORD, + SAMPLE_USER_TOTP_TOKEN, + AuthMntnerFactory, + AuthPermissionFactory, +) + + +@pytest.fixture() +def test_client(config_override): + config_override( + { + "server": {"http": {"url": "http://testserver/"}}, + "secret_key": "s", + "sources": {"TEST": {"authoritative": True}, "MIRROR": {}}, + "auth": {"irrd_internal_migration_enabled": True, "webui_auth_failure_rate_limit": "100/second"}, + } + ) + with TestClient(app) as client: + yield client + + +@pytest.fixture() +def test_client_with_smtp(config_override, smtpd): + config_override( + { + "server": {"http": {"url": "http://testserver/"}}, + "secret_key": "s", + "sources": {"TEST": {"authoritative": True}, "MIRROR": {}}, + "email": {"smtp": f"localhost:{smtpd.port}", "from": "irrd@example.net"}, + "auth": {"irrd_internal_migration_enabled": True, "webui_auth_failure_rate_limit": "100/second"}, + } + ) + with TestClient(app) as client: + yield client, smtpd + + +class WebRequestTest: + url: str + requires_login = True + requires_mfa = True + + def test_login_requirement(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + if not self.requires_login: + return + self.pre_login(session_provider, user) + response = test_client.get(self.url) + assert response.url.startswith("http://testserver/ui/auth/login/") + + def test_mfa_requirement(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + if not self.requires_mfa: + return + + self.pre_login(session_provider, user) + self._login(test_client, user) + response = test_client.get("/ui/user/") + assert response.url.startswith("http://testserver/ui/auth/mfa-authenticate/") + + def _login(self, test_client, user, password=SAMPLE_USER_PASSWORD): + response = test_client.post( + "/ui/auth/login/", + data={"email": user.email, "password": password}, + allow_redirects=False, + ) + assert response.status_code == 302 + + def _logout(self, test_client): + response = test_client.get( + "/ui/auth/logout/", + ) + assert response.status_code == 302 + + def _verify_mfa(self, test_client): + response = test_client.post( + "/ui/auth/mfa-authenticate/", + data={"token": pyotp.TOTP(SAMPLE_USER_TOTP_TOKEN).now()}, + allow_redirects=False, + ) + assert response.status_code == 302 + + def _login_if_needed(self, test_client, user): + if self.requires_login: + self._login(test_client, user) + if self.requires_mfa: + self._verify_mfa(test_client) + + def pre_login(self, session_provider, user): + pass + + +def create_permission(session_provider, user, mntner=None, user_management=True): + if not mntner: + mntner = AuthMntnerFactory() + return AuthPermissionFactory( + user_id=str(user.pk), mntner_id=str(mntner.pk), user_management=user_management + ) diff --git a/irrd/webui/tests/test_auth_endpoints.py b/irrd/webui/tests/test_auth_endpoints.py new file mode 100644 index 000000000..e054132de --- /dev/null +++ b/irrd/webui/tests/test_auth_endpoints.py @@ -0,0 +1,545 @@ +import secrets +import uuid + +from irrd.storage.models import AuthUser +from irrd.utils.factories import SAMPLE_USER_PASSWORD +from irrd.webui.auth.users import PasswordResetToken +from irrd.webui.tests.conftest import WebRequestTest + + +class TestLogin: + url = "/ui/auth/login/" + + def test_render_form(self, test_client): + response = test_client.get(self.url) + assert response.status_code == 200 + assert "password" in response.text + + def test_rate_limit(self, test_client, irrd_db_session_with_user, config_override): + session_provider, user = irrd_db_session_with_user + config_override( + { + "server": {"http": {"url": "http://testserver/"}}, + "secret_key": "s", + "auth": {"webui_auth_failure_rate_limit": "1/hour"}, + } + ) + response = test_client.post( + self.url, + data={"email": user.email, "password": "incorrect"}, + allow_redirects=False, + ) + # This might already hit the limit from previous tests + assert response.status_code in [200, 403] + + response = test_client.post( + self.url, + data={"email": user.email, "password": "incorrect"}, + allow_redirects=False, + ) + assert response.status_code == 403 + + def test_login_valid_mfa_pending(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + response = test_client.post( + self.url, + data={"email": user.email, "password": SAMPLE_USER_PASSWORD}, + allow_redirects=False, + ) + assert response.status_code == 302 + assert response.headers["Location"].endswith("/ui/auth/mfa-authenticate/") + + # Check that MFA is still pending + response = test_client.get("/ui/user/") + assert response.url.startswith("http://testserver/ui/auth/mfa-authenticate/") + + def test_login_valid_no_mfa(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + user.totp_secret = None + session_provider.session.commit() + + response = test_client.post( + self.url, + data={"email": user.email, "password": SAMPLE_USER_PASSWORD}, + allow_redirects=False, + ) + assert response.status_code == 302 + assert response.headers["Location"].endswith("/ui/") + + # Check that MFA is not pending + response = test_client.get("/ui/user/") + assert response.url.startswith("http://testserver/ui/user/") + + def test_login_invalid(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + response = test_client.post( + self.url, + data={"email": user.email, "password": "incorrect"}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Invalid account" in response.text + + +class TestLogout(WebRequestTest): + url = "/ui/auth/logout/" + requires_login = True + requires_mfa = False + + def test_logout(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url) + assert response.status_code == 200 + assert user.email not in response.text + + +class TestCreateAccount: + url = "/ui/auth/create/" + + def test_render_form(self, test_client): + response = test_client.get(self.url) + assert response.status_code == 200 + assert "name" in response.text + + def test_create_valid(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + new_user_email = "new-user@example.com" + + response = test_client.post( + self.url, + data={"email": new_user_email, "name": "name"}, + allow_redirects=False, + ) + assert response.status_code == 302 + + new_user = session_provider.run_sync( + session_provider.session.query(AuthUser).filter(AuthUser.email != user.email).one + ) + assert new_user.email == new_user_email + + token = PasswordResetToken(new_user).generate_token() + assert len(smtpd.messages) == 1 + assert [new_user_email] == smtpd.messages[0].get_all("to") + assert str(new_user.pk) in smtpd.messages[0].as_string() + assert token in smtpd.messages[0].as_string() + + def test_create_invalid_email_exists(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + + response = test_client.post( + self.url, + data={"email": user.email, "name": "name"}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "account with this email" in response.text + + new_user = session_provider.run_sync( + session_provider.session.query(AuthUser).filter(AuthUser.email != user.email).one + ) + assert not new_user + assert not smtpd.messages + + def test_create_invalid_missing_required(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + + response = test_client.post( + self.url, + data={}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "This field is required" in response.text + + new_user = session_provider.run_sync( + session_provider.session.query(AuthUser).filter(AuthUser.email != user.email).one + ) + assert not new_user + assert not smtpd.messages + + +class TestResetPasswordRequest: + url = "/ui/auth/reset-password/" + + def test_render_form(self, test_client): + response = test_client.get(self.url) + assert response.status_code == 200 + assert "name" in response.text + + def test_request_valid(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + + response = test_client.post( + self.url, + data={"email": user.email}, + allow_redirects=False, + ) + assert response.status_code == 302 + + token = PasswordResetToken(user).generate_token() + assert len(smtpd.messages) == 1 + assert [user.email] == smtpd.messages[0].get_all("to") + assert str(user.pk) in smtpd.messages[0].as_string() + assert token in smtpd.messages[0].as_string() + + def test_request_unknown_user(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + response = test_client.post( + self.url, + data={"email": "invalid-user@example.com"}, + allow_redirects=False, + ) + assert response.status_code == 302 + assert not smtpd.messages + + +class TestChangePassword(WebRequestTest): + url = "/ui/auth/change-password/" + + def test_render_form(self, test_client): + response = test_client.get(self.url) + assert response.status_code == 200 + assert "name" in response.text + + def test_valid(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + new_password = secrets.token_hex(24) + + response = test_client.post( + self.url, + data={ + "new_password": new_password, + "new_password_confirmation": new_password, + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 302 + self._login(test_client, user, new_password) + assert len(smtpd.messages) == 1 + assert "password was changed" in smtpd.messages[0].as_string() + + def test_invalid_too_long(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + new_password = "a" * 1100 + + response = test_client.post( + self.url, + data={ + "new_password": new_password, + "new_password_confirmation": new_password, + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "too long" in response.text + self._login(test_client, user, SAMPLE_USER_PASSWORD) + assert not smtpd.messages + + def test_invalid_current_password(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + new_password = secrets.token_hex(24) + + response = test_client.post( + self.url, + data={ + "new_password": new_password, + "new_password_confirmation": new_password, + "current_password": "invalid", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Incorrect password." in response.text + self._login(test_client, user, SAMPLE_USER_PASSWORD) + assert not smtpd.messages + + def test_invalid_password_mismatch(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + new_password = secrets.token_hex(24) + new_password2 = secrets.token_hex(24) + + response = test_client.post( + self.url, + data={ + "new_password": new_password, + "new_password_confirmation": new_password2, + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "do not match" in response.text + self._login(test_client, user, SAMPLE_USER_PASSWORD) + assert not smtpd.messages + + def test_invalid_weak_password(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "new_password": "a", + "new_password_confirmation": "a", + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "not strong enough" in response.text + self._login(test_client, user, SAMPLE_USER_PASSWORD) + assert not smtpd.messages + + def test_invalid_missing_field(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + new_password = secrets.token_hex(24) + + response = test_client.post( + self.url, + data={ + "new_password": new_password, + "new_password_confirmation": new_password, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "This field is required." in response.text + self._login(test_client, user, SAMPLE_USER_PASSWORD) + assert not smtpd.messages + + +class TestChangeProfile(WebRequestTest): + url = "/ui/auth/change-profile/" + + def test_render_form(self, test_client): + response = test_client.get(self.url) + assert response.status_code == 200 + assert "name" in response.text + + def test_valid(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + old_email = user.email + new_email = "new-email@example.com" + new_name = "new-name" + + response = test_client.post( + self.url, + data={ + "email": new_email, + "name": new_name, + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 302 + + session_provider.session.refresh(user) + assert user.email == new_email + assert user.name == new_name + + assert len(smtpd.messages) == 1 + assert old_email in smtpd.messages[0]["To"] + assert "current email address" in smtpd.messages[0].as_string() + assert new_email in smtpd.messages[0].as_string() + + def test_invalid_current_password(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + new_email = "new-email@example.com" + new_name = "new-name" + + response = test_client.post( + self.url, + data={ + "email": new_email, + "name": new_name, + "current_password": "invalid", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Incorrect password." in response.text + + old_email, old_name = user.email, user.name + session_provider.session.refresh(user) + assert user.email == old_email + assert user.name == old_name + assert not smtpd.messages + + def test_invalid_email(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "email": "invalid", + "name": "new name", + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Invalid email address" in response.text + + old_email, old_name = user.email, user.name + session_provider.session.refresh(user) + assert user.email == old_email + assert user.name == old_name + assert not smtpd.messages + + +class TestSetPassword(WebRequestTest): + requires_login = False + requires_mfa = False + + def valid_url(self, user, initial=False): + token = PasswordResetToken(user).generate_token() + return f"/ui/auth/set-password/{user.pk}/{token}/{1 if initial else 0}/" + + def test_render_form(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + token = PasswordResetToken(user).generate_token() + + url = f"/ui/auth/set-password/{user.pk}/{token}/0/" + response = test_client.get(url) + assert response.status_code == 200 + assert "Reset password" in response.text + + url = f"/ui/auth/set-password/{user.pk}/{token}/1/" + response = test_client.get(url) + assert response.status_code == 200 + assert "Create account" in response.text + + def test_valid_reset(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + url = self.valid_url(user) + new_password = secrets.token_hex(24) + + response = test_client.post( + url, + data={"new_password": new_password, "new_password_confirmation": new_password}, + allow_redirects=False, + ) + assert response.status_code == 302 + self._login(test_client, user, new_password) + assert len(smtpd.messages) == 1 + assert "password was reset" in smtpd.messages[0].as_string() + + def test_valid_reset_initial(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + url = self.valid_url(user, initial=True) + new_password = secrets.token_hex(24) + + response = test_client.post( + url, + data={"new_password": new_password, "new_password_confirmation": new_password}, + allow_redirects=False, + ) + assert response.status_code == 302 + self._login(test_client, user, new_password) + assert not smtpd.messages + + def test_invalid_password_mismatch(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + url = self.valid_url(user) + new_password = secrets.token_hex(24) + new_password2 = secrets.token_hex(24) + + response = test_client.post( + url, + data={"new_password": new_password, "new_password_confirmation": new_password2}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "do not match" in response.text + self._login(test_client, user) # uses original password + assert not smtpd.messages + + def test_invalid_password_weak(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + url = self.valid_url(user) + + response = test_client.post( + url, + data={"new_password": "a", "new_password_confirmation": "a"}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "not strong enough" in response.text + self._login(test_client, user) # uses original password + assert not smtpd.messages + + def test_invalid_missing_required(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + url = self.valid_url(user) + new_password = secrets.token_hex(24) + + response = test_client.post( + url, + data={ + "new_password": new_password, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "This field is required." in response.text + self._login(test_client, user) # uses original password + assert not smtpd.messages + + def test_unknown_user_or_invalid_token(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + + token = PasswordResetToken(user).generate_token() + url = f"/ui/auth/set-password/invalid-uuid/{token}/0/" + response = test_client.get(url) + assert response.status_code == 404 + + token = PasswordResetToken(user).generate_token() + url = f"/ui/auth/set-password/{uuid.uuid4()}/{token}/0/" + response = test_client.get(url) + assert response.status_code == 404 + + url = f"/ui/auth/set-password/{user.pk}/invalid-invalid/1/" + response = test_client.get(url) + assert response.status_code == 404 + + url = f"/ui/auth/set-password/{user.pk}/a/1" + response = test_client.get(url) + assert response.status_code == 404 + assert not smtpd.messages diff --git a/irrd/webui/tests/test_auth_endpoints_mfa.py b/irrd/webui/tests/test_auth_endpoints_mfa.py new file mode 100644 index 000000000..15e311e39 --- /dev/null +++ b/irrd/webui/tests/test_auth_endpoints_mfa.py @@ -0,0 +1,450 @@ +import base64 +import json +import os +import uuid + +import pyotp + +from irrd.storage.models import AuthWebAuthn +from irrd.utils.factories import ( + SAMPLE_USER_PASSWORD, + SAMPLE_USER_TOTP_TOKEN, + AuthWebAuthnFactory, +) +from irrd.webui.auth.endpoints_mfa import ( + ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE, + ENV_WEBAUTHN_TESTING_RP_OVERRIDE, +) +from irrd.webui.tests.conftest import WebRequestTest + + +class TestMfaStatus(WebRequestTest): + url = "/ui/auth/mfa-status" + + def test_render(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + webauthn = AuthWebAuthnFactory(user_id=str(user.pk)) + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert webauthn.name in response.text + assert "enabled for your account" in response.text + + +class TestTOTPAuthenticate(WebRequestTest): + url = "/ui/auth/mfa-authenticate/?next=/ui/user/" + requires_mfa = False + + def test_render_form(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url) + assert response.status_code == 200 + assert "one time password" in response.text + + def test_valid_totp(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.post( + self.url, + data={"token": pyotp.TOTP(SAMPLE_USER_TOTP_TOKEN).now()}, + allow_redirects=False, + ) + assert response.url.endswith("/ui/user/") + + def test_invalid_totp(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.post( + self.url, + data={"token": 3}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Incorrect token." in response.text + + def test_missing_totp(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.post( + self.url, + data={}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "This field is required" in response.text + + +class TestWebAuthnAuthenticate(WebRequestTest): + url = "/ui/auth/mfa-authenticate/" + verify_url = "/ui/auth/webauthn-verify-authentication-response/" + requires_login = True + requires_mfa = False + + def test_render_form(self, test_client, irrd_db_session_with_user): + if ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE in os.environ: + del os.environ[ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE] # pragma: no cover + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + token = AuthWebAuthnFactory(user_id=str(user.pk)) + response = test_client.get(self.url) + assert response.status_code == 200 + assert base64.b64encode(token.credential_id)[:10] in response.content + + challenge1 = json.loads(response.context["webauthn_options_json"])["challenge"] + response = test_client.get(self.url) + challenge2 = json.loads(response.context["webauthn_options_json"])["challenge"] + assert challenge1 != challenge2 + + def test_valid_authenticate(self, test_client, irrd_db_session_with_user): + os.environ[ENV_WEBAUTHN_TESTING_RP_OVERRIDE] = "http://localhost:5000,localhost" + os.environ[ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE] = ( + "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ" + ) + + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + AuthWebAuthnFactory(user_id=str(user.pk)) + + # Sets WN_CHALLENGE_SESSION_KEY + response = test_client.get(self.url) + assert response.status_code == 200 + assert "simplewebauthn" in response.text + + verification_body = json.dumps( + { + "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "response": { + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVBtQWkxUHAxWEw2b0FncTNQV1p0WlBuWmExekZVRG9HYmFRMF9LdlZHMWxGMnMzUnRfM280dVN6Y2N5MHRtY1RJcFRUVDRCVTFULUk0bWFhdm5kalEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", + "signature": "iOHKX3erU5_OYP_r_9HLZ-CexCE4bQRrxM8WmuoKTDdhAnZSeTP0sjECjvjfeS8MJzN1ArmvV0H0C3yy_FdRFfcpUPZzdZ7bBcmPh1XPdxRwY747OrIzcTLTFQUPdn1U-izCZtP_78VGw9pCpdMsv4CUzZdJbEcRtQuRS03qUjqDaovoJhOqEBmxJn9Wu8tBi_Qx7A33RbYjlfyLm_EDqimzDZhyietyop6XUcpKarKqVH0M6mMrM5zTjp8xf3W7odFCadXEJg-ERZqFM0-9Uup6kJNLbr6C5J4NDYmSm3HCSA6lp2iEiMPKU8Ii7QZ61kybXLxsX4w4Dm3fOLjmDw", + "userHandle": "T1RWa1l6VXdPRFV0WW1NNVlTMDBOVEkxTFRnd056Z3RabVZpWVdZNFpEVm1ZMk5p", + }, + "type": "public-key", + "authenticatorAttachment": "cross-platform", + "clientExtensionResults": {}, + } + ) + response = test_client.post(self.verify_url, data=verification_body) + assert response.json()["verified"] + + response = test_client.get("/ui/user/", allow_redirects=False) + assert response.status_code == 200 + + def test_invalid_authenticate(self, test_client, irrd_db_session_with_user): + os.environ[ENV_WEBAUTHN_TESTING_RP_OVERRIDE] = "http://localhost:5000,localhost" + os.environ[ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE] = ( + "iPmAi1Pp1XL6oAgq3PWZtZPnZa1zFUDoGbaQ0_KvVG1lF2s3Rt_3o4uSzccy0tmcTIpTTT4BU1T-I4maavndjQ=" + ) + + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + AuthWebAuthnFactory(user_id=str(user.pk)) + + # Sets WN_CHALLENGE_SESSION_KEY + response = test_client.get(self.url) + assert response.status_code == 200 + assert "simplewebauthn" in response.text + + # corrupted signature + verification_body = json.dumps( + { + "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "response": { + "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVBtQWkxUHAxWEw2b0FncTNQV1p0WlBuWmExekZVRG9HYmFRMF9LdlZHMWxGMnMzUnRfM280dVN6Y2N5MHRtY1RJcFRUVDRCVTFULUk0bWFhdm5kalEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", + "signature": "iOHKX3erx5_OYP_r_9HLZ-CexCE4bQRrxM8WmuoKTDdhAnZSeTP0sjECjvjfeS8MJzN1ArmvV0H0C3yy_FdRFfcpUPZzdZ7bBcmPh1XPdxRwY747OrIzcTLTFQUPdn1U-izCZtP_78VGw9pCpdMsv4CUzZdJbEcRtQuRS03qUjqDaovoJhOqEBmxJn9Wu8tBi_Qx7A33RbYjlfyLm_EDqimzDZhyietyop6XUcpKarKqVH0M6mMrM5zTjp8xf3W7odFCadXEJg-ERZqFM0-9Uup6kJNLbr6C5J4NDYmSm3HCSA6lp2iEiMPKU8Ii7QZ61kybXLxsX4w4Dm3fOLjmDw", + "userHandle": "T1RWa1l6VXdPRFV0WW1NNVlTMDBOVEkxTFRnd056Z3RabVZpWVdZNFpEVm1ZMk5p", + }, + "type": "public-key", + "authenticatorAttachment": "cross-platform", + "clientExtensionResults": {}, + } + ) + response = test_client.post(self.verify_url, data=verification_body) + assert not response.json()["verified"] + + response = test_client.get("/ui/user/", allow_redirects=False) + assert response.status_code != 200 + + +class TestWebAuthnRegister(WebRequestTest): + url = "/ui/auth/webauthn-register/" + verify_url = "/ui/auth/webauthn-verify-registration-response/" + requires_login = True + requires_mfa = True + + def test_render_form(self, test_client, irrd_db_session_with_user): + if ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE in os.environ: + del os.environ[ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE] # pragma: no cover + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + token = AuthWebAuthnFactory(user_id=str(user.pk)) + response = test_client.get(self.url) + assert response.status_code == 200 + assert base64.b64encode(token.credential_id)[:10] in response.content + + challenge1 = json.loads(response.context["webauthn_options_json"])["challenge"] + response = test_client.get(self.url) + challenge2 = json.loads(response.context["webauthn_options_json"])["challenge"] + assert challenge1 != challenge2 + + def test_valid_register(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + os.environ[ENV_WEBAUTHN_TESTING_RP_OVERRIDE] = "http://localhost:5000,localhost" + os.environ[ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE] = ( + "CeTWogmg0cchuiYuFrv8DXXdMZSIQRVZJOga_xayVVEcBj0Cw3y73yhD4FkGSe-RrP6hPJJAIm3LVien4hXELg" + ) + + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + # Sets WN_CHALLENGE_SESSION_KEY + response = test_client.get(self.url) + assert response.status_code == 200 + assert "simplewebauthn" in response.text + + key_name = "key name" + registration_body = { + "name": key_name, + "registration_response": json.dumps( + { + "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVkBZ0mWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjRQAAAAAAAAAAAAAAAAAAAAAAAAAAACBmggo_UlC8p2tiPVtNQ8nZ5NSxst4WS_5fnElA2viTq6QBAwM5AQAgWQEA31dtHqc70D_h7XHQ6V_nBs3Tscu91kBL7FOw56_VFiaKYRH6Z4KLr4J0S12hFJ_3fBxpKfxyMfK66ZMeAVbOl_wemY4S5Xs4yHSWy21Xm_dgWhLJjZ9R1tjfV49kDPHB_ssdvP7wo3_NmoUPYMgK-edgZ_ehttp_I6hUUCnVaTvn_m76b2j9yEPReSwl-wlGsabYG6INUhTuhSOqG-UpVVQdNJVV7GmIPHCA2cQpJBDZBohT4MBGme_feUgm4sgqVCWzKk6CzIKIz5AIVnspLbu05SulAVnSTB3NxTwCLNJR_9v9oSkvphiNbmQBVQH1tV_psyi9HM1Jtj9VJVKMeyFDAQAB", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQ2VUV29nbWcwY2NodWlZdUZydjhEWFhkTVpTSVFSVlpKT2dhX3hheVZWRWNCajBDdzN5NzN5aEQ0RmtHU2UtUnJQNmhQSkpBSW0zTFZpZW40aFhFTGciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", + "transports": ["internal"], + }, + "type": "public-key", + "authenticatorAttachment": "platform", + "clientExtensionResults": {}, + } + ), + } + + response = test_client.post(self.verify_url, json=registration_body) + assert response.json()["success"] + + token = session_provider.run_sync(session_provider.session.query(AuthWebAuthn).one) + assert token.name == key_name + assert len(smtpd.messages) == 1 + assert "token was added" in smtpd.messages[0].as_string() + + def test_invalid_register(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + os.environ[ENV_WEBAUTHN_TESTING_RP_OVERRIDE] = "http://localhost:5000,localhost" + os.environ[ENV_WEBAUTHN_TESTING_CHALLENGE_OVERRIDE] = ( + "CeTWogmg0cchuiYuFrv8DXXdMZSIQRVZJOga_xayVVEcBj0Cw3y73yhD4FkGSe-RrP6hPJJAIm3LVien4hXELg" + ) + + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + + # Sets WN_CHALLENGE_SESSION_KEY + response = test_client.get(self.url) + assert response.status_code == 200 + assert "simplewebauthn" in response.text + + registration_body = { + "name": "key name", + "registration_response": json.dumps( + { + "id": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "rawId": "ZoIKP1JQvKdrYj1bTUPJ2eTUsbLeFkv-X5xJQNr4k6s", + "response": { + "attestationObject": "invalidXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVkBZ0mWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjRQAAAAAAAAAAAAAAAAAAAAAAAAAAACBmggo_UlC8p2tiPVtNQ8nZ5NSxst4WS_5fnElA2viTq6QBAwM5AQAgWQEA31dtHqc70D_h7XHQ6V_nBs3Tscu91kBL7FOw56_VFiaKYRH6Z4KLr4J0S12hFJ_3fBxpKfxyMfK66ZMeAVbOl_wemY4S5Xs4yHSWy21Xm_dgWhLJjZ9R1tjfV49kDPHB_ssdvP7wo3_NmoUPYMgK-edgZ_ehttp_I6hUUCnVaTvn_m76b2j9yEPReSwl-wlGsabYG6INUhTuhSOqG-UpVVQdNJVV7GmIPHCA2cQpJBDZBohT4MBGme_feUgm4sgqVCWzKk6CzIKIz5AIVnspLbu05SulAVnSTB3NxTwCLNJR_9v9oSkvphiNbmQBVQH1tV_psyi9HM1Jtj9VJVKMeyFDAQAB", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQ2VUV29nbWcwY2NodWlZdUZydjhEWFhkTVpTSVFSVlpKT2dhX3hheVZWRWNCajBDdzN5NzN5aEQ0RmtHU2UtUnJQNmhQSkpBSW0zTFZpZW40aFhFTGciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9", + "transports": ["internal"], + }, + "type": "public-key", + "authenticatorAttachment": "platform", + "clientExtensionResults": {}, + } + ), + } + + response = test_client.post(self.verify_url, json=registration_body) + assert not response.json()["success"] + + assert not session_provider.run_sync(session_provider.session.query(AuthWebAuthn).one) + assert not smtpd.messages + + +class TestWebAuthnRemove(WebRequestTest): + url_template = "/ui/auth/webauthn-remove/{uuid}/" + + def pre_login(self, session_provider, user, user_management=True): + self.webauthn = AuthWebAuthnFactory(user_id=str(user.pk)) + self.url = self.url_template.format(uuid=self.webauthn.pk) + + def test_render_form(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert self.webauthn.name in response.text + + def test_valid_remove(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, data={"current_password": SAMPLE_USER_PASSWORD}, allow_redirects=False + ) + assert response.status_code == 302 + + assert not session_provider.run_sync(session_provider.session.query(AuthWebAuthn).one) + assert len(smtpd.messages) == 1 + assert "token was removed" in smtpd.messages[0].as_string() + + def test_invalid_incorrect_current_password(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post(self.url, data={"current_password": "invalid"}, allow_redirects=False) + assert response.status_code == 200 + assert "Incorrect password." in response.text + + assert session_provider.run_sync(session_provider.session.query(AuthWebAuthn).one) + assert not smtpd.messages + + def test_invalid_object_not_exists(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + url = self.url_template.format(uuid=uuid.uuid4()) + + response = test_client.get(url) + assert response.status_code == 404 + assert not smtpd.messages + + +class TestTOTPRegister(WebRequestTest): + url = "/ui/auth/totp-register/" + + def get_secret(self, test_client, session_provider, user): + self._login_if_needed(test_client, user) + user.totp_secret = None + session_provider.session.commit() + + response = test_client.get(self.url) + assert response.status_code == 200 + return response.context["secret"] + + def test_valid(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + secret = self.get_secret(test_client, session_provider, user) + + response = test_client.post( + self.url, + data={"token": pyotp.TOTP(secret).now(), "current_password": SAMPLE_USER_PASSWORD}, + allow_redirects=False, + ) + assert response.status_code == 302 + + session_provider.session.refresh(user) + assert user.totp_secret == secret + assert len(smtpd.messages) == 1 + assert "time password was added" in smtpd.messages[0].as_string() + + def test_invalid_token(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.get_secret(test_client, session_provider, user) + + response = test_client.post( + self.url, + data={"token": "invalid", "current_password": SAMPLE_USER_PASSWORD}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Incorrect token" in response.text + + session_provider.session.refresh(user) + assert not user.totp_secret + assert not smtpd.messages + + def test_invalid_current_password(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + secret = self.get_secret(test_client, session_provider, user) + + response = test_client.post( + self.url, + data={"token": pyotp.TOTP(secret).now(), "current_password": "invalid"}, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Incorrect password" in response.text + + session_provider.session.refresh(user) + assert not user.totp_secret + assert not smtpd.messages + + def test_missing_token(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.get_secret(test_client, session_provider, user) + + response = test_client.post( + self.url, data={"current_password": SAMPLE_USER_PASSWORD}, allow_redirects=False + ) + assert response.status_code == 200 + assert "This field is required" in response.text + + session_provider.session.refresh(user) + assert not user.totp_secret + assert not smtpd.messages + + +class TestTOTPRemove(WebRequestTest): + url = "/ui/auth/totp-remove/" + + def test_render_form(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + + def test_valid_remove(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, data={"current_password": SAMPLE_USER_PASSWORD}, allow_redirects=False + ) + assert response.status_code == 302 + + session_provider.session.refresh(user) + assert not user.totp_secret + assert "time password was removed" in smtpd.messages[0].as_string() + + def test_invalid_incorrect_current_password(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post(self.url, data={"current_password": "invalid"}, allow_redirects=False) + assert response.status_code == 200 + assert "Incorrect password." in response.text + + session_provider.session.refresh(user) + assert user.totp_secret + assert not smtpd.messages diff --git a/irrd/webui/tests/test_endpoints.py b/irrd/webui/tests/test_endpoints.py new file mode 100644 index 000000000..ebb900211 --- /dev/null +++ b/irrd/webui/tests/test_endpoints.py @@ -0,0 +1,254 @@ +from datetime import datetime, timezone +from unittest.mock import create_autospec + +import pytest + +from irrd.updates.handler import ChangeSubmissionHandler +from irrd.utils.rpsl_samples import SAMPLE_MNTNER +from irrd.webui import datetime_format + +from .conftest import WebRequestTest, create_permission + + +def test_datetime_format(): + date = datetime(2022, 3, 14, 12, 34, 56, tzinfo=timezone.utc) + assert datetime_format(date) == "2022-03-14 12:34" + + +class TestIndex(WebRequestTest): + url = "/ui/" + requires_login = False + requires_mfa = False + + def test_index(self, test_client): + response = test_client.get(self.url) + assert response.status_code == 200 + assert response.context["auth_sources"] == ["TEST"] + assert response.context["mirrored_sources"] == ["MIRROR"] + + +class TestUserDetail(WebRequestTest): + url = "/ui/user/" + + def test_get(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url) + assert response.status_code == 200 + + +class TestMaintainedObjects(WebRequestTest): + url = "/ui/maintained-objects" + + def test_get_no_user_mntners(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url) + assert response.status_code == 200 + assert response.context["objects"] is None + + def test_get(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + create_permission(session_provider, user) + self._login_if_needed(test_client, user) + response = test_client.get(self.url) + assert response.status_code == 200 + assert len(response.context["objects"]) == 3 + displayed_pks = {obj["rpsl_pk"] for obj in response.context["objects"]} + assert displayed_pks == {"ROLE-TEST", "TEST-MNT", "PERSON-TEST"} + + +class TestRpslDetail(WebRequestTest): + url = "/ui/rpsl/TEST/mntner/TEST-MNT" + requires_login = False + requires_mfa = False + + def test_valid_mntner_logged_in_mfa_complete_user_management( + self, test_client, irrd_db_session_with_user + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + self._verify_mfa(test_client) + create_permission(session_provider, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "DUMMYVALUE" not in response.text.upper() + + def test_valid_mntner_not_logged_in(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + create_permission(session_provider, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "DUMMYVALUE" in response.text.upper() + + def test_valid_mntner_logged_in_mfa_incomplete_user_management( + self, test_client, irrd_db_session_with_user + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + # No MFA + create_permission(session_provider, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "DUMMYVALUE" in response.text.upper() + + def test_valid_mntner_logged_in_mfa_complete_no_user_management( + self, test_client, irrd_db_session_with_user + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + self._verify_mfa(test_client) + create_permission(session_provider, user, user_management=False) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "DUMMYVALUE" in response.text.upper() + + def test_object_not_exists(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + create_permission(session_provider, user) + + response = test_client.get(self.url + "-none") + assert response.status_code == 200 + assert "TEST-MNT" not in response.text + + +@pytest.fixture() +def mock_change_submission_handler(monkeypatch): + mock_csh = create_autospec(ChangeSubmissionHandler) + monkeypatch.setattr("irrd.webui.endpoints.ChangeSubmissionHandler", mock_csh) + return mock_csh + + +class TestRpslUpdateNoInitial(WebRequestTest): + url = "/ui/rpsl/update/" + requires_login = False + requires_mfa = False + + def test_valid_mntner_logged_in_mfa_complete_no_user_management( + self, test_client, irrd_db_session_with_user, mock_change_submission_handler + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + self._verify_mfa(test_client) + create_permission(session_provider, user, user_management=False) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "(you can not update this mntner itself)" in response.text + + def test_valid_mntner_logged_in_mfa_complete_user_management( + self, test_client, irrd_db_session_with_user, mock_change_submission_handler + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + self._verify_mfa(test_client) + create_permission(session_provider, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "(you can not update this mntner itself)" not in response.text + + response = test_client.post(self.url, data={"data": SAMPLE_MNTNER}) + assert response.status_code == 200 + assert mock_change_submission_handler.mock_calls[1][0] == "().load_text_blob" + mock_handler_kwargs = mock_change_submission_handler.mock_calls[1][2] + assert mock_handler_kwargs["object_texts_blob"] == SAMPLE_MNTNER + assert mock_handler_kwargs["internal_authenticated_user"].pk == user.pk + + def test_valid_mntner_logged_in_mfa_incomplete_user_management( + self, test_client, irrd_db_session_with_user, mock_change_submission_handler + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + create_permission(session_provider, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" not in response.text + + response = test_client.post(self.url, data={"data": SAMPLE_MNTNER}) + assert response.status_code == 200 + assert mock_change_submission_handler.mock_calls[1][0] == "().load_text_blob" + mock_handler_kwargs = mock_change_submission_handler.mock_calls[1][2] + assert mock_handler_kwargs["object_texts_blob"] == SAMPLE_MNTNER + assert mock_handler_kwargs["internal_authenticated_user"] is None + + def test_valid_mntner_not_logged_in( + self, test_client, irrd_db_session_with_user, mock_change_submission_handler + ): + session_provider, user = irrd_db_session_with_user + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" not in response.text + + response = test_client.post(self.url, data={"data": SAMPLE_MNTNER}) + assert response.status_code == 200 + assert mock_change_submission_handler.mock_calls[1][0] == "().load_text_blob" + mock_handler_kwargs = mock_change_submission_handler.mock_calls[1][2] + assert mock_handler_kwargs["object_texts_blob"] == SAMPLE_MNTNER + assert mock_handler_kwargs["internal_authenticated_user"] is None + + +class TestRpslUpdateWithInitial(WebRequestTest): + url = "/ui/rpsl/update/TEST/mntner/TEST-MNT/" + requires_login = False + requires_mfa = False + + def test_valid_mntner_logged_in_mfa_complete_no_user_management( + self, test_client, irrd_db_session_with_user + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + self._verify_mfa(test_client) + create_permission(session_provider, user, user_management=False) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "(you can not update this mntner itself)" in response.text + assert "DUMMYVALUE" in response.text.upper() + + def test_valid_mntner_logged_in_mfa_complete_user_management( + self, test_client, irrd_db_session_with_user + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + self._verify_mfa(test_client) + create_permission(session_provider, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "(you can not update this mntner itself)" not in response.text + assert "DUMMYVALUE" not in response.text.upper() + + def test_valid_mntner_logged_in_mfa_incomplete_user_management( + self, test_client, irrd_db_session_with_user + ): + session_provider, user = irrd_db_session_with_user + self._login(test_client, user) + create_permission(session_provider, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "DUMMYVALUE" in response.text.upper() + + def test_valid_mntner_not_logged_in(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + assert "DUMMYVALUE" in response.text.upper() diff --git a/irrd/webui/tests/test_endpoints_mntners.py b/irrd/webui/tests/test_endpoints_mntners.py new file mode 100644 index 000000000..0916c4bfa --- /dev/null +++ b/irrd/webui/tests/test_endpoints_mntners.py @@ -0,0 +1,524 @@ +import uuid + +from irrd.storage.models import AuthPermission +from irrd.utils.factories import SAMPLE_USER_PASSWORD, AuthUserFactory + +from ...conf import RPSL_MNTNER_AUTH_INTERNAL +from ...rpsl.rpsl_objects import rpsl_object_from_text +from ...utils.rpsl_samples import SAMPLE_MNTNER, SAMPLE_MNTNER_BCRYPT +from .conftest import WebRequestTest, create_permission + + +class TestPermissionAdd(WebRequestTest): + url_template = "/ui/permission/add/{uuid}/" + + def pre_login(self, session_provider, user, user_management=True): + self.permission = create_permission(session_provider, user, user_management=user_management) + self.url = self.url_template.format(uuid=self.permission.mntner.pk) + + def test_render_form(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + + def test_valid_without_new_user_management(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + user2 = AuthUserFactory() + response = test_client.post( + self.url, + data={"new_user_email": user2.email, "confirm": "1", "current_password": SAMPLE_USER_PASSWORD}, + allow_redirects=False, + ) + assert response.status_code == 302 + + new_permission = session_provider.run_sync( + session_provider.session.query(AuthPermission).filter(AuthPermission.user_id != str(user.pk)).one + ) + assert new_permission.user == user2 + assert new_permission.mntner == self.permission.mntner + assert not new_permission.user_management + assert len(smtpd.messages) == 3 + assert user2.email in smtpd.messages[1].as_string() + + # Try a second time with the same user + response = test_client.post( + self.url, + data={"new_user_email": user2.email, "confirm": "1", "current_password": SAMPLE_USER_PASSWORD}, + allow_redirects=False, + ) + assert response.status_code == 200 + permission_count = session_provider.run_sync(session_provider.session.query(AuthPermission).count) + assert permission_count == 2 + + def test_valid_with_new_user_management(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + user2 = AuthUserFactory() + response = test_client.post( + self.url, + data={ + "new_user_email": user2.email, + "confirm": "1", + "user_management": "1", + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 302 + + new_permission = session_provider.run_sync( + session_provider.session.query(AuthPermission).filter(AuthPermission.user_id != str(user.pk)).one + ) + assert new_permission.user == user2 + assert new_permission.mntner == self.permission.mntner + assert new_permission.user_management + + assert len(smtpd.messages) == 3 + assert user2.email in smtpd.messages[1].as_string() + + def test_invalid_incorrect_current_password(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + user2 = AuthUserFactory() + response = test_client.post( + self.url, + data={ + "new_user_email": user2.email, + "confirm": "1", + "user_management": "1", + "current_password": "incorrect", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + + new_permission = session_provider.run_sync( + session_provider.session.query(AuthPermission).filter(AuthPermission.user_id != str(user.pk)).one + ) + assert new_permission is None + + def test_invalid_new_user_does_not_exist(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "new_user_email": "doesnotexist@example.com", + "confirm": "1", + "user_management": "1", + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + + new_permission = session_provider.run_sync( + session_provider.session.query(AuthPermission).filter(AuthPermission.user_id != str(user.pk)).one + ) + assert new_permission is None + + def test_missing_user_management_on_mntner(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user, user_management=False) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 404 + + def test_object_not_exists(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url_template.format(uuid=uuid.uuid4())) + assert response.status_code == 404 + + +class TestPermissionDelete(WebRequestTest): + url_template = "/ui/permission/delete/{uuid}/" + + def pre_login(self, session_provider, user, user_management=True): + self.permission = create_permission(session_provider, user, user_management=user_management) + self.user2 = AuthUserFactory() + self.permission2 = create_permission( + session_provider, self.user2, mntner=self.permission.mntner, user_management=user_management + ) + self.url = self.url_template.format(uuid=self.permission2.pk) + + def test_render_form(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "TEST-MNT" in response.text + + def test_valid_other_delete(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "confirm": "1", + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 302 + + deleted_permission = session_provider.run_sync( + session_provider.session.query(AuthPermission).filter(AuthPermission.user_id != str(user.pk)).one + ) + assert deleted_permission is None + + assert len(smtpd.messages) == 3 + assert self.user2.email in smtpd.messages[1].as_string() + + def test_valid_self_delete(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + f"/ui/permission/delete/{self.permission.pk}/", + data={ + "confirm": "1", + "confirm_self_delete": "1", + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 302 + + deleted_permission = session_provider.run_sync( + session_provider.session.query(AuthPermission).filter(AuthPermission.user_id == str(user.pk)).one + ) + assert deleted_permission is None + + assert len(smtpd.messages) == 3 + assert user.email in smtpd.messages[1].as_string() + + def test_invalid_refuse_last_delete(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + session_provider.session.delete(self.permission2) + session_provider.session.commit() + + response = test_client.post( + f"/ui/permission/delete/{self.permission.pk}/", + data={ + "confirm": "1", + "current_password": SAMPLE_USER_PASSWORD, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + + deleted_permission = session_provider.run_sync( + session_provider.session.query(AuthPermission).filter(AuthPermission.user_id == str(user.pk)).one + ) + assert deleted_permission is not None + + def test_invalid_incorrect_current_password(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "confirm": "1", + "current_password": "incorrect", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + + deleted_permission = session_provider.run_sync( + session_provider.session.query(AuthPermission).filter(AuthPermission.user_id != str(user.pk)).one + ) + assert deleted_permission is not None + + def test_missing_user_management_on_mntner(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user, user_management=False) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 404 + + def test_object_not_exists(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self._login_if_needed(test_client, user) + response = test_client.get(self.url_template.format(uuid=uuid.uuid4())) + assert response.status_code == 404 + + +class TestMntnerMigrateInitiate(WebRequestTest): + url = "/ui/migrate-mntner/" + + def pre_login(self, session_provider, user): + self.mntner_obj = rpsl_object_from_text(SAMPLE_MNTNER) + + def test_render_form(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + + def test_render_form_disabled(self, test_client, irrd_db_session_with_user, config_override): + config_override( + { + "server": {"http": {"url": "http://testserver/"}}, + "secret_key": "s", + "auth": {"irrd_internal_migration_enabled": False}, + } + ) + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + assert "mntners is not enabled on this instance" in response.text + + def test_valid_submit(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "mntner_key": self.mntner_obj.pk(), + "mntner_source": self.mntner_obj.source(), + "mntner_password": SAMPLE_MNTNER_BCRYPT, + "confirm": "1", + }, + allow_redirects=False, + ) + assert response.status_code == 302 + + new_permission = session_provider.run_sync(session_provider.session.query(AuthPermission).one) + assert new_permission.mntner.rpsl_mntner_pk == self.mntner_obj.pk() + assert new_permission.user == user + assert new_permission.user_management + + assert len(smtpd.messages) == 1 + assert "email@example.com" == smtpd.messages[0]["To"] + assert self.mntner_obj.pk() in smtpd.messages[0].as_string() + assert new_permission.mntner.migration_token in smtpd.messages[0].as_string() + + def test_invalid_password(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "mntner_key": self.mntner_obj.pk(), + "mntner_source": self.mntner_obj.source(), + "mntner_password": SAMPLE_MNTNER_BCRYPT + "-invalid", + "confirm": "1", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Invalid password" in response.text + + new_permission = session_provider.run_sync(session_provider.session.query(AuthPermission).one) + assert not new_permission + + def test_already_migrated(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + create_permission(session_provider, user) + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "mntner_key": self.mntner_obj.pk(), + "mntner_source": self.mntner_obj.source(), + "mntner_password": SAMPLE_MNTNER_BCRYPT, + "confirm": "1", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "already migrated" in response.text + + def test_missing_confirm(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "mntner_key": self.mntner_obj.pk(), + "mntner_source": self.mntner_obj.source(), + "mntner_password": SAMPLE_MNTNER_BCRYPT + "-invalid", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "This field is required" in response.text + + new_permission = session_provider.run_sync(session_provider.session.query(AuthPermission).one) + assert not new_permission + + def test_mntner_does_not_exist(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + response = test_client.post( + self.url, + data={ + "mntner_key": self.mntner_obj.pk() + "-not-exist", + "mntner_source": self.mntner_obj.source(), + "mntner_password": SAMPLE_MNTNER_BCRYPT, + "confirm": "1", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Unable to find" in response.text + + new_permission = session_provider.run_sync(session_provider.session.query(AuthPermission).one) + assert not new_permission + + +class TestMntnerMigrateComplete(WebRequestTest): + url_template = "/ui/migrate-mntner/complete/{uuid}/{token}/" + + def pre_login(self, session_provider, user, user_management=True): + self.permission = create_permission(session_provider, user, user_management=user_management) + self.permission.mntner.migration_token = "migration-token" + session_provider.session.commit() + self.url = self.url_template.format( + uuid=self.permission.mntner.pk, token=self.permission.mntner.migration_token + ) + + def test_render_form(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.get(self.url) + assert response.status_code == 200 + + def test_valid_submit(self, test_client_with_smtp, irrd_db_session_with_user): + test_client, smtpd = test_client_with_smtp + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "mntner_password": SAMPLE_MNTNER_BCRYPT, + "confirm": "1", + }, + allow_redirects=False, + ) + assert response.status_code == 302 + + session_provider.session.refresh(self.permission.mntner) + assert not self.permission.mntner.migration_token + assert RPSL_MNTNER_AUTH_INTERNAL in self.permission.mntner.rpsl_mntner_obj.parsed_data["auth"] + + assert len(smtpd.messages) == 3 + assert user.email in smtpd.messages[1].as_string() + + def test_invalid_password(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "mntner_password": SAMPLE_MNTNER_BCRYPT + "-invalid", + "confirm": "1", + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "Invalid password" in response.text + + session_provider.session.refresh(self.permission.mntner) + assert self.permission.mntner.migration_token + assert RPSL_MNTNER_AUTH_INTERNAL not in self.permission.mntner.rpsl_mntner_obj.parsed_data["auth"] + + def test_invalid_token_or_unknown_id(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + url=f"/ui/migrate-mntner/complete/{uuid.uuid4()}/{self.permission.mntner.migration_token}/", + data={ + "mntner_password": SAMPLE_MNTNER_BCRYPT, + "confirm": "1", + }, + allow_redirects=False, + ) + assert response.status_code == 404 + + response = test_client.post( + url=f"/ui/migrate-mntner/complete/{self.permission.mntner.pk}/bad-token/", + data={ + "mntner_password": SAMPLE_MNTNER_BCRYPT, + "confirm": "1", + }, + allow_redirects=False, + ) + assert response.status_code == 404 + + session_provider.session.refresh(self.permission.mntner) + assert self.permission.mntner.migration_token + assert RPSL_MNTNER_AUTH_INTERNAL not in self.permission.mntner.rpsl_mntner_obj.parsed_data["auth"] + + def test_missing_confirm(self, test_client, irrd_db_session_with_user): + session_provider, user = irrd_db_session_with_user + self.pre_login(session_provider, user) + self._login_if_needed(test_client, user) + + response = test_client.post( + self.url, + data={ + "mntner_password": SAMPLE_MNTNER_BCRYPT, + }, + allow_redirects=False, + ) + assert response.status_code == 200 + assert "This field is required" in response.text + + session_provider.session.refresh(self.permission.mntner) + assert self.permission.mntner.migration_token + assert RPSL_MNTNER_AUTH_INTERNAL not in self.permission.mntner.rpsl_mntner_obj.parsed_data["auth"] diff --git a/poetry.lock b/poetry.lock index aea892f55..24d858bf9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. [[package]] name = "aiohttp" @@ -124,6 +124,22 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "aiosmtpd" +version = "1.4.4.post2" +description = "aiosmtpd - asyncio based SMTP server" +category = "dev" +optional = false +python-versions = "~=3.7" +files = [ + {file = "aiosmtpd-1.4.4.post2-py3-none-any.whl", hash = "sha256:f821fe424b703b2ea391dc2df11d89d2afd728af27393e13cf1a3530f19fdc5e"}, + {file = "aiosmtpd-1.4.4.post2.tar.gz", hash = "sha256:f9243b7dfe00aaf567da8728d891752426b51392174a34d2cf5c18053b63dcbc"}, +] + +[package.dependencies] +atpublic = "*" +attrs = "*" + [[package]] name = "alabaster" version = "0.7.13" @@ -226,6 +242,18 @@ files = [ [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +[[package]] +name = "asn1crypto" +version = "1.5.1" +description = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67"}, + {file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"}, +] + [[package]] name = "async-timeout" version = "4.0.2" @@ -238,24 +266,36 @@ files = [ {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] +[[package]] +name = "atpublic" +version = "3.1.1" +description = "Keep all y'all's __all__'s in sync" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "atpublic-3.1.1-py3-none-any.whl", hash = "sha256:37f714748e77b8a7b34d59b7b485fd452a0d5906be52cb1bd28d29a2bd84f295"}, + {file = "atpublic-3.1.1.tar.gz", hash = "sha256:3098ee12d0107cc5009d61f4e80e5edcfac4cda2bdaa04644af75827cb121b18"}, +] + [[package]] name = "attrs" -version = "22.2.0" +version = "23.1.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, - {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, + {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, + {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, ] [package.extras] -cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] -tests = ["attrs[tests-no-zope]", "zope.interface"] -tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] [[package]] name = "automat" @@ -278,18 +318,18 @@ visualize = ["Twisted (>=16.1.1)", "graphviz (>0.5.1)"] [[package]] name = "babel" -version = "2.11.0" +version = "2.12.1" description = "Internationalization utilities" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, - {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, + {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, + {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, ] [package.dependencies] -pytz = ">=2015.7" +pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} [[package]] name = "bcrypt" @@ -388,6 +428,56 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cbor2" +version = "5.4.6" +description = "CBOR (de)serializer with extensive tag support" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cbor2-5.4.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:309fffbb7f561d67f02095d4b9657b73c9220558701c997e9bfcfbca2696e927"}, + {file = "cbor2-5.4.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ff95b33e5482313a74648ca3620c9328e9f30ecfa034df040b828e476597d352"}, + {file = "cbor2-5.4.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db9eb582fce972f0fa429d8159b7891ff8deccb7affc4995090afc61ce0d328a"}, + {file = "cbor2-5.4.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3950be57a1698086cf26d8710b4e5a637b65133c5b1f9eec23967d4089d8cfed"}, + {file = "cbor2-5.4.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:78304df140b9e13b93bcbb2aecee64c9aaa9f1cadbd45f043b5e7b93cc2f21a2"}, + {file = "cbor2-5.4.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e73ca40dd3c7210ff776acff9869ddc9ff67bae7c425b58e5715dcf55275163f"}, + {file = "cbor2-5.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:0b956f19e93ba3180c336282cd1b6665631f2d3a196a9c19b29a833bf979e7a4"}, + {file = "cbor2-5.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c12c0ab78f5bc290b08a79152a8621822415836a86f8f4b50dadba371736fda"}, + {file = "cbor2-5.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3545b16f9f0d5f34d4c99052829c3726020a07be34c99c250d0df87418f02954"}, + {file = "cbor2-5.4.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24144822f8d2b0156f4cda9427f071f969c18683ffed39663dc86bc0a75ae4dd"}, + {file = "cbor2-5.4.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1835536e76ea16e88c934aac5e369ba9f93d495b01e5fa2d93f0b4986b89146d"}, + {file = "cbor2-5.4.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:39452c799453f5bf33281ffc0752c620b8bfa0b7c13070b87d370257a1311976"}, + {file = "cbor2-5.4.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3316f09a77af85e7772ecfdd693b0f450678a60b1aee641bac319289757e3fa0"}, + {file = "cbor2-5.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:456cdff668a50a52fdb8aa6d0742511e43ed46d6a5b463dba80a5a720fa0d320"}, + {file = "cbor2-5.4.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9394ca49ecdf0957924e45d09a4026482d184a465a047f60c4044eb464c43de9"}, + {file = "cbor2-5.4.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56dfa030cd3d67e5b6701d3067923f2f61536a8ffb1b45be14775d1e866b59ae"}, + {file = "cbor2-5.4.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5094562dfe3e5583202b93ef7ca5082c2ba5571accb2c4412d27b7d0ba8a563"}, + {file = "cbor2-5.4.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:94f844d0e232aca061a86dd6ff191e47ba0389ddd34acb784ad9a41594dc99a4"}, + {file = "cbor2-5.4.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7bbd3470eb685325398023e335be896b74f61b014896604ed45049a7b7b6d8ac"}, + {file = "cbor2-5.4.6-cp37-cp37m-win_amd64.whl", hash = "sha256:0bd12c54a48949d11f5ffc2fa27f5df1b4754111f5207453e5fae3512ebb3cab"}, + {file = "cbor2-5.4.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2984a488f350aee1d54fa9cb8c6a3c1f1f5b268abbc91161e47185de4d829f3"}, + {file = "cbor2-5.4.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c285a2cb2c04004bfead93df89d92a0cef1874ad337d0cb5ea53c2c31e97bfdb"}, + {file = "cbor2-5.4.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6709d97695205cd08255363b54afa035306d5302b7b5e38308c8ff5a47e60f2a"}, + {file = "cbor2-5.4.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96087fa5336ebfc94465c0768cd5de0fcf9af3840d2cf0ce32f5767855f1a293"}, + {file = "cbor2-5.4.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0d2b926b024d3a1549b819bc82fdc387062bbd977b0299dd5fa5e0ea3267b98b"}, + {file = "cbor2-5.4.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6e1b5aee920b6a2f737aa12e2b54de3826b09f885a7ce402db84216343368140"}, + {file = "cbor2-5.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:79e048e623846d60d735bb350263e8fdd36cb6195d7f1a2b57eacd573d9c0b33"}, + {file = "cbor2-5.4.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80ac8ba450c7a41c5afe5f7e503d3092442ed75393e1de162b0bf0d97edf7c7f"}, + {file = "cbor2-5.4.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ce1a2c272ba8523a55ea2f1d66e3464e89fa0e37c9a3d786a919fe64e68dbd7"}, + {file = "cbor2-5.4.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1618d16e310f7ffed141762b0ff5d8bb6b53ad449406115cc465bf04213cefcf"}, + {file = "cbor2-5.4.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4bbbdb2e3ef274865dc3f279aae109b5d94f4654aea3c72c479fb37e4a1e7ed7"}, + {file = "cbor2-5.4.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6f9c702bee2954fffdfa3de95a5af1a6b1c5f155e39490353d5654d83bb05bb9"}, + {file = "cbor2-5.4.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b9f3924da0e460a93b3674c7e71020dd6c9e9f17400a34e52a88c0af2dcd2aa"}, + {file = "cbor2-5.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:d54bd840b4fe34f097b8665fc0692c7dd175349e53976be6c5de4433b970daa4"}, + {file = "cbor2-5.4.6-py3-none-any.whl", hash = "sha256:181ac494091d1f9c5bb373cd85514ce1eb967a8cf3ec298e8dfa8878aa823956"}, + {file = "cbor2-5.4.6.tar.gz", hash = "sha256:b893500db0fe033e570c3adc956af6eefc57e280026bd2d86fd53da9f1e594d7"}, +] + +[package.extras] +doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["pytest", "pytest-cov"] + [[package]] name = "certifi" version = "2022.12.7" @@ -479,100 +569,87 @@ pycparser = "*" [[package]] name = "charset-normalizer" -version = "3.0.1" +version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = "*" -files = [ - {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, - {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, + {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, ] [[package]] @@ -658,63 +735,63 @@ wrapt = ">=1.1.0,<2" [[package]] name = "coverage" -version = "7.2.0" +version = "7.2.3" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "coverage-7.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90e7a4cbbb7b1916937d380beb1315b12957b8e895d7d9fb032e2038ac367525"}, - {file = "coverage-7.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:34d7211be69b215ad92298a962b2cd5a4ef4b17c7871d85e15d3d1b6dc8d8c96"}, - {file = "coverage-7.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971b49dbf713044c3e5f6451b39f65615d4d1c1d9a19948fa0f41b0245a98765"}, - {file = "coverage-7.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0557289260125a6c453ad5673ba79e5b6841d9a20c9e101f758bfbedf928a77"}, - {file = "coverage-7.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:049806ae2df69468c130f04f0fab4212c46b34ba5590296281423bb1ae379df2"}, - {file = "coverage-7.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:875b03d92ac939fbfa8ae74a35b2c468fc4f070f613d5b1692f9980099a3a210"}, - {file = "coverage-7.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c160e34e388277f10c50dc2c7b5e78abe6d07357d9fe7fcb2f3c156713fd647e"}, - {file = "coverage-7.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:32e6a730fd18b2556716039ab93278ccebbefa1af81e6aa0c8dba888cf659e6e"}, - {file = "coverage-7.2.0-cp310-cp310-win32.whl", hash = "sha256:f3ff4205aff999164834792a3949f82435bc7c7655c849226d5836c3242d7451"}, - {file = "coverage-7.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:93db11da6e728587e943dff8ae1b739002311f035831b6ecdb15e308224a4247"}, - {file = "coverage-7.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cd38140b56538855d3d5722c6d1b752b35237e7ea3f360047ce57f3fade82d98"}, - {file = "coverage-7.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9dbb21561b0e04acabe62d2c274f02df0d715e8769485353ddf3cf84727e31ce"}, - {file = "coverage-7.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:171dd3aa71a49274a7e4fc26f5bc167bfae5a4421a668bc074e21a0522a0af4b"}, - {file = "coverage-7.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4655ecd813f4ba44857af3e9cffd133ab409774e9d2a7d8fdaf4fdfd2941b789"}, - {file = "coverage-7.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1856a8c4aa77eb7ca0d42c996d0ca395ecafae658c1432b9da4528c429f2575c"}, - {file = "coverage-7.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd67df6b48db18c10790635060858e2ea4109601e84a1e9bfdd92e898dc7dc79"}, - {file = "coverage-7.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2d7daf3da9c7e0ed742b3e6b4de6cc464552e787b8a6449d16517b31bbdaddf5"}, - {file = "coverage-7.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bf9e02bc3dee792b9d145af30db8686f328e781bd212fdef499db5e9e4dd8377"}, - {file = "coverage-7.2.0-cp311-cp311-win32.whl", hash = "sha256:3713a8ec18781fda408f0e853bf8c85963e2d3327c99a82a22e5c91baffcb934"}, - {file = "coverage-7.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:88ae5929f0ef668b582fd7cad09b5e7277f50f912183cf969b36e82a1c26e49a"}, - {file = "coverage-7.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5e29a64e9586194ea271048bc80c83cdd4587830110d1e07b109e6ff435e5dbc"}, - {file = "coverage-7.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d5302eb84c61e758c9d68b8a2f93a398b272073a046d07da83d77b0edc8d76b"}, - {file = "coverage-7.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c9fffbc39dc4a6277e1525cab06c161d11ee3995bbc97543dc74fcec33e045b"}, - {file = "coverage-7.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ceeab5fca62bca072eba6865a12d881f281c74231d2990f8a398226e1a5d96"}, - {file = "coverage-7.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:28563a35ef4a82b5bc5160a01853ce62b9fceee00760e583ffc8acf9e3413753"}, - {file = "coverage-7.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bfa065307667f1c6e1f4c3e13f415b0925e34e56441f5fda2c84110a4a1d8bda"}, - {file = "coverage-7.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7f992b32286c86c38f07a8b5c3fc88384199e82434040a729ec06b067ee0d52c"}, - {file = "coverage-7.2.0-cp37-cp37m-win32.whl", hash = "sha256:2c15bd09fd5009f3a79c8b3682b52973df29761030b692043f9834fc780947c4"}, - {file = "coverage-7.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f332d61fbff353e2ef0f3130a166f499c3fad3a196e7f7ae72076d41a6bfb259"}, - {file = "coverage-7.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:577a8bc40c01ad88bb9ab1b3a1814f2f860ff5c5099827da2a3cafc5522dadea"}, - {file = "coverage-7.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9240a0335365c29c968131bdf624bb25a8a653a9c0d8c5dbfcabf80b59c1973c"}, - {file = "coverage-7.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:358d3bce1468f298b19a3e35183bdb13c06cdda029643537a0cc37e55e74e8f1"}, - {file = "coverage-7.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:932048364ff9c39030c6ba360c31bf4500036d4e15c02a2afc5a76e7623140d4"}, - {file = "coverage-7.2.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7efa21611ffc91156e6f053997285c6fe88cfef3fb7533692d0692d2cb30c846"}, - {file = "coverage-7.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:465ea431c3b78a87e32d7d9ea6d081a1003c43a442982375cf2c247a19971961"}, - {file = "coverage-7.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0f03c229f1453b936916f68a47b3dfb5e84e7ad48e160488168a5e35115320c8"}, - {file = "coverage-7.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:40785553d68c61e61100262b73f665024fd2bb3c6f0f8e2cd5b13e10e4df027b"}, - {file = "coverage-7.2.0-cp38-cp38-win32.whl", hash = "sha256:b09dd7bef59448c66e6b490cc3f3c25c14bc85d4e3c193b81a6204be8dd355de"}, - {file = "coverage-7.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:dc4f9a89c82faf6254d646180b2e3aa4daf5ff75bdb2c296b9f6a6cf547e26a7"}, - {file = "coverage-7.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c243b25051440386179591a8d5a5caff4484f92c980fb6e061b9559da7cc3f64"}, - {file = "coverage-7.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b8fd32f85b256fc096deeb4872aeb8137474da0c0351236f93cbedc359353d6"}, - {file = "coverage-7.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7f2a7df523791e6a63b40360afa6792a11869651307031160dc10802df9a252"}, - {file = "coverage-7.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da32526326e8da0effb452dc32a21ffad282c485a85a02aeff2393156f69c1c3"}, - {file = "coverage-7.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1153a6156715db9d6ae8283480ae67fb67452aa693a56d7dae9ffe8f7a80da"}, - {file = "coverage-7.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:74cd60fa00f46f28bd40048d6ca26bd58e9bee61d2b0eb4ec18cea13493c003f"}, - {file = "coverage-7.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:59a427f8a005aa7254074719441acb25ac2c2f60c1f1026d43f846d4254c1c2f"}, - {file = "coverage-7.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c3c4beddee01c8125a75cde3b71be273995e2e9ec08fbc260dd206b46bb99969"}, - {file = "coverage-7.2.0-cp39-cp39-win32.whl", hash = "sha256:08e3dd256b8d3e07bb230896c8c96ec6c5dffbe5a133ba21f8be82b275b900e8"}, - {file = "coverage-7.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad12c74c6ce53a027f5a5ecbac9be20758a41c85425c1bbab7078441794b04ee"}, - {file = "coverage-7.2.0-pp37.pp38.pp39-none-any.whl", hash = "sha256:ffa637a2d5883298449a5434b699b22ef98dd8e2ef8a1d9e60fa9cfe79813411"}, - {file = "coverage-7.2.0.tar.gz", hash = "sha256:9cc9c41aa5af16d845b53287051340c363dd03b7ef408e45eec3af52be77810d"}, + {file = "coverage-7.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e58c0d41d336569d63d1b113bd573db8363bc4146f39444125b7f8060e4e04f5"}, + {file = "coverage-7.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:344e714bd0fe921fc72d97404ebbdbf9127bac0ca1ff66d7b79efc143cf7c0c4"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974bc90d6f6c1e59ceb1516ab00cf1cdfbb2e555795d49fa9571d611f449bcb2"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0743b0035d4b0e32bc1df5de70fba3059662ace5b9a2a86a9f894cfe66569013"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d0391fb4cfc171ce40437f67eb050a340fdbd0f9f49d6353a387f1b7f9dd4fa"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a42e1eff0ca9a7cb7dc9ecda41dfc7cbc17cb1d02117214be0561bd1134772b"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:be19931a8dcbe6ab464f3339966856996b12a00f9fe53f346ab3be872d03e257"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72fcae5bcac3333a4cf3b8f34eec99cea1187acd55af723bcbd559adfdcb5535"}, + {file = "coverage-7.2.3-cp310-cp310-win32.whl", hash = "sha256:aeae2aa38395b18106e552833f2a50c27ea0000122bde421c31d11ed7e6f9c91"}, + {file = "coverage-7.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:83957d349838a636e768251c7e9979e899a569794b44c3728eaebd11d848e58e"}, + {file = "coverage-7.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfd393094cd82ceb9b40df4c77976015a314b267d498268a076e940fe7be6b79"}, + {file = "coverage-7.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182eb9ac3f2b4874a1f41b78b87db20b66da6b9cdc32737fbbf4fea0c35b23fc"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bb1e77a9a311346294621be905ea8a2c30d3ad371fc15bb72e98bfcfae532df"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca0f34363e2634deffd390a0fef1aa99168ae9ed2af01af4a1f5865e362f8623"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55416d7385774285b6e2a5feca0af9652f7f444a4fa3d29d8ab052fafef9d00d"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:06ddd9c0249a0546997fdda5a30fbcb40f23926df0a874a60a8a185bc3a87d93"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fff5aaa6becf2c6a1699ae6a39e2e6fb0672c2d42eca8eb0cafa91cf2e9bd312"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ea53151d87c52e98133eb8ac78f1206498c015849662ca8dc246255265d9c3c4"}, + {file = "coverage-7.2.3-cp311-cp311-win32.whl", hash = "sha256:8f6c930fd70d91ddee53194e93029e3ef2aabe26725aa3c2753df057e296b925"}, + {file = "coverage-7.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:fa546d66639d69aa967bf08156eb8c9d0cd6f6de84be9e8c9819f52ad499c910"}, + {file = "coverage-7.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2317d5ed777bf5a033e83d4f1389fd4ef045763141d8f10eb09a7035cee774c"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be9824c1c874b73b96288c6d3de793bf7f3a597770205068c6163ea1f326e8b9"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c3b2803e730dc2797a017335827e9da6da0e84c745ce0f552e66400abdfb9a1"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f69770f5ca1994cb32c38965e95f57504d3aea96b6c024624fdd5bb1aa494a1"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1127b16220f7bfb3f1049ed4a62d26d81970a723544e8252db0efde853268e21"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:aa784405f0c640940595fa0f14064d8e84aff0b0f762fa18393e2760a2cf5841"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3146b8e16fa60427e03884301bf8209221f5761ac754ee6b267642a2fd354c48"}, + {file = "coverage-7.2.3-cp37-cp37m-win32.whl", hash = "sha256:1fd78b911aea9cec3b7e1e2622c8018d51c0d2bbcf8faaf53c2497eb114911c1"}, + {file = "coverage-7.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f3736a5d34e091b0a611964c6262fd68ca4363df56185902528f0b75dbb9c1f"}, + {file = "coverage-7.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:981b4df72c93e3bc04478153df516d385317628bd9c10be699c93c26ddcca8ab"}, + {file = "coverage-7.2.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0045f8f23a5fb30b2eb3b8a83664d8dc4fb58faddf8155d7109166adb9f2040"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f760073fcf8f3d6933178d67754f4f2d4e924e321f4bb0dcef0424ca0215eba1"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c86bd45d1659b1ae3d0ba1909326b03598affbc9ed71520e0ff8c31a993ad911"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:172db976ae6327ed4728e2507daf8a4de73c7cc89796483e0a9198fd2e47b462"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d2a3a6146fe9319926e1d477842ca2a63fe99af5ae690b1f5c11e6af074a6b5c"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f649dd53833b495c3ebd04d6eec58479454a1784987af8afb77540d6c1767abd"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c4ed4e9f3b123aa403ab424430b426a1992e6f4c8fd3cb56ea520446e04d152"}, + {file = "coverage-7.2.3-cp38-cp38-win32.whl", hash = "sha256:eb0edc3ce9760d2f21637766c3aa04822030e7451981ce569a1b3456b7053f22"}, + {file = "coverage-7.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:63cdeaac4ae85a179a8d6bc09b77b564c096250d759eed343a89d91bce8b6367"}, + {file = "coverage-7.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:20d1a2a76bb4eb00e4d36b9699f9b7aba93271c9c29220ad4c6a9581a0320235"}, + {file = "coverage-7.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ea748802cc0de4de92ef8244dd84ffd793bd2e7be784cd8394d557a3c751e21"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b154aba06df42e4b96fc915512ab39595105f6c483991287021ed95776d934"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd214917cabdd6f673a29d708574e9fbdb892cb77eb426d0eae3490d95ca7859"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2e58e45fe53fab81f85474e5d4d226eeab0f27b45aa062856c89389da2f0d9"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:87ecc7c9a1a9f912e306997ffee020297ccb5ea388421fe62a2a02747e4d5539"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:387065e420aed3c71b61af7e82c7b6bc1c592f7e3c7a66e9f78dd178699da4fe"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ea3f5bc91d7d457da7d48c7a732beaf79d0c8131df3ab278e6bba6297e23c6c4"}, + {file = "coverage-7.2.3-cp39-cp39-win32.whl", hash = "sha256:ae7863a1d8db6a014b6f2ff9c1582ab1aad55a6d25bac19710a8df68921b6e30"}, + {file = "coverage-7.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:3f04becd4fcda03c0160d0da9c8f0c246bc78f2f7af0feea1ec0930e7c93fa4a"}, + {file = "coverage-7.2.3-pp37.pp38.pp39-none-any.whl", hash = "sha256:965ee3e782c7892befc25575fa171b521d33798132692df428a09efacaffe8d0"}, + {file = "coverage-7.2.3.tar.gz", hash = "sha256:d298c2815fa4891edd9abe5ad6e6cb4207104c7dd9fd13aea3fdebf6f9b91259"}, ] [package.dependencies] @@ -723,6 +800,94 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "39.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "cryptography-39.0.2-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:2725672bb53bb92dc7b4150d233cd4b8c59615cd8288d495eaa86db00d4e5c06"}, + {file = "cryptography-39.0.2-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:23df8ca3f24699167daf3e23e51f7ba7334d504af63a94af468f468b975b7dd7"}, + {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:eb40fe69cfc6f5cdab9a5ebd022131ba21453cf7b8a7fd3631f45bbf52bed612"}, + {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc0521cce2c1d541634b19f3ac661d7a64f9555135e9d8af3980965be717fd4a"}, + {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffd394c7896ed7821a6d13b24657c6a34b6e2650bd84ae063cf11ccffa4f1a97"}, + {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:e8a0772016feeb106efd28d4a328e77dc2edae84dfbac06061319fdb669ff828"}, + {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8f35c17bd4faed2bc7797d2a66cbb4f986242ce2e30340ab832e5d99ae60e011"}, + {file = "cryptography-39.0.2-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b49a88ff802e1993b7f749b1eeb31134f03c8d5c956e3c125c75558955cda536"}, + {file = "cryptography-39.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c682e736513db7d04349b4f6693690170f95aac449c56f97415c6980edef5"}, + {file = "cryptography-39.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:d7d84a512a59f4412ca8549b01f94be4161c94efc598bf09d027d67826beddc0"}, + {file = "cryptography-39.0.2-cp36-abi3-win32.whl", hash = "sha256:c43ac224aabcbf83a947eeb8b17eaf1547bce3767ee2d70093b461f31729a480"}, + {file = "cryptography-39.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:788b3921d763ee35dfdb04248d0e3de11e3ca8eb22e2e48fef880c42e1f3c8f9"}, + {file = "cryptography-39.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d15809e0dbdad486f4ad0979753518f47980020b7a34e9fc56e8be4f60702fac"}, + {file = "cryptography-39.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:50cadb9b2f961757e712a9737ef33d89b8190c3ea34d0fb6675e00edbe35d074"}, + {file = "cryptography-39.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:103e8f7155f3ce2ffa0049fe60169878d47a4364b277906386f8de21c9234aa1"}, + {file = "cryptography-39.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6236a9610c912b129610eb1a274bdc1350b5df834d124fa84729ebeaf7da42c3"}, + {file = "cryptography-39.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e944fe07b6f229f4c1a06a7ef906a19652bdd9fd54c761b0ff87e83ae7a30354"}, + {file = "cryptography-39.0.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:35d658536b0a4117c885728d1a7032bdc9a5974722ae298d6c533755a6ee3915"}, + {file = "cryptography-39.0.2-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:30b1d1bfd00f6fc80d11300a29f1d8ab2b8d9febb6ed4a38a76880ec564fae84"}, + {file = "cryptography-39.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e029b844c21116564b8b61216befabca4b500e6816fa9f0ba49527653cae2108"}, + {file = "cryptography-39.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fa507318e427169ade4e9eccef39e9011cdc19534f55ca2f36ec3f388c1f70f3"}, + {file = "cryptography-39.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8bc0008ef798231fac03fe7d26e82d601d15bd16f3afaad1c6113771566570f3"}, + {file = "cryptography-39.0.2.tar.gz", hash = "sha256:bc5b871e977c8ee5a1bbc42fa8d19bcc08baf0c51cbf1586b0e87a2694dde42f"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +pep8test = ["black", "check-manifest", "mypy", "ruff", "types-pytz", "types-requests"] +sdist = ["setuptools-rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist", "pytz"] +test-randomorder = ["pytest-randomly"] +tox = ["tox"] + +[[package]] +name = "cryptography" +version = "40.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:8f79b5ff5ad9d3218afb1e7e20ea74da5f76943ee5edb7f76e56ec5161ec782b"}, + {file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05dc219433b14046c476f6f09d7636b92a1c3e5808b9a6536adf4932b3b2c440"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4df2af28d7bedc84fe45bd49bc35d710aede676e2a4cb7fc6d103a2adc8afe4d"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dcca15d3a19a66e63662dc8d30f8036b07be851a8680eda92d079868f106288"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a04386fb7bc85fab9cd51b6308633a3c271e3d0d3eae917eebab2fac6219b6d2"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:adc0d980fd2760c9e5de537c28935cc32b9353baaf28e0814df417619c6c8c3b"}, + {file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d5a1bd0e9e2031465761dfa920c16b0065ad77321d8a8c1f5ee331021fda65e9"}, + {file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a95f4802d49faa6a674242e25bfeea6fc2acd915b5e5e29ac90a32b1139cae1c"}, + {file = "cryptography-40.0.2-cp36-abi3-win32.whl", hash = "sha256:aecbb1592b0188e030cb01f82d12556cf72e218280f621deed7d806afd2113f9"}, + {file = "cryptography-40.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:b12794f01d4cacfbd3177b9042198f3af1c856eedd0a98f10f141385c809a14b"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:142bae539ef28a1c76794cca7f49729e7c54423f615cfd9b0b1fa90ebe53244b"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:956ba8701b4ffe91ba59665ed170a2ebbdc6fc0e40de5f6059195d9f2b33ca0e"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f01c9863da784558165f5d4d916093737a75203a5c5286fde60e503e4276c7a"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3daf9b114213f8ba460b829a02896789751626a2a4e7a43a28ee77c04b5e4958"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48f388d0d153350f378c7f7b41497a54ff1513c816bcbbcafe5b829e59b9ce5b"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c0764e72b36a3dc065c155e5b22f93df465da9c39af65516fe04ed3c68c92636"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cbaba590180cba88cb99a5f76f90808a624f18b169b90a4abb40c1fd8c19420e"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7a38250f433cd41df7fcb763caa3ee9362777fdb4dc642b9a349721d2bf47404"}, + {file = "cryptography-40.0.2.tar.gz", hash = "sha256:c33c0d32b8594fa647d2e01dbccc303478e16fdd7cf98652d5b3ed11aa5e5c99"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +pep8test = ["black", "check-manifest", "mypy", "ruff"] +sdist = ["setuptools-rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] +tox = ["tox"] + [[package]] name = "datrie" version = "0.8.2" @@ -776,6 +941,27 @@ wrapt = ">=1.10,<2" [package.extras] dev = ["PyTest", "PyTest (<5)", "PyTest-Cov", "PyTest-Cov (<2.6)", "bump2version (<1)", "configparser (<5)", "importlib-metadata (<3)", "importlib-resources (<4)", "sphinx (<2)", "sphinxcontrib-websupport (<2)", "tox", "zipp (<2)"] +[[package]] +name = "dnspython" +version = "2.3.0" +description = "DNS toolkit" +category = "main" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "dnspython-2.3.0-py3-none-any.whl", hash = "sha256:89141536394f909066cabd112e3e1a37e4e654db00a25308b0f130bc3152eb46"}, + {file = "dnspython-2.3.0.tar.gz", hash = "sha256:224e32b03eb46be70e12ef6d64e0be123a64e621ab4c0822ff6d450d52a540b9"}, +] + +[package.extras] +curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] +dnssec = ["cryptography (>=2.6,<40.0)"] +doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.11.0)"] +doq = ["aioquic (>=0.9.20)"] +idna = ["idna (>=2.1,<4.0)"] +trio = ["trio (>=0.14,<0.23)"] +wmi = ["wmi (>=1.5.1,<2.0.0)"] + [[package]] name = "docutils" version = "0.19" @@ -788,21 +974,71 @@ files = [ {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, ] +[[package]] +name = "email-validator" +version = "1.3.1" +description = "A robust email address syntax and deliverability validation library." +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "email_validator-1.3.1-py2.py3-none-any.whl", hash = "sha256:49a72f5fa6ed26be1c964f0567d931d10bf3fdeeacdf97bc26ef1cd2a44e0bda"}, + {file = "email_validator-1.3.1.tar.gz", hash = "sha256:d178c5c6fa6c6824e9b04f199cf23e79ac15756786573c190d2ad13089411ad2"}, +] + +[package.dependencies] +dnspython = ">=1.15.0" +idna = ">=2.0.0" + [[package]] name = "exceptiongroup" -version = "1.1.0" +version = "1.1.1" description = "Backport of PEP 654 (exception groups)" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, - {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, ] [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "factory-boy" +version = "3.2.1" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "factory_boy-3.2.1-py2.py3-none-any.whl", hash = "sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795"}, + {file = "factory_boy-3.2.1.tar.gz", hash = "sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e"}, +] + +[package.dependencies] +Faker = ">=0.7.0" + +[package.extras] +dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"] +doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] + +[[package]] +name = "faker" +version = "18.5.1" +description = "Faker is a Python package that generates fake data for you." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Faker-18.5.1-py3-none-any.whl", hash = "sha256:137c6667583b0b458599b11305eed5a486e3932a14cb792b2b5b82ad1ad1a430"}, + {file = "Faker-18.5.1.tar.gz", hash = "sha256:64e9ab619d75684cc0593aa9f336170b0b58fa77c07fc0ebc7b2b1258e53b67d"}, +] + +[package.dependencies] +python-dateutil = ">=2.4" + [[package]] name = "freezegun" version = "1.2.2" @@ -1118,16 +1354,32 @@ files = [ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] +[[package]] +name = "imia" +version = "0.5.3" +description = "Full stack authentication library for ASGI." +category = "main" +optional = false +python-versions = ">=3.8.0,<4.0.0" +files = [ + {file = "imia-0.5.3-py3-none-any.whl", hash = "sha256:f8edce6c04c4eb4542aee0fe90126899858c3466636134dbfb414edb0a475273"}, + {file = "imia-0.5.3.tar.gz", hash = "sha256:e02cdebceef1828f076f52477bf78612dabf282ac9334855ffb491576c26bb1f"}, +] + +[package.extras] +aiosqlite = ["aiosqlite (>=0.17.0,<0.18.0)"] +sqlalchemy = ["SQLAlchemy (>=1.4.25,<2.0.0)"] + [[package]] name = "importlib-metadata" -version = "6.0.0" +version = "6.6.0" description = "Read metadata from Python packages" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, - {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, + {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, + {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, ] [package.dependencies] @@ -1214,6 +1466,18 @@ pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib" plugins = ["setuptools"] requirements-deprecated-finder = ["pip-api", "pipreqs"] +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + [[package]] name = "jinja2" version = "3.1.2" @@ -1232,6 +1496,36 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "limits" +version = "3.2.0" +description = "Rate limiting utilities" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "limits-3.2.0-py3-none-any.whl", hash = "sha256:3e12a0d90bc1fb7f3d95fe61c5a76770aaeb21d50f590268187a8884d513c1da"}, + {file = "limits-3.2.0.tar.gz", hash = "sha256:6fe1d261162ca6fd8023311273661a7355bc0f4615832bc9a4d6e45c0df59f5e"}, +] + +[package.dependencies] +deprecated = ">=1.2" +packaging = ">=21,<24" +setuptools = "*" +typing-extensions = "*" + +[package.extras] +all = ["aetcd", "coredis (>=3.4.0,<5)", "emcache (>=0.6.1)", "emcache (>=1)", "etcd3", "motor (>=3,<4)", "pymemcache (>3,<5.0.0)", "pymongo (>4.1,<5)", "redis (>3,<5.0.0)", "redis (>=4.2.0)"] +async-etcd = ["aetcd"] +async-memcached = ["emcache (>=0.6.1)", "emcache (>=1)"] +async-mongodb = ["motor (>=3,<4)"] +async-redis = ["coredis (>=3.4.0,<5)"] +etcd = ["etcd3"] +memcached = ["pymemcache (>3,<5.0.0)"] +mongodb = ["pymongo (>4.1,<5)"] +redis = ["redis (>3,<5.0.0)"] +rediscluster = ["redis (>=4.2.0)"] + [[package]] name = "lockfile" version = "0.12.2" @@ -1410,42 +1704,42 @@ files = [ [[package]] name = "mypy" -version = "1.0.1" +version = "1.2.0" description = "Optional static typing for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mypy-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:71a808334d3f41ef011faa5a5cd8153606df5fc0b56de5b2e89566c8093a0c9a"}, - {file = "mypy-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:920169f0184215eef19294fa86ea49ffd4635dedfdea2b57e45cb4ee85d5ccaf"}, - {file = "mypy-1.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27a0f74a298769d9fdc8498fcb4f2beb86f0564bcdb1a37b58cbbe78e55cf8c0"}, - {file = "mypy-1.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:65b122a993d9c81ea0bfde7689b3365318a88bde952e4dfa1b3a8b4ac05d168b"}, - {file = "mypy-1.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5deb252fd42a77add936b463033a59b8e48eb2eaec2976d76b6878d031933fe4"}, - {file = "mypy-1.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2013226d17f20468f34feddd6aae4635a55f79626549099354ce641bc7d40262"}, - {file = "mypy-1.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:48525aec92b47baed9b3380371ab8ab6e63a5aab317347dfe9e55e02aaad22e8"}, - {file = "mypy-1.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c96b8a0c019fe29040d520d9257d8c8f122a7343a8307bf8d6d4a43f5c5bfcc8"}, - {file = "mypy-1.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:448de661536d270ce04f2d7dddaa49b2fdba6e3bd8a83212164d4174ff43aa65"}, - {file = "mypy-1.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:d42a98e76070a365a1d1c220fcac8aa4ada12ae0db679cb4d910fabefc88b994"}, - {file = "mypy-1.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e64f48c6176e243ad015e995de05af7f22bbe370dbb5b32bd6988438ec873919"}, - {file = "mypy-1.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fdd63e4f50e3538617887e9aee91855368d9fc1dea30da743837b0df7373bc4"}, - {file = "mypy-1.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dbeb24514c4acbc78d205f85dd0e800f34062efcc1f4a4857c57e4b4b8712bff"}, - {file = "mypy-1.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a2948c40a7dd46c1c33765718936669dc1f628f134013b02ff5ac6c7ef6942bf"}, - {file = "mypy-1.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5bc8d6bd3b274dd3846597855d96d38d947aedba18776aa998a8d46fabdaed76"}, - {file = "mypy-1.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:17455cda53eeee0a4adb6371a21dd3dbf465897de82843751cf822605d152c8c"}, - {file = "mypy-1.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e831662208055b006eef68392a768ff83596035ffd6d846786578ba1714ba8f6"}, - {file = "mypy-1.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e60d0b09f62ae97a94605c3f73fd952395286cf3e3b9e7b97f60b01ddfbbda88"}, - {file = "mypy-1.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:0af4f0e20706aadf4e6f8f8dc5ab739089146b83fd53cb4a7e0e850ef3de0bb6"}, - {file = "mypy-1.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:24189f23dc66f83b839bd1cce2dfc356020dfc9a8bae03978477b15be61b062e"}, - {file = "mypy-1.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93a85495fb13dc484251b4c1fd7a5ac370cd0d812bbfc3b39c1bafefe95275d5"}, - {file = "mypy-1.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f546ac34093c6ce33f6278f7c88f0f147a4849386d3bf3ae193702f4fe31407"}, - {file = "mypy-1.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c6c2ccb7af7154673c591189c3687b013122c5a891bb5651eca3db8e6c6c55bd"}, - {file = "mypy-1.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:15b5a824b58c7c822c51bc66308e759243c32631896743f030daf449fe3677f3"}, - {file = "mypy-1.0.1-py3-none-any.whl", hash = "sha256:eda5c8b9949ed411ff752b9a01adda31afe7eae1e53e946dbdf9db23865e66c4"}, - {file = "mypy-1.0.1.tar.gz", hash = "sha256:28cea5a6392bb43d266782983b5a4216c25544cd7d80be681a155ddcdafd152d"}, + {file = "mypy-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:701189408b460a2ff42b984e6bd45c3f41f0ac9f5f58b8873bbedc511900086d"}, + {file = "mypy-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe91be1c51c90e2afe6827601ca14353bbf3953f343c2129fa1e247d55fd95ba"}, + {file = "mypy-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d26b513225ffd3eacece727f4387bdce6469192ef029ca9dd469940158bc89e"}, + {file = "mypy-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a2d219775a120581a0ae8ca392b31f238d452729adbcb6892fa89688cb8306a"}, + {file = "mypy-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:2e93a8a553e0394b26c4ca683923b85a69f7ccdc0139e6acd1354cc884fe0128"}, + {file = "mypy-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3efde4af6f2d3ccf58ae825495dbb8d74abd6d176ee686ce2ab19bd025273f41"}, + {file = "mypy-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:695c45cea7e8abb6f088a34a6034b1d273122e5530aeebb9c09626cea6dca4cb"}, + {file = "mypy-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0e9464a0af6715852267bf29c9553e4555b61f5904a4fc538547a4d67617937"}, + {file = "mypy-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8293a216e902ac12779eb7a08f2bc39ec6c878d7c6025aa59464e0c4c16f7eb9"}, + {file = "mypy-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:f46af8d162f3d470d8ffc997aaf7a269996d205f9d746124a179d3abe05ac602"}, + {file = "mypy-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:031fc69c9a7e12bcc5660b74122ed84b3f1c505e762cc4296884096c6d8ee140"}, + {file = "mypy-1.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:390bc685ec209ada4e9d35068ac6988c60160b2b703072d2850457b62499e336"}, + {file = "mypy-1.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4b41412df69ec06ab141808d12e0bf2823717b1c363bd77b4c0820feaa37249e"}, + {file = "mypy-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4e4a682b3f2489d218751981639cffc4e281d548f9d517addfd5a2917ac78119"}, + {file = "mypy-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a197ad3a774f8e74f21e428f0de7f60ad26a8d23437b69638aac2764d1e06a6a"}, + {file = "mypy-1.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c9a084bce1061e55cdc0493a2ad890375af359c766b8ac311ac8120d3a472950"}, + {file = "mypy-1.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaeaa0888b7f3ccb7bcd40b50497ca30923dba14f385bde4af78fac713d6d6f6"}, + {file = "mypy-1.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bea55fc25b96c53affab852ad94bf111a3083bc1d8b0c76a61dd101d8a388cf5"}, + {file = "mypy-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:4c8d8c6b80aa4a1689f2a179d31d86ae1367ea4a12855cc13aa3ba24bb36b2d8"}, + {file = "mypy-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70894c5345bea98321a2fe84df35f43ee7bb0feec117a71420c60459fc3e1eed"}, + {file = "mypy-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4a99fe1768925e4a139aace8f3fb66db3576ee1c30b9c0f70f744ead7e329c9f"}, + {file = "mypy-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521"}, + {file = "mypy-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d19f1a239d59f10fdc31263d48b7937c585810288376671eaf75380b074f238"}, + {file = "mypy-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:2de7babe398cb7a85ac7f1fd5c42f396c215ab3eff731b4d761d68d0f6a80f48"}, + {file = "mypy-1.2.0-py3-none-any.whl", hash = "sha256:d8e9187bfcd5ffedbe87403195e1fc340189a68463903c39e2b63307c9fa0394"}, + {file = "mypy-1.2.0.tar.gz", hash = "sha256:f70a40410d774ae23fcb4afbbeca652905a04de7948eaf0b1789c8d1426b72d1"}, ] [package.dependencies] -mypy-extensions = ">=0.4.3" +mypy-extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=3.10" @@ -1526,14 +1820,14 @@ files = [ [[package]] name = "pathspec" -version = "0.11.0" +version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"}, - {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"}, + {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, + {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, ] [[package]] @@ -1553,19 +1847,19 @@ psutil = {version = ">=5.4.8", markers = "sys_platform == \"win32\""} [[package]] name = "platformdirs" -version = "3.0.0" +version = "3.3.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, - {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, + {file = "platformdirs-3.3.0-py3-none-any.whl", hash = "sha256:ea61fd7b85554beecbbd3e9b37fb26689b227ffae38f73353cbcc1cf8bd01878"}, + {file = "platformdirs-3.3.0.tar.gz", hash = "sha256:64370d47dc3fca65b4879f89bdead8197e93e05d696d6d1816243ebae8595da5"}, ] [package.extras] -docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "pluggy" @@ -1817,14 +2111,14 @@ files = [ [[package]] name = "pygments" -version = "2.14.0" +version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, - {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, + {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, + {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, ] [package.extras] @@ -1842,20 +2136,50 @@ files = [ {file = "Pympler-1.0.1.tar.gz", hash = "sha256:993f1a3599ca3f4fcd7160c7545ad06310c9e12f70174ae7ae8d4e25f6c5d3fa"}, ] +[[package]] +name = "pyopenssl" +version = "23.1.1" +description = "Python wrapper module around the OpenSSL library" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyOpenSSL-23.1.1-py3-none-any.whl", hash = "sha256:9e0c526404a210df9d2b18cd33364beadb0dc858a739b885677bc65e105d4a4c"}, + {file = "pyOpenSSL-23.1.1.tar.gz", hash = "sha256:841498b9bec61623b1b6c47ebbc02367c07d60e0e195f19790817f10cc8db0b7"}, +] + +[package.dependencies] +cryptography = ">=38.0.0,<41" + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] + +[[package]] +name = "pyotp" +version = "2.8.0" +description = "Python One Time Password Library" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyotp-2.8.0-py3-none-any.whl", hash = "sha256:889d037fdde6accad28531fc62a790f089e5dfd5b638773e9ee004cce074a2e5"}, + {file = "pyotp-2.8.0.tar.gz", hash = "sha256:c2f5e17d9da92d8ec1f7de6331ab08116b9115adbabcba6e208d46fc49a98c5a"}, +] + [[package]] name = "pytest" -version = "7.2.1" +version = "7.3.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, - {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, + {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, + {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, ] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" @@ -1864,7 +2188,7 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -1958,14 +2282,14 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "0.21.1" +version = "1.0.0" description = "Read key-value pairs from a .env file and set them as environment variables" category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "python-dotenv-0.21.1.tar.gz", hash = "sha256:1c93de8f636cde3ce377292818d0e440b6e45a82f215c3744979151fa8151c49"}, - {file = "python_dotenv-0.21.1-py3-none-any.whl", hash = "sha256:41e12e0318bebc859fcc4d97d4db8d20ad21721a6aa5047dd59f090391cb549a"}, + {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, + {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, ] [package.extras] @@ -2003,6 +2327,21 @@ websockets = ">=5.0" [package.extras] dev = ["black", "flake8", "flake8-black", "flake8-docstrings", "flake8-isort", "gitchangelog", "pre-commit", "pystache"] +[[package]] +name = "python-multipart" +version = "0.0.6" +description = "A streaming multipart parser for Python" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "python_multipart-0.0.6-py3-none-any.whl", hash = "sha256:ee698bab5ef148b0a760751c261902cd096e57e10558e11aca17646b74ee1c18"}, + {file = "python_multipart-0.0.6.tar.gz", hash = "sha256:e9925a80bb668529f1b67c7fdb0a5dacdd7cbfc6fb0bff3ea443fe22bdd62132"}, +] + +[package.extras] +dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatch", "invoke (==1.7.3)", "more-itertools (==4.3.0)", "pbr (==4.3.0)", "pluggy (==1.0.0)", "py (==1.11.0)", "pytest (==7.2.0)", "pytest-cov (==4.0.0)", "pytest-timeout (==2.1.0)", "pyyaml (==5.1)"] + [[package]] name = "pytz" version = "2022.7.1" @@ -2220,14 +2559,14 @@ test = ["pytest"] [[package]] name = "setuptools" -version = "67.4.0" +version = "67.7.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "setuptools-67.4.0-py3-none-any.whl", hash = "sha256:f106dee1b506dee5102cc3f3e9e68137bbad6d47b616be7991714b0c62204251"}, - {file = "setuptools-67.4.0.tar.gz", hash = "sha256:e5fd0a713141a4a105412233c63dc4e17ba0090c8e8334594ac790ec97792330"}, + {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"}, + {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"}, ] [package.extras] @@ -2235,28 +2574,6 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-g testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] -[[package]] -name = "setuptools-scm" -version = "7.1.0" -description = "the blessed package to manage your versions by scm tags" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "setuptools_scm-7.1.0-py3-none-any.whl", hash = "sha256:73988b6d848709e2af142aa48c986ea29592bbcfca5375678064708205253d8e"}, - {file = "setuptools_scm-7.1.0.tar.gz", hash = "sha256:6c508345a771aad7d56ebff0e70628bf2b0ec7573762be9960214730de278f27"}, -] - -[package.dependencies] -packaging = ">=20.0" -setuptools = "*" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} -typing-extensions = "*" - -[package.extras] -test = ["pytest (>=6.2)", "virtualenv (>20)"] -toml = ["setuptools (>=42)"] - [[package]] name = "six" version = "1.16.0" @@ -2269,6 +2586,31 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "smtpdfix" +version = "0.4.2" +description = "A SMTP server for use as a pytest fixture that implements encryption and authentication for testing." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "smtpdfix-0.4.2-py3-none-any.whl", hash = "sha256:95b770f5421fc8f30812e4603b69665d4fce573d16ebcf762989e5a701d37c92"}, + {file = "smtpdfix-0.4.2.tar.gz", hash = "sha256:e7f700d6c0399f85992ebe7f943a5e61bccb00323f7cf211dff77b1ad7038132"}, +] + +[package.dependencies] +aiosmtpd = "*" +cryptography = [ + {version = "*", markers = "platform_python_implementation != \"PyPy\""}, + {version = ">=39.0.1,<40.0.0", markers = "platform_python_implementation == \"PyPy\""}, +] +pytest = "*" +python-dotenv = "*" + +[package.extras] +dev = ["flake8", "isort", "pytest-asyncio", "pytest-cov", "pytest-timeout", "tox"] +typing = ["mypy"] + [[package]] name = "sniffio" version = "1.3.0" @@ -2295,21 +2637,21 @@ files = [ [[package]] name = "sphinx" -version = "6.1.3" +version = "6.2.1" description = "Python documentation generator" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "Sphinx-6.1.3.tar.gz", hash = "sha256:0dac3b698538ffef41716cf97ba26c1c7788dba73ce6f150c1ff5b4720786dd2"}, - {file = "sphinx-6.1.3-py3-none-any.whl", hash = "sha256:807d1cb3d6be87eb78a381c3e70ebd8d346b9a25f3753e9947e866b2786865fc"}, + {file = "Sphinx-6.2.1.tar.gz", hash = "sha256:6d56a34697bb749ffa0152feafc4b19836c755d90a7c59b72bc7dfd371b9cc6b"}, + {file = "sphinx-6.2.1-py3-none-any.whl", hash = "sha256:97787ff1fa3256a3eef9eda523a63dbf299f7b47e053cfcf684a1c2a8380c912"}, ] [package.dependencies] alabaster = ">=0.7,<0.8" babel = ">=2.9" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.18,<0.20" +docutils = ">=0.18.1,<0.20" imagesize = ">=1.3" importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} Jinja2 = ">=3.0" @@ -2327,7 +2669,7 @@ sphinxcontrib-serializinghtml = ">=1.1.5" [package.extras] docs = ["sphinxcontrib-websupport"] lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] -test = ["cython", "html5lib", "pytest (>=4.6)"] +test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"] [[package]] name = "sphinx-immaterial" @@ -2545,6 +2887,26 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" [package.extras] full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] +[[package]] +name = "starlette-wtf" +version = "0.4.3" +description = "Simple integration of Starlette and WTForms." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "Starlette-WTF-0.4.3.tar.gz", hash = "sha256:4b01d670f65112f13d19aa458cc9932f94f17ace14aaae1b6f08224f4aab2d86"}, +] + +[package.dependencies] +itsdangerous = "*" +python-multipart = "*" +starlette = "*" +WTForms = "*" + +[package.extras] +test = ["jinja2", "pytest", "requests"] + [[package]] name = "tomli" version = "2.0.1" @@ -2598,24 +2960,28 @@ windows-platform = ["PyHamcrest (>=1.9.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.0. [[package]] name = "twisted-iocpsupport" -version = "1.0.2" +version = "1.0.3" description = "An extension for use in the twisted I/O Completion Ports reactor." category = "dev" optional = false python-versions = "*" files = [ - {file = "twisted-iocpsupport-1.0.2.tar.gz", hash = "sha256:72068b206ee809c9c596b57b5287259ea41ddb4774d86725b19f35bf56aa32a9"}, - {file = "twisted_iocpsupport-1.0.2-cp310-cp310-win32.whl", hash = "sha256:985c06a33f5c0dae92c71a036d1ea63872ee86a21dd9b01e1f287486f15524b4"}, - {file = "twisted_iocpsupport-1.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:81b3abe3527b367da0220482820cb12a16c661672b7bcfcde328902890d63323"}, - {file = "twisted_iocpsupport-1.0.2-cp36-cp36m-win32.whl", hash = "sha256:9dbb8823b49f06d4de52721b47de4d3b3026064ef4788ce62b1a21c57c3fff6f"}, - {file = "twisted_iocpsupport-1.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:b9fed67cf0f951573f06d560ac2f10f2a4bbdc6697770113a2fc396ea2cb2565"}, - {file = "twisted_iocpsupport-1.0.2-cp37-cp37m-win32.whl", hash = "sha256:b76b4eed9b27fd63ddb0877efdd2d15835fdcb6baa745cb85b66e5d016ac2878"}, - {file = "twisted_iocpsupport-1.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:851b3735ca7e8102e661872390e3bce88f8901bece95c25a0c8bb9ecb8a23d32"}, - {file = "twisted_iocpsupport-1.0.2-cp38-cp38-win32.whl", hash = "sha256:bf4133139d77fc706d8f572e6b7d82871d82ec7ef25d685c2351bdacfb701415"}, - {file = "twisted_iocpsupport-1.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:306becd6e22ab6e8e4f36b6bdafd9c92e867c98a5ce517b27fdd27760ee7ae41"}, - {file = "twisted_iocpsupport-1.0.2-cp39-cp39-win32.whl", hash = "sha256:3c61742cb0bc6c1ac117a7e5f422c129832f0c295af49e01d8a6066df8cfc04d"}, - {file = "twisted_iocpsupport-1.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:b435857b9efcbfc12f8c326ef0383f26416272260455bbca2cd8d8eca470c546"}, - {file = "twisted_iocpsupport-1.0.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:7d972cfa8439bdcb35a7be78b7ef86d73b34b808c74be56dfa785c8a93b851bf"}, + {file = "twisted-iocpsupport-1.0.3.tar.gz", hash = "sha256:afb00801fdfbaccf0d0173a722626500023d4a19719ac9f129d1347a32e2fc66"}, + {file = "twisted_iocpsupport-1.0.3-cp310-cp310-win32.whl", hash = "sha256:a379ef56a576c8090889f74441bc3822ca31ac82253cc61e8d50631bcb0c26d0"}, + {file = "twisted_iocpsupport-1.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:1ea2c3fbdb739c95cc8b3355305cd593d2c9ec56d709207aa1a05d4d98671e85"}, + {file = "twisted_iocpsupport-1.0.3-cp311-cp311-win32.whl", hash = "sha256:7efcdfafb377f32db90f42bd5fc5bb32cd1e3637ee936cdaf3aff4f4786ab3bf"}, + {file = "twisted_iocpsupport-1.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1dbfac706972bf9ec5ce1ddbc735d2ebba406ad363345df8751ffd5252aa1618"}, + {file = "twisted_iocpsupport-1.0.3-cp36-cp36m-win32.whl", hash = "sha256:1ddfc5fa22ec6f913464b736b3f46e642237f17ac41be47eed6fa9bd52f5d0e0"}, + {file = "twisted_iocpsupport-1.0.3-cp36-cp36m-win_amd64.whl", hash = "sha256:1bdccbb22199fc69fd7744d6d2dfd22d073c028c8611d994b41d2d2ad0e0f40d"}, + {file = "twisted_iocpsupport-1.0.3-cp37-cp37m-win32.whl", hash = "sha256:db11c80054b52dbdea44d63d5474a44c9a6531882f0e2960268b15123088641a"}, + {file = "twisted_iocpsupport-1.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:67bec1716eb8f466ef366bbf262e1467ecc9e20940111207663ac24049785bad"}, + {file = "twisted_iocpsupport-1.0.3-cp38-cp38-win32.whl", hash = "sha256:98a6f16ab215f8c1446e9fc60aaed0ab7c746d566aa2f3492a23cea334e6bebb"}, + {file = "twisted_iocpsupport-1.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:4f249d0baac836bb431d6fa0178be063a310136bc489465a831e3abd2d7acafd"}, + {file = "twisted_iocpsupport-1.0.3-cp39-cp39-win32.whl", hash = "sha256:aaca8f30c3b7c80d27a33fe9fe0d0bac42b1b012ddc60f677175c30e1becc1f3"}, + {file = "twisted_iocpsupport-1.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:dff43136c33665c2d117a73706aef6f7d6433e5c4560332a118fe066b16b8695"}, + {file = "twisted_iocpsupport-1.0.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:8faceae553cfadc42ad791b1790e7cdecb7751102608c405217f6a26e877e0c5"}, + {file = "twisted_iocpsupport-1.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6f8c433faaad5d53d30d1da6968d5a3730df415e2efb6864847267a9b51290cd"}, + {file = "twisted_iocpsupport-1.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3f39c41c0213a81a9ce0961e30d0d7650f371ad80f8d261007d15a2deb6d5be3"}, ] [[package]] @@ -2707,14 +3073,14 @@ files = [ [[package]] name = "urllib3" -version = "1.26.14" +version = "1.26.15" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"}, - {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"}, + {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, + {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, ] [package.extras] @@ -2795,112 +3161,136 @@ test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "my [[package]] name = "watchfiles" -version = "0.18.1" +version = "0.19.0" description = "Simple, modern and high performance file watching and code reload in python." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "watchfiles-0.18.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:9891d3c94272108bcecf5597a592e61105279def1313521e637f2d5acbe08bc9"}, - {file = "watchfiles-0.18.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:7102342d60207fa635e24c02a51c6628bf0472e5fef067f78a612386840407fc"}, - {file = "watchfiles-0.18.1-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:00ea0081eca5e8e695cffbc3a726bb90da77f4e3f78ce29b86f0d95db4e70ef7"}, - {file = "watchfiles-0.18.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8e6db99e49cd7125d8a4c9d33c0735eea7b75a942c6ad68b75be3e91c242fb"}, - {file = "watchfiles-0.18.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc7c726855f04f22ac79131b51bf0c9f728cb2117419ed830a43828b2c4a5fcb"}, - {file = "watchfiles-0.18.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbaff354d12235002e62d9d3fa8bcf326a8490c1179aa5c17195a300a9e5952f"}, - {file = "watchfiles-0.18.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:888db233e06907c555eccd10da99b9cd5ed45deca47e41766954292dc9f7b198"}, - {file = "watchfiles-0.18.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:dde79930d1b28f15994ad6613aa2865fc7a403d2bb14585a8714a53233b15717"}, - {file = "watchfiles-0.18.1-cp37-abi3-win32.whl", hash = "sha256:e2b2bdd26bf8d6ed90763e6020b475f7634f919dbd1730ea1b6f8cb88e21de5d"}, - {file = "watchfiles-0.18.1-cp37-abi3-win_amd64.whl", hash = "sha256:c541e0f2c3e95e83e4f84561c893284ba984e9d0025352057396d96dceb09f44"}, - {file = "watchfiles-0.18.1-cp37-abi3-win_arm64.whl", hash = "sha256:9a26272ef3e930330fc0c2c148cc29706cc2c40d25760c7ccea8d768a8feef8b"}, - {file = "watchfiles-0.18.1-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:9fb12a5e2b42e0b53769455ff93546e6bc9ab14007fbd436978d827a95ca5bd1"}, - {file = "watchfiles-0.18.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:548d6b42303d40264118178053c78820533b683b20dfbb254a8706ca48467357"}, - {file = "watchfiles-0.18.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0d8fdfebc50ac7569358f5c75f2b98bb473befccf9498cf23b3e39993bb45a"}, - {file = "watchfiles-0.18.1-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:0f9a22fff1745e2bb930b1e971c4c5b67ea3b38ae17a6adb9019371f80961219"}, - {file = "watchfiles-0.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b02e7fa03cd4059dd61ff0600080a5a9e7a893a85cb8e5178943533656eec65e"}, - {file = "watchfiles-0.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a868ce2c7565137f852bd4c863a164dc81306cae7378dbdbe4e2aca51ddb8857"}, - {file = "watchfiles-0.18.1.tar.gz", hash = "sha256:4ec0134a5e31797eb3c6c624dbe9354f2a8ee9c720e0b46fc5b7bab472b7c6d4"}, + {file = "watchfiles-0.19.0-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:91633e64712df3051ca454ca7d1b976baf842d7a3640b87622b323c55f3345e7"}, + {file = "watchfiles-0.19.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b6577b8c6c8701ba8642ea9335a129836347894b666dd1ec2226830e263909d3"}, + {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:18b28f6ad871b82df9542ff958d0c86bb0d8310bb09eb8e87d97318a3b5273af"}, + {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fac19dc9cbc34052394dbe81e149411a62e71999c0a19e1e09ce537867f95ae0"}, + {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ea3397aecbc81c19ed7f025e051a7387feefdb789cf768ff994c1228182fda"}, + {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c0376deac92377817e4fb8f347bf559b7d44ff556d9bc6f6208dd3f79f104aaf"}, + {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c75eff897786ee262c9f17a48886f4e98e6cfd335e011c591c305e5d083c056"}, + {file = "watchfiles-0.19.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb5d45c4143c1dd60f98a16187fd123eda7248f84ef22244818c18d531a249d1"}, + {file = "watchfiles-0.19.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:79c533ff593db861ae23436541f481ec896ee3da4e5db8962429b441bbaae16e"}, + {file = "watchfiles-0.19.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3d7d267d27aceeeaa3de0dd161a0d64f0a282264d592e335fff7958cc0cbae7c"}, + {file = "watchfiles-0.19.0-cp37-abi3-win32.whl", hash = "sha256:176a9a7641ec2c97b24455135d58012a5be5c6217fc4d5fef0b2b9f75dbf5154"}, + {file = "watchfiles-0.19.0-cp37-abi3-win_amd64.whl", hash = "sha256:945be0baa3e2440151eb3718fd8846751e8b51d8de7b884c90b17d271d34cae8"}, + {file = "watchfiles-0.19.0-cp37-abi3-win_arm64.whl", hash = "sha256:0089c6dc24d436b373c3c57657bf4f9a453b13767150d17284fc6162b2791911"}, + {file = "watchfiles-0.19.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:cae3dde0b4b2078f31527acff6f486e23abed307ba4d3932466ba7cdd5ecec79"}, + {file = "watchfiles-0.19.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f3920b1285a7d3ce898e303d84791b7bf40d57b7695ad549dc04e6a44c9f120"}, + {file = "watchfiles-0.19.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9afd0d69429172c796164fd7fe8e821ade9be983f51c659a38da3faaaaac44dc"}, + {file = "watchfiles-0.19.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68dce92b29575dda0f8d30c11742a8e2b9b8ec768ae414b54f7453f27bdf9545"}, + {file = "watchfiles-0.19.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:5569fc7f967429d4bc87e355cdfdcee6aabe4b620801e2cf5805ea245c06097c"}, + {file = "watchfiles-0.19.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5471582658ea56fca122c0f0d0116a36807c63fefd6fdc92c71ca9a4491b6b48"}, + {file = "watchfiles-0.19.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b538014a87f94d92f98f34d3e6d2635478e6be6423a9ea53e4dd96210065e193"}, + {file = "watchfiles-0.19.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20b44221764955b1e703f012c74015306fb7e79a00c15370785f309b1ed9aa8d"}, + {file = "watchfiles-0.19.0.tar.gz", hash = "sha256:d9b073073e048081e502b6c6b0b88714c026a1a4c890569238d04aca5f9ca74b"}, ] [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "webauthn" +version = "1.7.2" +description = "Pythonic WebAuthn" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "webauthn-1.7.2-py3-none-any.whl", hash = "sha256:4153dd245dcc44f948f2543a726cbc852353d7c5bc04e7e35c6e05dc744b9a11"}, + {file = "webauthn-1.7.2.tar.gz", hash = "sha256:d4821ce47d9870a94bb55c23002c4e1969fb1c08ad35e878f4b8cec71e507a1d"}, +] + +[package.dependencies] +asn1crypto = ">=1.4.0" +cbor2 = ">=5.4.2.post1" +cryptography = ">=36.0.1" +pydantic = ">=1.9.0" +pyOpenSSL = ">=22.0.0" + [[package]] name = "websockets" -version = "10.4" +version = "11.0.2" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "websockets-10.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d58804e996d7d2307173d56c297cf7bc132c52df27a3efaac5e8d43e36c21c48"}, - {file = "websockets-10.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc0b82d728fe21a0d03e65f81980abbbcb13b5387f733a1a870672c5be26edab"}, - {file = "websockets-10.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ba089c499e1f4155d2a3c2a05d2878a3428cf321c848f2b5a45ce55f0d7d310c"}, - {file = "websockets-10.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33d69ca7612f0ddff3316b0c7b33ca180d464ecac2d115805c044bf0a3b0d032"}, - {file = "websockets-10.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62e627f6b6d4aed919a2052efc408da7a545c606268d5ab5bfab4432734b82b4"}, - {file = "websockets-10.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ea7b82bfcae927eeffc55d2ffa31665dc7fec7b8dc654506b8e5a518eb4d50"}, - {file = "websockets-10.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e0cb5cc6ece6ffa75baccfd5c02cffe776f3f5c8bf486811f9d3ea3453676ce8"}, - {file = "websockets-10.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ae5e95cfb53ab1da62185e23b3130e11d64431179debac6dc3c6acf08760e9b1"}, - {file = "websockets-10.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7c584f366f46ba667cfa66020344886cf47088e79c9b9d39c84ce9ea98aaa331"}, - {file = "websockets-10.4-cp310-cp310-win32.whl", hash = "sha256:b029fb2032ae4724d8ae8d4f6b363f2cc39e4c7b12454df8df7f0f563ed3e61a"}, - {file = "websockets-10.4-cp310-cp310-win_amd64.whl", hash = "sha256:8dc96f64ae43dde92530775e9cb169979f414dcf5cff670455d81a6823b42089"}, - {file = "websockets-10.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47a2964021f2110116cc1125b3e6d87ab5ad16dea161949e7244ec583b905bb4"}, - {file = "websockets-10.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e789376b52c295c4946403bd0efecf27ab98f05319df4583d3c48e43c7342c2f"}, - {file = "websockets-10.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3f0b61c45c3fa9a349cf484962c559a8a1d80dae6977276df8fd1fa5e3cb8c"}, - {file = "websockets-10.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f55b5905705725af31ccef50e55391621532cd64fbf0bc6f4bac935f0fccec46"}, - {file = "websockets-10.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00c870522cdb69cd625b93f002961ffb0c095394f06ba8c48f17eef7c1541f96"}, - {file = "websockets-10.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f38706e0b15d3c20ef6259fd4bc1700cd133b06c3c1bb108ffe3f8947be15fa"}, - {file = "websockets-10.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f2c38d588887a609191d30e902df2a32711f708abfd85d318ca9b367258cfd0c"}, - {file = "websockets-10.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fe10ddc59b304cb19a1bdf5bd0a7719cbbc9fbdd57ac80ed436b709fcf889106"}, - {file = "websockets-10.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:90fcf8929836d4a0e964d799a58823547df5a5e9afa83081761630553be731f9"}, - {file = "websockets-10.4-cp311-cp311-win32.whl", hash = "sha256:b9968694c5f467bf67ef97ae7ad4d56d14be2751000c1207d31bf3bb8860bae8"}, - {file = "websockets-10.4-cp311-cp311-win_amd64.whl", hash = "sha256:a7a240d7a74bf8d5cb3bfe6be7f21697a28ec4b1a437607bae08ac7acf5b4882"}, - {file = "websockets-10.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:74de2b894b47f1d21cbd0b37a5e2b2392ad95d17ae983e64727e18eb281fe7cb"}, - {file = "websockets-10.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3a686ecb4aa0d64ae60c9c9f1a7d5d46cab9bfb5d91a2d303d00e2cd4c4c5cc"}, - {file = "websockets-10.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d15c968ea7a65211e084f523151dbf8ae44634de03c801b8bd070b74e85033"}, - {file = "websockets-10.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00213676a2e46b6ebf6045bc11d0f529d9120baa6f58d122b4021ad92adabd41"}, - {file = "websockets-10.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e23173580d740bf8822fd0379e4bf30aa1d5a92a4f252d34e893070c081050df"}, - {file = "websockets-10.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:dd500e0a5e11969cdd3320935ca2ff1e936f2358f9c2e61f100a1660933320ea"}, - {file = "websockets-10.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4239b6027e3d66a89446908ff3027d2737afc1a375f8fd3eea630a4842ec9a0c"}, - {file = "websockets-10.4-cp37-cp37m-win32.whl", hash = "sha256:8a5cc00546e0a701da4639aa0bbcb0ae2bb678c87f46da01ac2d789e1f2d2038"}, - {file = "websockets-10.4-cp37-cp37m-win_amd64.whl", hash = "sha256:a9f9a735deaf9a0cadc2d8c50d1a5bcdbae8b6e539c6e08237bc4082d7c13f28"}, - {file = "websockets-10.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5c1289596042fad2cdceb05e1ebf7aadf9995c928e0da2b7a4e99494953b1b94"}, - {file = "websockets-10.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0cff816f51fb33c26d6e2b16b5c7d48eaa31dae5488ace6aae468b361f422b63"}, - {file = "websockets-10.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dd9becd5fe29773d140d68d607d66a38f60e31b86df75332703757ee645b6faf"}, - {file = "websockets-10.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45ec8e75b7dbc9539cbfafa570742fe4f676eb8b0d3694b67dabe2f2ceed8aa6"}, - {file = "websockets-10.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f72e5cd0f18f262f5da20efa9e241699e0cf3a766317a17392550c9ad7b37d8"}, - {file = "websockets-10.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185929b4808b36a79c65b7865783b87b6841e852ef5407a2fb0c03381092fa3b"}, - {file = "websockets-10.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d27a7e34c313b3a7f91adcd05134315002aaf8540d7b4f90336beafaea6217c"}, - {file = "websockets-10.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:884be66c76a444c59f801ac13f40c76f176f1bfa815ef5b8ed44321e74f1600b"}, - {file = "websockets-10.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:931c039af54fc195fe6ad536fde4b0de04da9d5916e78e55405436348cfb0e56"}, - {file = "websockets-10.4-cp38-cp38-win32.whl", hash = "sha256:db3c336f9eda2532ec0fd8ea49fef7a8df8f6c804cdf4f39e5c5c0d4a4ad9a7a"}, - {file = "websockets-10.4-cp38-cp38-win_amd64.whl", hash = "sha256:48c08473563323f9c9debac781ecf66f94ad5a3680a38fe84dee5388cf5acaf6"}, - {file = "websockets-10.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:40e826de3085721dabc7cf9bfd41682dadc02286d8cf149b3ad05bff89311e4f"}, - {file = "websockets-10.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:56029457f219ade1f2fc12a6504ea61e14ee227a815531f9738e41203a429112"}, - {file = "websockets-10.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5fc088b7a32f244c519a048c170f14cf2251b849ef0e20cbbb0fdf0fdaf556f"}, - {file = "websockets-10.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc8709c00704194213d45e455adc106ff9e87658297f72d544220e32029cd3d"}, - {file = "websockets-10.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0154f7691e4fe6c2b2bc275b5701e8b158dae92a1ab229e2b940efe11905dff4"}, - {file = "websockets-10.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c6d2264f485f0b53adf22697ac11e261ce84805c232ed5dbe6b1bcb84b00ff0"}, - {file = "websockets-10.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9bc42e8402dc5e9905fb8b9649f57efcb2056693b7e88faa8fb029256ba9c68c"}, - {file = "websockets-10.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:edc344de4dac1d89300a053ac973299e82d3db56330f3494905643bb68801269"}, - {file = "websockets-10.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:84bc2a7d075f32f6ed98652db3a680a17a4edb21ca7f80fe42e38753a58ee02b"}, - {file = "websockets-10.4-cp39-cp39-win32.whl", hash = "sha256:c94ae4faf2d09f7c81847c63843f84fe47bf6253c9d60b20f25edfd30fb12588"}, - {file = "websockets-10.4-cp39-cp39-win_amd64.whl", hash = "sha256:bbccd847aa0c3a69b5f691a84d2341a4f8a629c6922558f2a70611305f902d74"}, - {file = "websockets-10.4-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:82ff5e1cae4e855147fd57a2863376ed7454134c2bf49ec604dfe71e446e2193"}, - {file = "websockets-10.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d210abe51b5da0ffdbf7b43eed0cfdff8a55a1ab17abbec4301c9ff077dd0342"}, - {file = "websockets-10.4-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:942de28af58f352a6f588bc72490ae0f4ccd6dfc2bd3de5945b882a078e4e179"}, - {file = "websockets-10.4-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9b27d6c1c6cd53dc93614967e9ce00ae7f864a2d9f99fe5ed86706e1ecbf485"}, - {file = "websockets-10.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3d3cac3e32b2c8414f4f87c1b2ab686fa6284a980ba283617404377cd448f631"}, - {file = "websockets-10.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:da39dd03d130162deb63da51f6e66ed73032ae62e74aaccc4236e30edccddbb0"}, - {file = "websockets-10.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389f8dbb5c489e305fb113ca1b6bdcdaa130923f77485db5b189de343a179393"}, - {file = "websockets-10.4-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09a1814bb15eff7069e51fed0826df0bc0702652b5cb8f87697d469d79c23576"}, - {file = "websockets-10.4-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff64a1d38d156d429404aaa84b27305e957fd10c30e5880d1765c9480bea490f"}, - {file = "websockets-10.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b343f521b047493dc4022dd338fc6db9d9282658862756b4f6fd0e996c1380e1"}, - {file = "websockets-10.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:932af322458da7e4e35df32f050389e13d3d96b09d274b22a7aa1808f292fee4"}, - {file = "websockets-10.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a4162139374a49eb18ef5b2f4da1dd95c994588f5033d64e0bbfda4b6b6fcf"}, - {file = "websockets-10.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c57e4c1349fbe0e446c9fa7b19ed2f8a4417233b6984277cce392819123142d3"}, - {file = "websockets-10.4-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b627c266f295de9dea86bd1112ed3d5fafb69a348af30a2422e16590a8ecba13"}, - {file = "websockets-10.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:05a7233089f8bd355e8cbe127c2e8ca0b4ea55467861906b80d2ebc7db4d6b72"}, - {file = "websockets-10.4.tar.gz", hash = "sha256:eef610b23933c54d5d921c92578ae5f89813438fded840c2e9809d378dc765d3"}, + {file = "websockets-11.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:580cc95c58118f8c39106be71e24d0b7e1ad11a155f40a2ee687f99b3e5e432e"}, + {file = "websockets-11.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:143782041e95b63083b02107f31cda999f392903ae331de1307441f3a4557d51"}, + {file = "websockets-11.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8df63dcd955eb6b2e371d95aacf8b7c535e482192cff1b6ce927d8f43fb4f552"}, + {file = "websockets-11.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9b2dced5cbbc5094678cc1ec62160f7b0fe4defd601cd28a36fde7ee71bbb5"}, + {file = "websockets-11.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0eeeea3b01c97fd3b5049a46c908823f68b59bf0e18d79b231d8d6764bc81ee"}, + {file = "websockets-11.0.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:502683c5dedfc94b9f0f6790efb26aa0591526e8403ad443dce922cd6c0ec83b"}, + {file = "websockets-11.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d3cc3e48b6c9f7df8c3798004b9c4b92abca09eeea5e1b0a39698f05b7a33b9d"}, + {file = "websockets-11.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:808b8a33c961bbd6d33c55908f7c137569b09ea7dd024bce969969aa04ecf07c"}, + {file = "websockets-11.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:34a6f8996964ccaa40da42ee36aa1572adcb1e213665e24aa2f1037da6080909"}, + {file = "websockets-11.0.2-cp310-cp310-win32.whl", hash = "sha256:8f24cd758cbe1607a91b720537685b64e4d39415649cac9177cd1257317cf30c"}, + {file = "websockets-11.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:3b87cd302f08ea9e74fdc080470eddbed1e165113c1823fb3ee6328bc40ca1d3"}, + {file = "websockets-11.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3565a8f8c7bdde7c29ebe46146bd191290413ee6f8e94cf350609720c075b0a1"}, + {file = "websockets-11.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f97e03d4d5a4f0dca739ea274be9092822f7430b77d25aa02da6775e490f6846"}, + {file = "websockets-11.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f392587eb2767afa8a34e909f2fec779f90b630622adc95d8b5e26ea8823cb8"}, + {file = "websockets-11.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7742cd4524622cc7aa71734b51294644492a961243c4fe67874971c4d3045982"}, + {file = "websockets-11.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46dda4bc2030c335abe192b94e98686615f9274f6b56f32f2dd661fb303d9d12"}, + {file = "websockets-11.0.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6b2bfa1d884c254b841b0ff79373b6b80779088df6704f034858e4d705a4802"}, + {file = "websockets-11.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1df2413266bf48430ef2a752c49b93086c6bf192d708e4a9920544c74cd2baa6"}, + {file = "websockets-11.0.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf45d273202b0c1cec0f03a7972c655b93611f2e996669667414557230a87b88"}, + {file = "websockets-11.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a09cce3dacb6ad638fdfa3154d9e54a98efe7c8f68f000e55ca9c716496ca67"}, + {file = "websockets-11.0.2-cp311-cp311-win32.whl", hash = "sha256:2174a75d579d811279855df5824676d851a69f52852edb0e7551e0eeac6f59a4"}, + {file = "websockets-11.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:c78ca3037a954a4209b9f900e0eabbc471fb4ebe96914016281df2c974a93e3e"}, + {file = "websockets-11.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3a2100b02d1aaf66dc48ff1b2a72f34f6ebc575a02bc0350cc8e9fbb35940166"}, + {file = "websockets-11.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dca9708eea9f9ed300394d4775beb2667288e998eb6f542cdb6c02027430c599"}, + {file = "websockets-11.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:320ddceefd2364d4afe6576195201a3632a6f2e6d207b0c01333e965b22dbc84"}, + {file = "websockets-11.0.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2a573c8d71b7af937852b61e7ccb37151d719974146b5dc734aad350ef55a02"}, + {file = "websockets-11.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:13bd5bebcd16a4b5e403061b8b9dcc5c77e7a71e3c57e072d8dff23e33f70fba"}, + {file = "websockets-11.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:95c09427c1c57206fe04277bf871b396476d5a8857fa1b99703283ee497c7a5d"}, + {file = "websockets-11.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2eb042734e710d39e9bc58deab23a65bd2750e161436101488f8af92f183c239"}, + {file = "websockets-11.0.2-cp37-cp37m-win32.whl", hash = "sha256:5875f623a10b9ba154cb61967f940ab469039f0b5e61c80dd153a65f024d9fb7"}, + {file = "websockets-11.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:634239bc844131863762865b75211a913c536817c0da27f691400d49d256df1d"}, + {file = "websockets-11.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3178d965ec204773ab67985a09f5696ca6c3869afeed0bb51703ea404a24e975"}, + {file = "websockets-11.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:955fcdb304833df2e172ce2492b7b47b4aab5dcc035a10e093d911a1916f2c87"}, + {file = "websockets-11.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb46d2c7631b2e6f10f7c8bac7854f7c5e5288f024f1c137d4633c79ead1e3c0"}, + {file = "websockets-11.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25aae96c1060e85836552a113495db6d857400288161299d77b7b20f2ac569f2"}, + {file = "websockets-11.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2abeeae63154b7f63d9f764685b2d299e9141171b8b896688bd8baec6b3e2303"}, + {file = "websockets-11.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:daa1e8ea47507555ed7a34f8b49398d33dff5b8548eae3de1dc0ef0607273a33"}, + {file = "websockets-11.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:954eb789c960fa5daaed3cfe336abc066941a5d456ff6be8f0e03dd89886bb4c"}, + {file = "websockets-11.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3ffe251a31f37e65b9b9aca5d2d67fd091c234e530f13d9dce4a67959d5a3fba"}, + {file = "websockets-11.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:adf6385f677ed2e0b021845b36f55c43f171dab3a9ee0ace94da67302f1bc364"}, + {file = "websockets-11.0.2-cp38-cp38-win32.whl", hash = "sha256:aa7b33c1fb2f7b7b9820f93a5d61ffd47f5a91711bc5fa4583bbe0c0601ec0b2"}, + {file = "websockets-11.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:220d5b93764dd70d7617f1663da64256df7e7ea31fc66bc52c0e3750ee134ae3"}, + {file = "websockets-11.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fb4480556825e4e6bf2eebdbeb130d9474c62705100c90e59f2f56459ddab42"}, + {file = "websockets-11.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec00401846569aaf018700249996143f567d50050c5b7b650148989f956547af"}, + {file = "websockets-11.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87c69f50281126dcdaccd64d951fb57fbce272578d24efc59bce72cf264725d0"}, + {file = "websockets-11.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:232b6ba974f5d09b1b747ac232f3a3d8f86de401d7b565e837cc86988edf37ac"}, + {file = "websockets-11.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:392d409178db1e46d1055e51cc850136d302434e12d412a555e5291ab810f622"}, + {file = "websockets-11.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4fe2442091ff71dee0769a10449420fd5d3b606c590f78dd2b97d94b7455640"}, + {file = "websockets-11.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ede13a6998ba2568b21825809d96e69a38dc43184bdeebbde3699c8baa21d015"}, + {file = "websockets-11.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4c54086b2d2aec3c3cb887ad97e9c02c6be9f1d48381c7419a4aa932d31661e4"}, + {file = "websockets-11.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e37a76ccd483a6457580077d43bc3dfe1fd784ecb2151fcb9d1c73f424deaeba"}, + {file = "websockets-11.0.2-cp39-cp39-win32.whl", hash = "sha256:d1881518b488a920434a271a6e8a5c9481a67c4f6352ebbdd249b789c0467ddc"}, + {file = "websockets-11.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:25e265686ea385f22a00cc2b719b880797cd1bb53b46dbde969e554fb458bfde"}, + {file = "websockets-11.0.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ce69f5c742eefd039dce8622e99d811ef2135b69d10f9aa79fbf2fdcc1e56cd7"}, + {file = "websockets-11.0.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b985ba2b9e972cf99ddffc07df1a314b893095f62c75bc7c5354a9c4647c6503"}, + {file = "websockets-11.0.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b52def56d2a26e0e9c464f90cadb7e628e04f67b0ff3a76a4d9a18dfc35e3dd"}, + {file = "websockets-11.0.2-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d70a438ef2a22a581d65ad7648e949d4ccd20e3c8ed7a90bbc46df4e60320891"}, + {file = "websockets-11.0.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:752fbf420c71416fb1472fec1b4cb8631c1aa2be7149e0a5ba7e5771d75d2bb9"}, + {file = "websockets-11.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:dd906b0cdc417ea7a5f13bb3c6ca3b5fd563338dc596996cb0fdd7872d691c0a"}, + {file = "websockets-11.0.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e79065ff6549dd3c765e7916067e12a9c91df2affea0ac51bcd302aaf7ad207"}, + {file = "websockets-11.0.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46388a050d9e40316e58a3f0838c63caacb72f94129eb621a659a6e49bad27ce"}, + {file = "websockets-11.0.2-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c7de298371d913824f71b30f7685bb07ad13969c79679cca5b1f7f94fec012f"}, + {file = "websockets-11.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6d872c972c87c393e6a49c1afbdc596432df8c06d0ff7cd05aa18e885e7cfb7c"}, + {file = "websockets-11.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b444366b605d2885f0034dd889faf91b4b47668dd125591e2c64bfde611ac7e1"}, + {file = "websockets-11.0.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b967a4849db6b567dec3f7dd5d97b15ce653e3497b8ce0814e470d5e074750"}, + {file = "websockets-11.0.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2acdc82099999e44fa7bd8c886f03c70a22b1d53ae74252f389be30d64fd6004"}, + {file = "websockets-11.0.2-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:518ed6782d9916c5721ebd61bb7651d244178b74399028302c8617d0620af291"}, + {file = "websockets-11.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:58477b041099bb504e1a5ddd8aa86302ed1d5c6995bdd3db2b3084ef0135d277"}, + {file = "websockets-11.0.2-py3-none-any.whl", hash = "sha256:5004c087d17251938a52cce21b3dbdabeecbbe432ce3f5bbbf15d8692c36eac9"}, + {file = "websockets-11.0.2.tar.gz", hash = "sha256:b1a69701eb98ed83dd099de4a686dc892c413d974fa31602bc00aca7cb988ac9"}, ] [[package]] @@ -3003,88 +3393,125 @@ files = [ {file = "wrapt-1.15.0.tar.gz", hash = "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a"}, ] +[[package]] +name = "wtforms" +version = "3.0.1" +description = "Form validation and rendering for Python web development." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "WTForms-3.0.1-py3-none-any.whl", hash = "sha256:837f2f0e0ca79481b92884962b914eba4e72b7a2daaf1f939c890ed0124b834b"}, + {file = "WTForms-3.0.1.tar.gz", hash = "sha256:6b351bbb12dd58af57ffef05bc78425d08d1914e0fd68ee14143b7ade023c5bc"}, +] + +[package.dependencies] +MarkupSafe = "*" + +[package.extras] +email = ["email-validator"] + +[[package]] +name = "wtforms-bootstrap5" +version = "0.1.6" +description = "Simple library for rendering WTForms in HTML as Bootstrap 5 form controls" +category = "main" +optional = false +python-versions = "^3.8" +files = [] +develop = false + +[package.dependencies] +WTForms = "^3.0.1" + +[package.source] +type = "git" +url = "https://github.com/mxsasha/wtforms-bootstrap5.git" +reference = "compat-38" +resolved_reference = "ec64fc7620dd9dcb6b13edfb6b239413b3781459" + [[package]] name = "yarl" -version = "1.8.2" +version = "1.9.1" description = "Yet another URL library" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "yarl-1.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bb81f753c815f6b8e2ddd2eef3c855cf7da193b82396ac013c661aaa6cc6b0a5"}, - {file = "yarl-1.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:47d49ac96156f0928f002e2424299b2c91d9db73e08c4cd6742923a086f1c863"}, - {file = "yarl-1.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3fc056e35fa6fba63248d93ff6e672c096f95f7836938241ebc8260e062832fe"}, - {file = "yarl-1.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58a3c13d1c3005dbbac5c9f0d3210b60220a65a999b1833aa46bd6677c69b08e"}, - {file = "yarl-1.8.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10b08293cda921157f1e7c2790999d903b3fd28cd5c208cf8826b3b508026996"}, - {file = "yarl-1.8.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de986979bbd87272fe557e0a8fcb66fd40ae2ddfe28a8b1ce4eae22681728fef"}, - {file = "yarl-1.8.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c4fcfa71e2c6a3cb568cf81aadc12768b9995323186a10827beccf5fa23d4f8"}, - {file = "yarl-1.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae4d7ff1049f36accde9e1ef7301912a751e5bae0a9d142459646114c70ecba6"}, - {file = "yarl-1.8.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bf071f797aec5b96abfc735ab97da9fd8f8768b43ce2abd85356a3127909d146"}, - {file = "yarl-1.8.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:74dece2bfc60f0f70907c34b857ee98f2c6dd0f75185db133770cd67300d505f"}, - {file = "yarl-1.8.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:df60a94d332158b444301c7f569659c926168e4d4aad2cfbf4bce0e8fb8be826"}, - {file = "yarl-1.8.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:63243b21c6e28ec2375f932a10ce7eda65139b5b854c0f6b82ed945ba526bff3"}, - {file = "yarl-1.8.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cfa2bbca929aa742b5084fd4663dd4b87c191c844326fcb21c3afd2d11497f80"}, - {file = "yarl-1.8.2-cp310-cp310-win32.whl", hash = "sha256:b05df9ea7496df11b710081bd90ecc3a3db6adb4fee36f6a411e7bc91a18aa42"}, - {file = "yarl-1.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:24ad1d10c9db1953291f56b5fe76203977f1ed05f82d09ec97acb623a7976574"}, - {file = "yarl-1.8.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2a1fca9588f360036242f379bfea2b8b44cae2721859b1c56d033adfd5893634"}, - {file = "yarl-1.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f37db05c6051eff17bc832914fe46869f8849de5b92dc4a3466cd63095d23dfd"}, - {file = "yarl-1.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77e913b846a6b9c5f767b14dc1e759e5aff05502fe73079f6f4176359d832581"}, - {file = "yarl-1.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0978f29222e649c351b173da2b9b4665ad1feb8d1daa9d971eb90df08702668a"}, - {file = "yarl-1.8.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388a45dc77198b2460eac0aca1efd6a7c09e976ee768b0d5109173e521a19daf"}, - {file = "yarl-1.8.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2305517e332a862ef75be8fad3606ea10108662bc6fe08509d5ca99503ac2aee"}, - {file = "yarl-1.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42430ff511571940d51e75cf42f1e4dbdded477e71c1b7a17f4da76c1da8ea76"}, - {file = "yarl-1.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3150078118f62371375e1e69b13b48288e44f6691c1069340081c3fd12c94d5b"}, - {file = "yarl-1.8.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c15163b6125db87c8f53c98baa5e785782078fbd2dbeaa04c6141935eb6dab7a"}, - {file = "yarl-1.8.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4d04acba75c72e6eb90745447d69f84e6c9056390f7a9724605ca9c56b4afcc6"}, - {file = "yarl-1.8.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e7fd20d6576c10306dea2d6a5765f46f0ac5d6f53436217913e952d19237efc4"}, - {file = "yarl-1.8.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:75c16b2a900b3536dfc7014905a128a2bea8fb01f9ee26d2d7d8db0a08e7cb2c"}, - {file = "yarl-1.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6d88056a04860a98341a0cf53e950e3ac9f4e51d1b6f61a53b0609df342cc8b2"}, - {file = "yarl-1.8.2-cp311-cp311-win32.whl", hash = "sha256:fb742dcdd5eec9f26b61224c23baea46c9055cf16f62475e11b9b15dfd5c117b"}, - {file = "yarl-1.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8c46d3d89902c393a1d1e243ac847e0442d0196bbd81aecc94fcebbc2fd5857c"}, - {file = "yarl-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ceff9722e0df2e0a9e8a79c610842004fa54e5b309fe6d218e47cd52f791d7ef"}, - {file = "yarl-1.8.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f6b4aca43b602ba0f1459de647af954769919c4714706be36af670a5f44c9c1"}, - {file = "yarl-1.8.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1684a9bd9077e922300ecd48003ddae7a7474e0412bea38d4631443a91d61077"}, - {file = "yarl-1.8.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ebb78745273e51b9832ef90c0898501006670d6e059f2cdb0e999494eb1450c2"}, - {file = "yarl-1.8.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3adeef150d528ded2a8e734ebf9ae2e658f4c49bf413f5f157a470e17a4a2e89"}, - {file = "yarl-1.8.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57a7c87927a468e5a1dc60c17caf9597161d66457a34273ab1760219953f7f4c"}, - {file = "yarl-1.8.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:efff27bd8cbe1f9bd127e7894942ccc20c857aa8b5a0327874f30201e5ce83d0"}, - {file = "yarl-1.8.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a783cd344113cb88c5ff7ca32f1f16532a6f2142185147822187913eb989f739"}, - {file = "yarl-1.8.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:705227dccbe96ab02c7cb2c43e1228e2826e7ead880bb19ec94ef279e9555b5b"}, - {file = "yarl-1.8.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:34c09b43bd538bf6c4b891ecce94b6fa4f1f10663a8d4ca589a079a5018f6ed7"}, - {file = "yarl-1.8.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a48f4f7fea9a51098b02209d90297ac324241bf37ff6be6d2b0149ab2bd51b37"}, - {file = "yarl-1.8.2-cp37-cp37m-win32.whl", hash = "sha256:0414fd91ce0b763d4eadb4456795b307a71524dbacd015c657bb2a39db2eab89"}, - {file = "yarl-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:d881d152ae0007809c2c02e22aa534e702f12071e6b285e90945aa3c376463c5"}, - {file = "yarl-1.8.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5df5e3d04101c1e5c3b1d69710b0574171cc02fddc4b23d1b2813e75f35a30b1"}, - {file = "yarl-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7a66c506ec67eb3159eea5096acd05f5e788ceec7b96087d30c7d2865a243918"}, - {file = "yarl-1.8.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2b4fa2606adf392051d990c3b3877d768771adc3faf2e117b9de7eb977741229"}, - {file = "yarl-1.8.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e21fb44e1eff06dd6ef971d4bdc611807d6bd3691223d9c01a18cec3677939e"}, - {file = "yarl-1.8.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93202666046d9edadfe9f2e7bf5e0782ea0d497b6d63da322e541665d65a044e"}, - {file = "yarl-1.8.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc77086ce244453e074e445104f0ecb27530d6fd3a46698e33f6c38951d5a0f1"}, - {file = "yarl-1.8.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dd68a92cab699a233641f5929a40f02a4ede8c009068ca8aa1fe87b8c20ae3"}, - {file = "yarl-1.8.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b372aad2b5f81db66ee7ec085cbad72c4da660d994e8e590c997e9b01e44901"}, - {file = "yarl-1.8.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e6f3515aafe0209dd17fb9bdd3b4e892963370b3de781f53e1746a521fb39fc0"}, - {file = "yarl-1.8.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:dfef7350ee369197106805e193d420b75467b6cceac646ea5ed3049fcc950a05"}, - {file = "yarl-1.8.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:728be34f70a190566d20aa13dc1f01dc44b6aa74580e10a3fb159691bc76909d"}, - {file = "yarl-1.8.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ff205b58dc2929191f68162633d5e10e8044398d7a45265f90a0f1d51f85f72c"}, - {file = "yarl-1.8.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf211dcad448a87a0d9047dc8282d7de59473ade7d7fdf22150b1d23859f946"}, - {file = "yarl-1.8.2-cp38-cp38-win32.whl", hash = "sha256:272b4f1599f1b621bf2aabe4e5b54f39a933971f4e7c9aa311d6d7dc06965165"}, - {file = "yarl-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:326dd1d3caf910cd26a26ccbfb84c03b608ba32499b5d6eeb09252c920bcbe4f"}, - {file = "yarl-1.8.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f8ca8ad414c85bbc50f49c0a106f951613dfa5f948ab69c10ce9b128d368baf8"}, - {file = "yarl-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:418857f837347e8aaef682679f41e36c24250097f9e2f315d39bae3a99a34cbf"}, - {file = "yarl-1.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ae0eec05ab49e91a78700761777f284c2df119376e391db42c38ab46fd662b77"}, - {file = "yarl-1.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:009a028127e0a1755c38b03244c0bea9d5565630db9c4cf9572496e947137a87"}, - {file = "yarl-1.8.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3edac5d74bb3209c418805bda77f973117836e1de7c000e9755e572c1f7850d0"}, - {file = "yarl-1.8.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da65c3f263729e47351261351b8679c6429151ef9649bba08ef2528ff2c423b2"}, - {file = "yarl-1.8.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ef8fb25e52663a1c85d608f6dd72e19bd390e2ecaf29c17fb08f730226e3a08"}, - {file = "yarl-1.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcd7bb1e5c45274af9a1dd7494d3c52b2be5e6bd8d7e49c612705fd45420b12d"}, - {file = "yarl-1.8.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44ceac0450e648de86da8e42674f9b7077d763ea80c8ceb9d1c3e41f0f0a9951"}, - {file = "yarl-1.8.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:97209cc91189b48e7cfe777237c04af8e7cc51eb369004e061809bcdf4e55220"}, - {file = "yarl-1.8.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:48dd18adcf98ea9cd721a25313aef49d70d413a999d7d89df44f469edfb38a06"}, - {file = "yarl-1.8.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e59399dda559688461762800d7fb34d9e8a6a7444fd76ec33220a926c8be1516"}, - {file = "yarl-1.8.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d617c241c8c3ad5c4e78a08429fa49e4b04bedfc507b34b4d8dceb83b4af3588"}, - {file = "yarl-1.8.2-cp39-cp39-win32.whl", hash = "sha256:cb6d48d80a41f68de41212f3dfd1a9d9898d7841c8f7ce6696cf2fd9cb57ef83"}, - {file = "yarl-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:6604711362f2dbf7160df21c416f81fac0de6dbcf0b5445a2ef25478ecc4c778"}, - {file = "yarl-1.8.2.tar.gz", hash = "sha256:49d43402c6e3013ad0978602bf6bf5328535c48d192304b91b97a3c6790b1562"}, + {file = "yarl-1.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e124b283a04cc06d22443cae536f93d86cd55108fa369f22b8fe1f2288b2fe1c"}, + {file = "yarl-1.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:56956b13ec275de31fe4fb991510b735c4fb3e1b01600528c952b9ac90464430"}, + {file = "yarl-1.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecaa5755a39f6f26079bf13f336c67af589c222d76b53cd3824d3b684b84d1f1"}, + {file = "yarl-1.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92a101f6d5a9464e86092adc36cd40ef23d18a25bfb1eb32eaeb62edc22776bb"}, + {file = "yarl-1.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92e37999e36f9f3ded78e9d839face6baa2abdf9344ea8ed2735f495736159de"}, + {file = "yarl-1.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef7e2f6c47c41e234600a02e1356b799761485834fe35d4706b0094cb3a587ee"}, + {file = "yarl-1.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7a0075a55380b19aa43b9e8056e128b058460d71d75018a4f9d60ace01e78c"}, + {file = "yarl-1.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f01351b7809182822b21061d2a4728b7b9e08f4585ba90ee4c5c4d3faa0812"}, + {file = "yarl-1.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6cf47fe9df9b1ededc77e492581cdb6890a975ad96b4172e1834f1b8ba0fc3ba"}, + {file = "yarl-1.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:098bdc06ffb4db39c73883325b8c738610199f5f12e85339afedf07e912a39af"}, + {file = "yarl-1.9.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:6cdb47cbbacae8e1d7941b0d504d0235d686090eef5212ca2450525905e9cf02"}, + {file = "yarl-1.9.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:73a4b46689f2d59c8ec6b71c9a0cdced4e7863dd6eb98a8c30ea610e191f9e1c"}, + {file = "yarl-1.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:65d952e464df950eed32bb5dcbc1b4443c7c2de4d7abd7265b45b1b3b27f5fa2"}, + {file = "yarl-1.9.1-cp310-cp310-win32.whl", hash = "sha256:39a7a9108e9fc633ae381562f8f0355bb4ba00355218b5fb19cf5263fcdbfa68"}, + {file = "yarl-1.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:b63d41e0eecf3e3070d44f97456cf351fff7cb960e97ecb60a936b877ff0b4f6"}, + {file = "yarl-1.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4295790981630c4dab9d6de7b0f555a4c8defe3ed7704a8e9e595a321e59a0f5"}, + {file = "yarl-1.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b2b2382d59dec0f1fdca18ea429c4c4cee280d5e0dbc841180abb82e188cf6e9"}, + {file = "yarl-1.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:575975d28795a61e82c85f114c02333ca54cbd325fd4e4b27598c9832aa732e7"}, + {file = "yarl-1.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bb794882818fae20ff65348985fdf143ea6dfaf6413814db1848120db8be33e"}, + {file = "yarl-1.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89da1fd6068553e3a333011cc17ad91c414b2100c32579ddb51517edc768b49c"}, + {file = "yarl-1.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d817593d345fefda2fae877accc8a0d9f47ada57086da6125fa02a62f6d1a94"}, + {file = "yarl-1.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85aa6fd779e194901386709e0eedd45710b68af2709f82a84839c44314b68c10"}, + {file = "yarl-1.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eed9827033b7f67ad12cb70bd0cb59d36029144a7906694317c2dbf5c9eb5ddd"}, + {file = "yarl-1.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:df747104ef27ab1aa9a1145064fa9ea26ad8cf24bfcbdba7db7abf0f8b3676b9"}, + {file = "yarl-1.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:efec77851231410125cb5be04ec96fa4a075ca637f415a1f2d2c900b09032a8a"}, + {file = "yarl-1.9.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:d5c407e530cf2979ea383885516ae79cc4f3c3530623acf5e42daf521f5c2564"}, + {file = "yarl-1.9.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f76edb386178a54ea7ceffa798cb830c3c22ab50ea10dfb25dc952b04848295f"}, + {file = "yarl-1.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:75676110bce59944dd48fd18d0449bd37eaeb311b38a0c768f7670864b5f8b68"}, + {file = "yarl-1.9.1-cp311-cp311-win32.whl", hash = "sha256:9ba5a18c4fbd408fe49dc5da85478a76bc75c1ce912d7fd7b43ed5297c4403e1"}, + {file = "yarl-1.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:b20a5ddc4e243cbaa54886bfe9af6ffc4ba4ef58f17f1bb691e973eb65bba84d"}, + {file = "yarl-1.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:791357d537a09a194f92b834f28c98d074e7297bac0a8f1d5b458a906cafa17c"}, + {file = "yarl-1.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89099c887338608da935ba8bee027564a94f852ac40e472de15d8309517ad5fe"}, + {file = "yarl-1.9.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:395ea180257a3742d09dcc5071739682a95f7874270ebe3982d6696caec75be0"}, + {file = "yarl-1.9.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90ebaf448b5f048352ec7c76cb8d452df30c27cb6b8627dfaa9cf742a14f141a"}, + {file = "yarl-1.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f878a78ed2ccfbd973cab46dd0933ecd704787724db23979e5731674d76eb36f"}, + {file = "yarl-1.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74390c2318d066962500045aa145f5412169bce842e734b8c3e6e3750ad5b817"}, + {file = "yarl-1.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f8e73f526140c1c32f5fca4cd0bc3b511a1abcd948f45b2a38a95e4edb76ca72"}, + {file = "yarl-1.9.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ac8e593df1fbea820da7676929f821a0c7c2cecb8477d010254ce8ed54328ea8"}, + {file = "yarl-1.9.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:01cf88cb80411978a14aa49980968c1aeb7c18a90ac978c778250dd234d8e0ba"}, + {file = "yarl-1.9.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:97d76a3128f48fa1c721ef8a50e2c2f549296b2402dc8a8cde12ff60ed922f53"}, + {file = "yarl-1.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:01a073c9175481dfed6b40704a1b67af5a9435fc4a58a27d35fd6b303469b0c7"}, + {file = "yarl-1.9.1-cp37-cp37m-win32.whl", hash = "sha256:ecad20c3ef57c513dce22f58256361d10550a89e8eaa81d5082f36f8af305375"}, + {file = "yarl-1.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f5bcb80006efe9bf9f49ae89711253dd06df8053ff814622112a9219346566a7"}, + {file = "yarl-1.9.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e7ddebeabf384099814353a2956ed3ab5dbaa6830cc7005f985fcb03b5338f05"}, + {file = "yarl-1.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:13a1ad1f35839b3bb5226f59816b71e243d95d623f5b392efaf8820ddb2b3cd5"}, + {file = "yarl-1.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f0cd87949d619157a0482c6c14e5011f8bf2bc0b91cb5087414d9331f4ef02dd"}, + {file = "yarl-1.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d21887cbcf6a3cc5951662d8222bc9c04e1b1d98eebe3bb659c3a04ed49b0eec"}, + {file = "yarl-1.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4764114e261fe49d5df9b316b3221493d177247825c735b2aae77bc2e340d800"}, + {file = "yarl-1.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abe37fd89a93ebe0010417ca671f422fa6fcffec54698f623b09f46b4d4a512"}, + {file = "yarl-1.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fe3a1c073ab80a28a06f41d2b623723046709ed29faf2c56bea41848597d86"}, + {file = "yarl-1.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3b5f8da07a21f2e57551f88a6709c2d340866146cf7351e5207623cfe8aad16"}, + {file = "yarl-1.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:88f6413ff5edfb9609e2769e32ce87a62353e66e75d264bf0eaad26fb9daa8f2"}, + {file = "yarl-1.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b5d5fb6c94b620a7066a3adb7c246c87970f453813979818e4707ac32ce4d7bd"}, + {file = "yarl-1.9.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f206adb89424dca4a4d0b31981869700e44cd62742527e26d6b15a510dd410a2"}, + {file = "yarl-1.9.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44fa6158e6b4b8ccfa2872c3900a226b29e8ce543ce3e48aadc99816afa8874d"}, + {file = "yarl-1.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:08c8599d6aa8a24425f8635f6c06fa8726afe3be01c8e53e236f519bcfa5db5b"}, + {file = "yarl-1.9.1-cp38-cp38-win32.whl", hash = "sha256:6b09cce412386ea9b4dda965d8e78d04ac5b5792b2fa9cced3258ec69c7d1c16"}, + {file = "yarl-1.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:09c56a32c26e24ef98d5757c5064e252836f621f9a8b42737773aa92936b8e08"}, + {file = "yarl-1.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b86e98c3021b7e2740d8719bf074301361bf2f51221ca2765b7a58afbfbd9042"}, + {file = "yarl-1.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5faf3ec98747318cb980aaf9addf769da68a66431fc203a373d95d7ee9c1fbb4"}, + {file = "yarl-1.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a21789bdf28549d4eb1de6910cabc762c9f6ae3eef85efc1958197c1c6ef853b"}, + {file = "yarl-1.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8b8d4b478a9862447daef4cafc89d87ea4ed958672f1d11db7732b77ead49cc"}, + {file = "yarl-1.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:307a782736ebf994e7600dcaeea3b3113083584da567272f2075f1540919d6b3"}, + {file = "yarl-1.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46c4010de941e2e1365c07fb4418ddca10fcff56305a6067f5ae857f8c98f3a7"}, + {file = "yarl-1.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab67d041c78e305ff3eef5e549304d843bd9b603c8855b68484ee663374ce15"}, + {file = "yarl-1.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1baf8cdaaab65d9ccedbf8748d626ad648b74b0a4d033e356a2f3024709fb82f"}, + {file = "yarl-1.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:27efc2e324f72df02818cd72d7674b1f28b80ab49f33a94f37c6473c8166ce49"}, + {file = "yarl-1.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ca14b84091700ae7c1fcd3a6000bd4ec1a3035009b8bcb94f246741ca840bb22"}, + {file = "yarl-1.9.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c3ca8d71b23bdf164b36d06df2298ec8a5bd3de42b17bf3e0e8e6a7489195f2c"}, + {file = "yarl-1.9.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:8c72a1dc7e2ea882cd3df0417c808ad3b69e559acdc43f3b096d67f2fb801ada"}, + {file = "yarl-1.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d966cd59df9a4b218480562e8daab39e87e746b78a96add51a3ab01636fc4291"}, + {file = "yarl-1.9.1-cp39-cp39-win32.whl", hash = "sha256:518a92a34c741836a315150460b5c1c71ae782d569eabd7acf53372e437709f7"}, + {file = "yarl-1.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:78755ce43b6e827e65ec0c68be832f86d059fcf05d4b33562745ebcfa91b26b1"}, + {file = "yarl-1.9.1.tar.gz", hash = "sha256:5ce0bcab7ec759062c818d73837644cde567ab8aa1e0d6c45db38dfb7c284441"}, ] [package.dependencies] @@ -3093,64 +3520,58 @@ multidict = ">=4.0" [[package]] name = "zipp" -version = "3.14.0" +version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "zipp-3.14.0-py3-none-any.whl", hash = "sha256:188834565033387710d046e3fe96acfc9b5e86cbca7f39ff69cf21a4128198b7"}, - {file = "zipp-3.14.0.tar.gz", hash = "sha256:9e5421e176ef5ab4c0ad896624e87a7b2f07aca746c9b2aa305952800cb8eecb"}, + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [[package]] name = "zope-interface" -version = "5.5.2" +version = "6.0" description = "Interfaces for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "zope.interface-5.5.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:a2ad597c8c9e038a5912ac3cf166f82926feff2f6e0dabdab956768de0a258f5"}, - {file = "zope.interface-5.5.2-cp27-cp27m-win_amd64.whl", hash = "sha256:65c3c06afee96c654e590e046c4a24559e65b0a87dbff256cd4bd6f77e1a33f9"}, - {file = "zope.interface-5.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d514c269d1f9f5cd05ddfed15298d6c418129f3f064765295659798349c43e6f"}, - {file = "zope.interface-5.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5334e2ef60d3d9439c08baedaf8b84dc9bb9522d0dacbc10572ef5609ef8db6d"}, - {file = "zope.interface-5.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc26c8d44472e035d59d6f1177eb712888447f5799743da9c398b0339ed90b1b"}, - {file = "zope.interface-5.5.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:17ebf6e0b1d07ed009738016abf0d0a0f80388e009d0ac6e0ead26fc162b3b9c"}, - {file = "zope.interface-5.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f98d4bd7bbb15ca701d19b93263cc5edfd480c3475d163f137385f49e5b3a3a7"}, - {file = "zope.interface-5.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:696f3d5493eae7359887da55c2afa05acc3db5fc625c49529e84bd9992313296"}, - {file = "zope.interface-5.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7579960be23d1fddecb53898035a0d112ac858c3554018ce615cefc03024e46d"}, - {file = "zope.interface-5.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:765d703096ca47aa5d93044bf701b00bbce4d903a95b41fff7c3796e747b1f1d"}, - {file = "zope.interface-5.5.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e945de62917acbf853ab968d8916290548df18dd62c739d862f359ecd25842a6"}, - {file = "zope.interface-5.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:655796a906fa3ca67273011c9805c1e1baa047781fca80feeb710328cdbed87f"}, - {file = "zope.interface-5.5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:0fb497c6b088818e3395e302e426850f8236d8d9f4ef5b2836feae812a8f699c"}, - {file = "zope.interface-5.5.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:008b0b65c05993bb08912f644d140530e775cf1c62a072bf9340c2249e613c32"}, - {file = "zope.interface-5.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:404d1e284eda9e233c90128697c71acffd55e183d70628aa0bbb0e7a3084ed8b"}, - {file = "zope.interface-5.5.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3218ab1a7748327e08ef83cca63eea7cf20ea7e2ebcb2522072896e5e2fceedf"}, - {file = "zope.interface-5.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d169ccd0756c15bbb2f1acc012f5aab279dffc334d733ca0d9362c5beaebe88e"}, - {file = "zope.interface-5.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:e1574980b48c8c74f83578d1e77e701f8439a5d93f36a5a0af31337467c08fcf"}, - {file = "zope.interface-5.5.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:0217a9615531c83aeedb12e126611b1b1a3175013bbafe57c702ce40000eb9a0"}, - {file = "zope.interface-5.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:311196634bb9333aa06f00fc94f59d3a9fddd2305c2c425d86e406ddc6f2260d"}, - {file = "zope.interface-5.5.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6373d7eb813a143cb7795d3e42bd8ed857c82a90571567e681e1b3841a390d16"}, - {file = "zope.interface-5.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:959697ef2757406bff71467a09d940ca364e724c534efbf3786e86eee8591452"}, - {file = "zope.interface-5.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:dbaeb9cf0ea0b3bc4b36fae54a016933d64c6d52a94810a63c00f440ecb37dd7"}, - {file = "zope.interface-5.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604cdba8f1983d0ab78edc29aa71c8df0ada06fb147cea436dc37093a0100a4e"}, - {file = "zope.interface-5.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e74a578172525c20d7223eac5f8ad187f10940dac06e40113d62f14f3adb1e8f"}, - {file = "zope.interface-5.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0980d44b8aded808bec5059018d64692f0127f10510eca71f2f0ace8fb11188"}, - {file = "zope.interface-5.5.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6e972493cdfe4ad0411fd9abfab7d4d800a7317a93928217f1a5de2bb0f0d87a"}, - {file = "zope.interface-5.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9d783213fab61832dbb10d385a319cb0e45451088abd45f95b5bb88ed0acca1a"}, - {file = "zope.interface-5.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:a16025df73d24795a0bde05504911d306307c24a64187752685ff6ea23897cb0"}, - {file = "zope.interface-5.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:40f4065745e2c2fa0dff0e7ccd7c166a8ac9748974f960cd39f63d2c19f9231f"}, - {file = "zope.interface-5.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a2ffadefd0e7206adc86e492ccc60395f7edb5680adedf17a7ee4205c530df4"}, - {file = "zope.interface-5.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d692374b578360d36568dd05efb8a5a67ab6d1878c29c582e37ddba80e66c396"}, - {file = "zope.interface-5.5.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4087e253bd3bbbc3e615ecd0b6dd03c4e6a1e46d152d3be6d2ad08fbad742dcc"}, - {file = "zope.interface-5.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fb68d212efd057596dee9e6582daded9f8ef776538afdf5feceb3059df2d2e7b"}, - {file = "zope.interface-5.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:7e66f60b0067a10dd289b29dceabd3d0e6d68be1504fc9d0bc209cf07f56d189"}, - {file = "zope.interface-5.5.2.tar.gz", hash = "sha256:bfee1f3ff62143819499e348f5b8a7f3aa0259f9aca5e0ddae7391d059dce671"}, +python-versions = ">=3.7" +files = [ + {file = "zope.interface-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f299c020c6679cb389814a3b81200fe55d428012c5e76da7e722491f5d205990"}, + {file = "zope.interface-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee4b43f35f5dc15e1fec55ccb53c130adb1d11e8ad8263d68b1284b66a04190d"}, + {file = "zope.interface-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a158846d0fca0a908c1afb281ddba88744d403f2550dc34405c3691769cdd85"}, + {file = "zope.interface-6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f72f23bab1848edb7472309e9898603141644faec9fd57a823ea6b4d1c4c8995"}, + {file = "zope.interface-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48f4d38cf4b462e75fac78b6f11ad47b06b1c568eb59896db5b6ec1094eb467f"}, + {file = "zope.interface-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:87b690bbee9876163210fd3f500ee59f5803e4a6607d1b1238833b8885ebd410"}, + {file = "zope.interface-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f2363e5fd81afb650085c6686f2ee3706975c54f331b426800b53531191fdf28"}, + {file = "zope.interface-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af169ba897692e9cd984a81cb0f02e46dacdc07d6cf9fd5c91e81f8efaf93d52"}, + {file = "zope.interface-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa90bac61c9dc3e1a563e5babb3fd2c0c1c80567e815442ddbe561eadc803b30"}, + {file = "zope.interface-6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89086c9d3490a0f265a3c4b794037a84541ff5ffa28bb9c24cc9f66566968464"}, + {file = "zope.interface-6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:809fe3bf1a91393abc7e92d607976bbb8586512913a79f2bf7d7ec15bd8ea518"}, + {file = "zope.interface-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:0ec9653825f837fbddc4e4b603d90269b501486c11800d7c761eee7ce46d1bbb"}, + {file = "zope.interface-6.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:790c1d9d8f9c92819c31ea660cd43c3d5451df1df61e2e814a6f99cebb292788"}, + {file = "zope.interface-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b39b8711578dcfd45fc0140993403b8a81e879ec25d53189f3faa1f006087dca"}, + {file = "zope.interface-6.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eba51599370c87088d8882ab74f637de0c4f04a6d08a312dce49368ba9ed5c2a"}, + {file = "zope.interface-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee934f023f875ec2cfd2b05a937bd817efcc6c4c3f55c5778cbf78e58362ddc"}, + {file = "zope.interface-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:042f2381118b093714081fd82c98e3b189b68db38ee7d35b63c327c470ef8373"}, + {file = "zope.interface-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dfbbbf0809a3606046a41f8561c3eada9db811be94138f42d9135a5c47e75f6f"}, + {file = "zope.interface-6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:424d23b97fa1542d7be882eae0c0fc3d6827784105264a8169a26ce16db260d8"}, + {file = "zope.interface-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e538f2d4a6ffb6edfb303ce70ae7e88629ac6e5581870e66c306d9ad7b564a58"}, + {file = "zope.interface-6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12175ca6b4db7621aedd7c30aa7cfa0a2d65ea3a0105393e05482d7a2d367446"}, + {file = "zope.interface-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c3d7dfd897a588ec27e391edbe3dd320a03684457470415870254e714126b1f"}, + {file = "zope.interface-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b3f543ae9d3408549a9900720f18c0194ac0fe810cecda2a584fd4dca2eb3bb8"}, + {file = "zope.interface-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0583b75f2e70ec93f100931660328965bb9ff65ae54695fb3fa0a1255daa6f2"}, + {file = "zope.interface-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:23ac41d52fd15dd8be77e3257bc51bbb82469cf7f5e9a30b75e903e21439d16c"}, + {file = "zope.interface-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99856d6c98a326abbcc2363827e16bd6044f70f2ef42f453c0bd5440c4ce24e5"}, + {file = "zope.interface-6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1592f68ae11e557b9ff2bc96ac8fc30b187e77c45a3c9cd876e3368c53dc5ba8"}, + {file = "zope.interface-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4407b1435572e3e1610797c9203ad2753666c62883b921318c5403fb7139dec2"}, + {file = "zope.interface-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:5171eb073474a5038321409a630904fd61f12dd1856dd7e9d19cd6fe092cbbc5"}, + {file = "zope.interface-6.0.tar.gz", hash = "sha256:aab584725afd10c710b8f1e6e208dbee2d0ad009f57d674cb9d1b3964037275d"}, ] [package.dependencies] @@ -3161,7 +3582,18 @@ docs = ["Sphinx", "repoze.sphinx.autointerface"] test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] +[[package]] +name = "zxcvbn" +version = "4.4.28" +description = "" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "zxcvbn-4.4.28.tar.gz", hash = "sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1"}, +] + [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "3a5e4960a6cd5228a1693e4b2ffbd2c95a164212fdb5b622ae046e1c0991305a" +content-hash = "b462050755fbfaa982378a5a7f615f07f550fd6f0fe10bc6760b5477b4bac950" diff --git a/pyproject.toml b/pyproject.toml index af719435c..61a85ff25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ exclude = ['irrd/*/tests', 'irrd/*/*/tests', 'irrd/integration_tests'] python = "^3.8" # For installation dependencies, this project pins on exact # versions. This is because it's an application rather than -# a library, so we assume that irrd is the only tool installe +# a library, so we assume that irrd is the only tool installed # in the venv. Pinning exact versions increases reproducability # in our distributed packages. # https://github.com/python-poetry/poetry/issues/2778 may fix this @@ -52,6 +52,16 @@ sqlalchemy = "1.3.24" alembic = "1.9.4" ujson = "5.7.0" wheel = "0.38.4" +python-multipart = "0.0.6" +imia = "0.5.3" +starlette-wtf = "0.4.3" +limits = "3.2.0" +webauthn = "1.7.2" +pyotp = "2.8.0" +click = "8.1.3" +zxcvbn = "4.4.28" +wtforms-bootstrap5 = {git = "https://github.com/mxsasha/wtforms-bootstrap5.git", rev = "compat-38"} +email-validator = "1.3.1" [tool.poetry.group.dev.dependencies] pytest = "^7.2.1" @@ -65,9 +75,10 @@ pytest-freezegun = "^0.4.2" mypy = { version = "^1.0.1", markers = "platform_python_implementation == 'CPython'" } ruff = "^0.0.252" isort = "^5.12.0" -black = "^23.1.0" -setuptools-scm = "^7.1.0" +black = "23.1.0" poethepoet = "^0.18.1" +factory-boy = "^3.2.1" +smtpdfix = "^0.4.2" [tool.poetry.group.docs.dependencies] sphinx = "^6.1.3" @@ -76,6 +87,7 @@ sphinx-immaterial = "^0.11.3" [tool.poetry.scripts] irrd = 'irrd.daemon.main:main' +irrdctl = 'irrd.scripts.irrd_control:cli' irrd_submit_email = 'irrd.scripts.submit_email:main' irrd_database_upgrade = 'irrd.scripts.database_upgrade:main' irrd_database_downgrade = 'irrd.scripts.database_downgrade:main' @@ -110,7 +122,11 @@ asyncio_mode = "auto" ignore_missing_imports = true install_types = true non_interactive = true -exclude = "irrd/storage/alembic/*" +exclude = ['irrd/vendor/mock_alchemy/'] + +[[tool.mypy.overrides]] +module = "irrd.vendor.mock_alchemy.*" +follow_imports = "skip" [tool.poe.tasks] black = "black irrd" @@ -130,7 +146,7 @@ exclude_lines = [ "raise AssertionError", "raise NotImplementedError", "if 0:", - "if __name__ == '__main__':", + "if __name__ == \"__main__\":", ] # Impractical for unit tests, but covered in integration tests @@ -145,7 +161,8 @@ omit = [ "irrd/scripts/database_downgrade.py", "irrd/scripts/load_test.py", "irrd/integration_tests/*", - "irrd/vendor/*" + "irrd/vendor/*", + "irrd/*/tests/*", ] [build-system]