diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 91d65299800a695cc7684ab763e43eb399548030..9b95b44ccc1bb44784266b97f8eda88cd95a1df2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,11 +1,11 @@ -image: registry.git.cccv.de/uffd/docker-images/buster +image: registry.git.cccv.de/uffd/docker-images/bookworm variables: DEBIAN_FRONTEND: noninteractive GIT_SUBMODULE_STRATEGY: normal APT_API_URL: https://packages.cccv.de APT_REPO: uffd - PYLINT_PIN: pylint~=2.10.0 + PYLINT_PIN: pylint~=2.16.2 before_script: - python3 -V @@ -13,7 +13,7 @@ before_script: - uname -a - python3 -m pylint --version - python3 -m coverage --version - - echo "${CI_COMMIT_TAG}" | grep -qE "v[0-9]+[.][0-9]+[.][0-9]+.*" && export UFFD_PACKAGE_VERSION="${CI_COMMIT_TAG#v}" || export UFFD_PACKAGE_VERSION="${CI_COMMIT_SHA}" + - export PACKAGE_VERSION="$(git describe | sed -E -n -e 's/^v([0-9.]*)$/\1/p' -e 's/^v([0-9.]*)-([0-9]*)-g([0-9a-z]*)$/\1.dev+git.\3/p' | grep .)" .build: stage: build @@ -21,7 +21,7 @@ before_script: build:pip: extends: .build script: - - PACKAGE_VERSION="${UFFD_PACKAGE_VERSION}" python3 -m build + - python3 -m build artifacts: paths: - dist/* @@ -75,33 +75,30 @@ linter:bullseye: reports: codequality: codeclimate.json +linter:bookworm: + image: registry.git.cccv.de/uffd/docker-images/bookworm + stage: test + needs: [] + script: + - pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages! + - python3 -m pylint --output-format=pylint_gitlab.GitlabCodeClimateReporter:codeclimate.json,pylint_gitlab.GitlabPagesHtmlReporter:pylint.html,colorized uffd + artifacts: + when: always + paths: + - pylint.html + reports: + codequality: codeclimate.json + tests:buster:sqlite: image: registry.git.cccv.de/uffd/docker-images/buster stage: test needs: [] script: - - rm -rf pages - - mkdir -p pages - - cp -r uffd/static pages/static - - DUMP_PAGES=pages python3-coverage run --include 'uffd/*.py' -m pytest --junitxml=report.xml || touch failed - - sed -i -e 's/href="\/static\//href=".\/static\//g' -e 's/src="\/static\//src=".\/static\//g' pages/*.html || true - - python3-coverage report -m - - python3-coverage html - - python3-coverage xml - - test ! -e failed + - python3 -m pytest --junitxml=report.xml artifacts: when: always - paths: - - htmlcov/index.html - - htmlcov - - pages - expose_as: 'Coverage Report' reports: - coverage_report: - coverage_format: cobertura - path: coverage.xml junit: report.xml - coverage: '/^TOTAL.*\s+(\d+\%)$/' tests:buster:mysql: image: registry.git.cccv.de/uffd/docker-images/buster @@ -138,10 +135,50 @@ tests:bullseye:mysql: reports: junit: report.xml +tests:bookworm:sqlite: + image: registry.git.cccv.de/uffd/docker-images/bookworm + stage: test + needs: [] + script: + - rm -rf pages + - mkdir -p pages + - cp -r uffd/static pages/static + - DUMP_PAGES=pages python3-coverage run --include 'uffd/*.py' -m pytest --junitxml=report.xml || touch failed + - sed -i -e 's/href="\/static\//href=".\/static\//g' -e 's/src="\/static\//src=".\/static\//g' pages/*.html || true + - python3-coverage report -m + - python3-coverage html + - python3-coverage xml + - test ! -e failed + artifacts: + when: always + paths: + - htmlcov/index.html + - htmlcov + - pages + expose_as: 'Coverage Report' + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml + junit: report.xml + coverage: '/^TOTAL.*\s+(\d+\%)$/' + +tests:bookworm:mysql: + image: registry.git.cccv.de/uffd/docker-images/bookworm + stage: test + needs: [] + script: + - service mariadb start + - TEST_WITH_MYSQL=1 python3 -m pytest --junitxml=report.xml + artifacts: + when: always + reports: + junit: report.xml + html5validator: stage: test needs: - - job: tests:buster:sqlite + - job: tests:bookworm:sqlite script: - html5validator --root pages 2>&1 | tee html5validator.log artifacts: @@ -177,6 +214,14 @@ test:package:pip:bullseye: script: - pip3 install dist/*.tar.gz +test:package:pip:bookworm: + image: registry.git.cccv.de/uffd/docker-images/bookworm + stage: test + needs: + - job: build:pip + script: + - pip3 install dist/*.tar.gz + # Since we want to test if the package installs correctly on a fresh Debian # install (has correct dependencies, etc.), we don't use uffd/docker-images # here @@ -210,6 +255,21 @@ test:package:apt:bullseye: - uffd-admin routes - curl -Lv 127.0.0.1:5000 +test:package:apt:bookworm: + image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/debian:bookworm + stage: test + needs: + - job: build:apt + before_script: [] + script: + - apt -y update + - apt -y install curl ./*.deb + - service uwsgi start uffd || ( service uwsgi status uffd ; sleep 15; cat /var/log/uwsgi/app/uffd.log; ) + - echo "server { listen 127.0.0.1:5000 default_server; include /etc/uffd/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd.ini + - service nginx start || ( service nginx status; nginx -t; exit 1; ) + - uffd-admin routes + - curl -Lv 127.0.0.1:5000 + .publish: stage: deploy rules: @@ -234,5 +294,6 @@ publish:apt: - echo Update published repo for all distros - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/buster"' - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/bullseye"' + - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/bookworm"' dependencies: - build:apt diff --git a/.pylintrc b/.pylintrc index f3f5782875309366520961e3879688638a10911e..90cd22f4a43abda1b3cc6547a63e0be3345b86fb 100644 --- a/.pylintrc +++ b/.pylintrc @@ -68,6 +68,8 @@ disable=missing-module-docstring, too-many-ancestors, duplicate-code, redefined-builtin, + superfluous-parens, + consider-using-f-string, # Temporary # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -386,13 +388,6 @@ max-line-length=160 # Maximum number of lines in a module. max-module-lines=1000 -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no @@ -513,5 +508,5 @@ min-public-methods=2 # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception +overgeneral-exceptions=builtin.BaseException, + builtin.Exception diff --git a/README.md b/README.md index f9e79f31372d4811f3ee4805395a40262d6f2bde..984acc8eb473b8b88ebdb5361887a3b46a371f2c 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,15 @@ Please note that we refer to Debian packages here and **not** pip packages. - python3-qrcode - python3-fido2 (version 0.5.0 or 0.9.1, optional) - python3-prometheus-client (optional, needed for metrics) -- python3-oauthlib +- python3-jwt +- python3-cryptography - python3-flask-babel - python3-argon2 - python3-itsdangerous (also a dependency of python3-flask) - python3-mysqldb or python3-pymysql for MariaDB support +- python3-ua-parser (optional, better user agent parsing) -Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Buster or Bullseye. +Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Bookworm, Bullseye or Buster. For development, you can also use virtualenv with the supplied `requirements.txt`. ## Development @@ -57,7 +59,7 @@ The dependencies of the pip package roughly represent the versions shipped by De We do not keep them updated and we do not test the pip package! The pip package only exists for local testing/development and to help build the Debian package. -We provide packages for Debian stable and oldstable (currently Bullseye and Buster). +We provide packages for Debian stable, oldstable and oldoldstable (currently Bookworm, Bullseye and Buster). Since all dependencies are available in the official package mirrors, you will get security updates for everything but uffd itself from Debian. To install uffd on Debian Bullseye, add our package mirror to `/etc/sources.list`: @@ -97,6 +99,8 @@ The services need to be setup to use the following URLs with the Authorization C * `/oauth2/token`: token request endpoint * `/oauth2/userinfo`: endpoint that provides information about the current user +If the service supports server metadata discovery ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), configuring the base url of your uffd installation or `/.well-known/openid-configuration` as the discovery endpoint should be sufficient. + The only OAuth2 scope supported is `profile`. The userinfo endpoint returns json data with the following structure: ``` @@ -114,6 +118,48 @@ The only OAuth2 scope supported is `profile`. The userinfo endpoint returns json `id` is the numeric (Unix) user id, `name` the display name and `nickname` the loginname of the user. +## OpenID Connect Single-Sign-On Provider + +In addition to plain OAuth2, uffd also has basic OpenID Connect support. +Endpoint URLs are the same as for plain OAuth2. +OpenID Connect support is enabled by requesting the `openid` scope. +ID token signing keys are served at `/oauth2/keys`. + +See [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) specification for more details. + +Supported flows and response types: + +* Only Authorization Code Flow with `code` response type + +Supported scopes: + +* `openid`: Enables OpenID Connect support and returns mandatory `sub` claim +* `profile`: Returns `name` and `preferred_username` claims +* `email`: Returns `email` and `email_verified` claims +* `groups`: Returns non-standard `groups` claim + +Supported claims: + +* `sub` (string): Decimal encoded numeric (Unix) user id +* `name` (string): Display name +* `preferred_username`(string): Loginname +* `email` (string): Service-specific or primary email address +* `email_verified` (boolean): Verification status of `email` value (always `true`) +* `groups` (array of strings): Names of groups the user is a member of (non-standard) + +uffd supports the optional `claims` authorization request parameter for requesting claims individually. + +Note that there is a IANA-registered `groups` claim with a syntax borrowed from [SCIM](https://www.rfc-editor.org/rfc/rfc7643.html). +The syntax used by uffd is different and incompatible, although arguably more common for a claim named "groups" in this context. + +uffd aims for complience with OpenID provider conformance profiles Basic and Config. +It is, however, not a certified OpenID provider and it has the following limitations: + +* Only the `none` value for the `prompt` authorization request parameter is recognized. Other values (`login`, `consent` and `select_account`) are ignored. +* The `max_age` authorization request parameter is not supported and ignored by uffd. +* The `auth_time` claim is not supported and neither returned if the `max_age` authorization request parameter is present nor if it is requested via the `claims` parameter. +* Requesting the `sub` claim with a specific value for the ID Token (or passing the `id_token_hint` authorization request parameter) is only supported if the `prompt` authorization request parameter is set to `none`. The authorization request is rejected otherwise. + ## Metrics Uffd can export metrics in a prometheus compatible way. It needs python3-prometheus-client for this feature to work. diff --git a/debian/control b/debian/control index 0e498d6a15f3d2fe548a96bc6204fcc630f7fc79..e206c489378e9765293976b31038f9edd24f6db3 100644 --- a/debian/control +++ b/debian/control @@ -23,7 +23,8 @@ Depends: python3-flask-migrate, python3-qrcode, python3-fido2, - python3-oauthlib, + python3-jwt, + python3-cryptography, python3-flask-babel, python3-argon2, python3-itsdangerous, @@ -33,4 +34,5 @@ Recommends: nginx, python3-mysqldb, python3-prometheus-client, + python3-ua-parser, Description: Web-based user management and single sign-on software diff --git a/setup.py b/setup.py index 794373b3a024cd8586babf297ac09b7651dacb1f..31db92ff18e3250be26528d79407827e0fbab785 100644 --- a/setup.py +++ b/setup.py @@ -36,13 +36,15 @@ setup( 'Flask-SQLAlchemy==2.1', 'qrcode==6.1', 'fido2==0.5.0', - 'oauthlib==2.1.0', + 'cryptography==2.6.1', + 'pyjwt==1.7.0', 'Flask-Migrate==2.1.1', 'Flask-Babel==0.11.2', 'alembic==1.0.0', 'argon2-cffi==18.3.0', 'itsdangerous==0.24', 'prometheus-client==0.9', + 'ua-parser==0.8.0', # The main dependencies on their own lead to version collisions and pip is # not very good at resolving them, so we pin the versions from Debian Buster @@ -52,11 +54,9 @@ setup( 'cffi # v1.12.2 no longer works with python3.9. Newer versions seem to work fine.', 'chardet==3.0.4', 'click==7.0', - 'cryptography==2.6.1', 'idna==2.6', 'Jinja2==2.10', 'MarkupSafe==1.1.0', - 'oauthlib==2.1.0', 'pyasn1==0.4.2', 'pycparser==2.19', 'requests==2.21.0', diff --git a/tests/migrations/test_fuzzy.py b/tests/migrations/test_fuzzy.py index 7200267b7ba403c61850cf91d1ff2fe175be898b..597e9b33c5a2614b452739ad74d998d29db4fa46 100644 --- a/tests/migrations/test_fuzzy.py +++ b/tests/migrations/test_fuzzy.py @@ -13,6 +13,7 @@ from uffd.models import ( Service, OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, PasswordToken, + Session, ) from tests.utils import MigrationTestCase @@ -60,9 +61,17 @@ class TestFuzzy(MigrationTestCase): service = Service(name='testservice', access_group=group) oauth2_client = OAuth2Client(service=service, client_id='testclient', client_secret='testsecret', redirect_uris=['http://localhost:1234/callback'], logout_uris=[OAuth2LogoutURI(method='GET', uri='http://localhost:1234/callback')]) db.session.add_all([service, oauth2_client]) - db.session.add(OAuth2Grant(user=user, client=oauth2_client, code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now())) - db.session.add(OAuth2Token(user=user, client=oauth2_client, token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now())) - db.session.add(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(user=user)])) + session = Session( + user=user, + secret='0919de9da3f7dc6c33ab849f44c20e8221b673ca701030de17488f3269fc5469f100e2ce56e5fd71305b23d8ecbb06d80d22004adcd3fefc5f5fcb80a436e31f2c2d9cc8fe8c59ae44871ae4524408d312474570280bf29d3ba145a4bd00010ca758eaa0795b180ec12978b42d13bf4c4f06f72103d44077022ce656610be855', + ip_address='127.0.0.1', + user_agent='Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0', + mfa_done=True, + ) + db.session.add(session) + db.session.add(OAuth2Grant(session=session, client=oauth2_client, _code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now())) + db.session.add(OAuth2Token(session=session, client=oauth2_client, token_type='Bearer', _access_token='testcode', _refresh_token='testcode', expires=datetime.datetime.now())) + db.session.add(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(session=session)])) db.session.add(PasswordToken(user=user)) db.session.commit() revs = [s.split('_', 1)[0] for s in os.listdir('uffd/migrations/versions') if '_' in s and s.endswith('.py')] diff --git a/tests/models/test_mfa.py b/tests/models/test_mfa.py index f2494fafc124119bd1d73b733f707036ca8144d7..885aba0c35ecd6bfc195d62180deffe5ba477ddd 100644 --- a/tests/models/test_mfa.py +++ b/tests/models/test_mfa.py @@ -82,6 +82,15 @@ class TestMfaMethodModels(UffdTestCase): self.assertTrue(method.verify(_hotp(counter, method.raw_key))) self.assertFalse(method.verify(_hotp(counter+2, method.raw_key))) + def test_totp_method_verify_reuse(self): + method = TOTPMethod(user=self.get_user()) + counter = int(time.time()/30) + self.assertFalse(method.verify(_hotp(counter-2, method.raw_key))) + self.assertTrue(method.verify(_hotp(counter-1, method.raw_key))) + self.assertTrue(method.verify(_hotp(counter, method.raw_key))) + self.assertFalse(method.verify(_hotp(counter-1, method.raw_key))) + self.assertFalse(method.verify(_hotp(counter, method.raw_key))) + def test_webauthn_method(self): data = get_fido2_test_cred(self) method = WebauthnMethod(user=self.get_user(), cred=data, name='testname') diff --git a/tests/models/test_oauth2.py b/tests/models/test_oauth2.py new file mode 100644 index 0000000000000000000000000000000000000000..3a2dc85ed091f59a22c579499b035935bb902a39 --- /dev/null +++ b/tests/models/test_oauth2.py @@ -0,0 +1,159 @@ +import unittest +import datetime + +import jwt + +from uffd.database import db +from uffd.models import OAuth2Key + +from tests.utils import UffdTestCase + +TEST_JWK = dict( + id='HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU', + created=datetime.datetime(2023, 11, 9, 0, 21, 10), + active=True, + algorithm='RS256', + private_key_jwk='''{ + "kty": "RSA", + "key_ops": ["sign"], + "n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H", + "e": "AQAB", + "d": "G7yoH5mLcZTA6ia-byCoN-zpofGvdga9AZnxPO0vsq6K_cY_O2gxuVZ3n6reAKKbuLNGCbb_D_Dffs4q8rprlfkgi3TCLzXX5Zv5HWTD7a4Y7xpxEzQ2sWo-iagVIqZVPh0pyjliqnTyUWnFmWiY0gBe9UHianHjFVZqe8E2HFOKgW3UUbQz0keg8JtJ3T9gzZrM38KWbqhOJO0VVSRAoANPTSnumfRsUCyWywrMtIfgAbQaKazqX3xkOsAF1L-iNfd6slzPvRyIQVflVDMdfKnsu-lHiKJ0DK_lg9f55T5FymgcXsq43EKBQ2H4v2dafIm-vtWx_TRZWj_msD32BEPBA-zTqh_oP1r6a3DZh4DBtWY3vzSiuhAC0erlRs-hRTX_e9ET5fUbJnmNxjnxQD9zZmwq4ujMK6KFnHct8t77Qxj3a-wDR_XyDJ4_EKYqHlcVHfxGNBSvIdjuZJkPJnVpVtfCtpyamQIR4u5oNV7fIwYe_tFnw0Y90rGoJMzB", + "p": "-A-FnH21HJ7GPWUm9k3mxsxSchy89QEUCZZiH6EcB4ZP8wJsxrQsUSIHCR74YmZEI3Ulsum1Ql4x50k7Q2sNh9SnwKvmctjksehGy4yCrdunAqjqyz3wFwGaKWnhn3frkiqH5ATjkOoc8qHz8saa7reeVClj47ZWyy-Nl559ycLMs0rI1N_THzO07C3jSbJhyPj0yeygAflsRqqnNvEQ6ps1VLiqf9G5jfSvUUn5DyKIpep9iGo29caGSIPIy_2h", + "q": "xNe1-QWskxOcY_GiHpFWdvzqr1o9fxg5whgpNcGi3caokw2iNHRYut4cbVvFFBlv_9B5QCl9WVfR2ADG0AtvkvUxEZqCdxEvcqjIANeRLKHDjW5kMuPS0_fcskFP-r7mCM9SBfPplfMVCF5nuNWf5LzNopWfsTChIDD1rSpPjItNYuwLXszm_3R81HHHeQLcyvoMxLCmeLy5TXX2hXOMHh2IMZCXAHopJmLJUVnQ48kr5jd2l0kLbmx3aBqdccJn", + "dp": "MLS7g1KbcRcrzXpDADGjkn0j4wwJfgHMMWW5toQnwMJ6iDh9qzZNTVDlGMFf-9IgpuWllU-WK4XbPpJ-dGpcqcLzfT1DbmFv5g65d9YLAqASVs9b6rQqpBnIb0E-79TYCEcZj4f2NsoBDRMHly-v1BdxmwzVdCylNhgMMS0Jfcgl8T5J2KJqDcJVT9piumGwGYnoZo1zjW-v9uAjHQKQU8BN5Git8ZL4YAsfMVLY-EPLmOhF5bcVO4TTcQGPN56B", + "dq": "HiiSl-G3rB0QE_v8g8Ruw_JCHrWrwGI8zzEWd0cApgv-3fDzzieZRKAtKNArpMW09DPDsAHrU5nx669KxqtJ3_EzIGhU3ttCMsYLRp3Af18VcADe1zEypwlNxf3dvCQtaGIjRgg13KSOr2aPa7FHOyt2MhfMjMBPn3gA3BQkdfsN0z8pCtBIABGf4ojAMBkxLOQcurH5_3uixGxzZcTrTd3mdPmbORZ-YYQ3JgCl0ZCL6kzLHaiyWKvDq66QOtK3", + "qi": "ySqD9cUxbq3wkCsPQId_YfQLIqb5RK_JJIMjtBOdTdo4aT5tmodYCSmjBmhrYXjDWtyJdelvPfdSfgncHJhf8VgkZ8TPvUeaQwsQFBwB5llwpdb72eEEJrmG1SVwNMoFCLXdNT3ACad16cUDMnWmklH0X07OzdxGOBnGhgLZUs4RbPjLH7OpYTyQqVy2L8vofqJR42cfePZw8WQM4k0PPbhralhybExIkSCmaQyYbACZ5k0OVQErEqnj4elglA0h" + }''', + public_key_jwk='''{ + "kty": "RSA", + "key_ops": ["verify"], + "n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H", + "e": "AQAB" + }''', +) + +class TestOAuth2Key(UffdTestCase): + def setUp(self): + super().setUp() + db.session.add(OAuth2Key(**TEST_JWK)) + db.session.add(OAuth2Key( + id='1e9gdk7', + created=datetime.datetime(2014, 11, 8, 0, 0, 0), + active=True, + algorithm='RS256', + private_key_jwk='invalid', + public_key_jwk='''{ + "kty":"RSA", + "n":"w7Zdfmece8iaB0kiTY8pCtiBtzbptJmP28nSWwtdjRu0f2GFpajvWE4VhfJAjEsOcwYzay7XGN0b-X84BfC8hmCTOj2b2eHT7NsZegFPKRUQzJ9wW8ipn_aDJWMGDuB1XyqT1E7DYqjUCEOD1b4FLpy_xPn6oV_TYOfQ9fZdbE5HGxJUzekuGcOKqOQ8M7wfYHhHHLxGpQVgL0apWuP2gDDOdTtpuld4D2LK1MZK99s9gaSjRHE8JDb1Z4IGhEcEyzkxswVdPndUWzfvWBBWXWxtSUvQGBRkuy1BHOa4sP6FKjWEeeF7gm7UMs2Nm2QUgNZw6xvEDGaLk4KASdIxRQ", + "e":"AQAB" + }''' + )) + db.session.commit() + self.key = OAuth2Key.query.get('HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU') + self.key_oidc_spec = OAuth2Key.query.get('1e9gdk7') + + def test_private_key(self): + self.key.private_key + + def test_public_key(self): + self.key.private_key + + def test_public_key_jwks_dict(self): + self.assertEqual(self.key.public_key_jwks_dict, { + "kid": "HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H", + "e": "AQAB" + }) + + def test_encode_jwt(self): + jwtdata = self.key.encode_jwt({'aud': 'test', 'foo': 'bar'}) + self.assertIsInstance(jwtdata, str) # Regression check for #165 + self.assertEqual( + jwt.get_unverified_header(jwtdata), + # typ is optional, x5u/x5c/jku/jwk are discoraged by OIDC Core 1.0 spec section 2 + {'kid': self.key.id, 'alg': self.key.algorithm, 'typ': 'JWT'} + ) + self.assertEqual( + OAuth2Key.decode_jwt(jwtdata, audience='test'), + {'aud': 'test', 'foo': 'bar'} + ) + self.key.active = False + with self.assertRaises(jwt.exceptions.InvalidKeyError): + self.key.encode_jwt({'aud': 'test', 'foo': 'bar'}) + + def test_oidc_hash(self): + # Example from OIDC Core 1.0 spec A.3 + self.assertEqual( + self.key.oidc_hash(b'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y'), + '77QmUPtjPfzWtF2AnpK9RQ' + ) + # Example from OIDC Core 1.0 spec A.4 + self.assertEqual( + self.key.oidc_hash(b'Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk'), + 'LDktKdoQak3Pk0cnXxCltA' + ) + # Example from OIDC Core 1.0 spec A.6 + self.assertEqual( + self.key.oidc_hash(b'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y'), + '77QmUPtjPfzWtF2AnpK9RQ' + ) + self.assertEqual( + self.key.oidc_hash(b'Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk'), + 'LDktKdoQak3Pk0cnXxCltA' + ) + + def test_decode_jwt(self): + # Example from OIDC Core 1.0 spec A.2 + jwt_data = ( + 'eyJraWQiOiIxZTlnZGs3IiwiYWxnIjoiUlMyNTYifQ.ewogImlz' + 'cyI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4' + 'Mjg5NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAi' + 'bi0wUzZfV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEz' + 'MTEyODA5NzAsCiAibmFtZSI6ICJKYW5lIERvZSIsCiAiZ2l2ZW5fbmFtZSI6' + 'ICJKYW5lIiwKICJmYW1pbHlfbmFtZSI6ICJEb2UiLAogImdlbmRlciI6ICJm' + 'ZW1hbGUiLAogImJpcnRoZGF0ZSI6ICIwMDAwLTEwLTMxIiwKICJlbWFpbCI6' + 'ICJqYW5lZG9lQGV4YW1wbGUuY29tIiwKICJwaWN0dXJlIjogImh0dHA6Ly9l' + 'eGFtcGxlLmNvbS9qYW5lZG9lL21lLmpwZyIKfQ.rHQjEmBqn9Jre0OLykYNn' + 'spA10Qql2rvx4FsD00jwlB0Sym4NzpgvPKsDjn_wMkHxcp6CilPcoKrWHcip' + 'R2iAjzLvDNAReF97zoJqq880ZD1bwY82JDauCXELVR9O6_B0w3K-E7yM2mac' + 'AAgNCUwtik6SjoSUZRcf-O5lygIyLENx882p6MtmwaL1hd6qn5RZOQ0TLrOY' + 'u0532g9Exxcm-ChymrB4xLykpDj3lUivJt63eEGGN6DH5K6o33TcxkIjNrCD' + '4XB1CKKumZvCedgHHF3IAK4dVEDSUoGlH9z4pP_eWYNXvqQOjGs-rDaQzUHl' + '6cQQWNiDpWOl_lxXjQEvQ' + ) + self.assertEqual( + OAuth2Key.decode_jwt(jwt_data, options={'verify_exp': False, 'verify_aud': False}), + { + "iss": "http://server.example.com", + "sub": "248289761001", + "aud": "s6BhdRkqt3", + "nonce": "n-0S6_WzA2Mj", + "exp": 1311281970, + "iat": 1311280970, + "name": "Jane Doe", + "given_name": "Jane", + "family_name": "Doe", + "gender": "female", + "birthdate": "0000-10-31", + "email": "janedoe@example.com", + "picture": "http://example.com/janedoe/me.jpg" + } + ) + with self.assertRaises(jwt.exceptions.InvalidKeyError): + # {"alg":"RS256"} -> no key id + OAuth2Key.decode_jwt('eyJhbGciOiJSUzI1NiJ9.' + jwt_data.split('.', 1)[-1]) + with self.assertRaises(jwt.exceptions.InvalidKeyError): + # {"kid":"XXXXX","alg":"RS256"} -> unknown key id + OAuth2Key.decode_jwt('eyJraWQiOiJYWFhYWCIsImFsZyI6IlJTMjU2In0.' + jwt_data.split('.', 1)[-1]) + OAuth2Key.query.get('1e9gdk7').active = False + with self.assertRaises(jwt.exceptions.InvalidKeyError): + # not active + OAuth2Key.decode_jwt(jwt_data) + + def test_generate_rsa_key(self): + key = OAuth2Key.generate_rsa_key() + self.assertEqual(key.algorithm, 'RS256') diff --git a/tests/models/test_role.py b/tests/models/test_role.py index 8c786a75c1f818aaa2036dba7bba412ead3527f4..21c8a264f566aa76d9bd4d4289c8d059073ff202 100644 --- a/tests/models/test_role.py +++ b/tests/models/test_role.py @@ -57,6 +57,7 @@ class TestUserRoleAttributes(UffdTestCase): role2.groups[group2].requires_mfa = True self.assertSetEqual(user.compute_groups(), {group1}) db.session.add(TOTPMethod(user=user)) + db.session.commit() self.assertSetEqual(user.compute_groups(), {group1, group2}) def test_update_groups(self): diff --git a/tests/models/test_session.py b/tests/models/test_session.py new file mode 100644 index 0000000000000000000000000000000000000000..291c63a09a74011b07e39cc9577898adad35b67d --- /dev/null +++ b/tests/models/test_session.py @@ -0,0 +1,48 @@ +import unittest +import datetime + +from uffd.database import db +from uffd.models.session import Session, USER_AGENT_PARSER_SUPPORTED + +from tests.utils import UffdTestCase + +class TestSession(UffdTestCase): + def test_expire(self): + self.app.config['SESSION_LIFETIME_SECONDS'] = 100 + self.app.config['PERMANENT_SESSION_LIFETIME'] = 10 + user = self.get_user() + def make_session(created_age, last_used_age): + return Session( + user=user, + created=datetime.datetime.utcnow() - datetime.timedelta(seconds=created_age), + last_used=datetime.datetime.utcnow() - datetime.timedelta(seconds=last_used_age), + ) + session1 = Session(user=user) + self.assertFalse(session1.expired) + session2 = make_session(0, 0) + self.assertFalse(session2.expired) + session3 = make_session(50, 5) + self.assertFalse(session3.expired) + session4 = make_session(50, 15) + self.assertTrue(session4.expired) + session5 = make_session(105, 5) + self.assertTrue(session5.expired) + session6 = make_session(105, 15) + self.assertTrue(session6.expired) + db.session.add_all([session1, session2, session3, session4, session5, session6]) + db.session.commit() + self.assertEqual(set(Session.query.filter_by(expired=False).all()), {session1, session2, session3}) + self.assertEqual(set(Session.query.filter_by(expired=True).all()), {session4, session5, session6}) + + def test_useragent_ua_parser(self): + if not USER_AGENT_PARSER_SUPPORTED: + self.skipTest('ua_parser not available') + session = Session(user_agent='Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0') + self.assertEqual(session.user_agent_browser, 'Firefox') + self.assertEqual(session.user_agent_platform, 'Windows') + + def test_useragent_no_ua_parser(self): + session = Session(user_agent='Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0') + session.DISABLE_USER_AGENT_PARSER = True + self.assertEqual(session.user_agent_browser, 'Firefox') + self.assertEqual(session.user_agent_platform, 'Windows') diff --git a/tests/views/test_mfa.py b/tests/views/test_mfa.py deleted file mode 100644 index 589bf3707bc782505bdb324822c7e504b1da7d4f..0000000000000000000000000000000000000000 --- a/tests/views/test_mfa.py +++ /dev/null @@ -1,349 +0,0 @@ -import time - -from flask import url_for, session, request - -from uffd.database import db -from uffd.models import Role, RoleGroup, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod -from uffd.models.mfa import _hotp - -from tests.utils import dump, UffdTestCase, db_flush - -def get_fido2_test_cred(self): - try: - from uffd.fido2_compat import AttestedCredentialData - except ImportError: - self.skipTest('fido2 could not be imported') - # Example public key from webauthn spec 6.5.1.1 - return AttestedCredentialData(bytes.fromhex('00000000000000000000000000000000'+'0040'+'053cbcc9d37a61d3bac87cdcc77ee326256def08ab15775d3a720332e4101d14fae95aeee3bc9698781812e143c0597dc6e180595683d501891e9dd030454c0a'+'A501020326200121582065eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d2258201e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c')) - -class TestMfaViews(UffdTestCase): - def setUp(self): - super().setUp() - db.session.add(RecoveryCodeMethod(user=self.get_admin())) - db.session.add(TOTPMethod(user=self.get_admin(), name='Admin Phone')) - # We don't want to skip all tests only because fido2 is not installed! - #db.session.add(WebauthnMethod(user=get_testadmin(), cred=get_fido2_test_cred(self), name='Admin FIDO2 dongle')) - db.session.commit() - - def add_recovery_codes(self, count=10): - user = self.get_user() - for _ in range(count): - db.session.add(RecoveryCodeMethod(user=user)) - db.session.commit() - - def add_totp(self): - db.session.add(TOTPMethod(user=self.get_user(), name='My phone')) - db.session.commit() - - def add_webauthn(self): - db.session.add(WebauthnMethod(user=self.get_user(), cred=get_fido2_test_cred(self), name='My FIDO2 dongle')) - db.session.commit() - - def test_setup_disabled(self): - self.login_as('user') - r = self.client.get(path=url_for('mfa.setup'), follow_redirects=True) - dump('mfa_setup_disabled', r) - self.assertEqual(r.status_code, 200) - - def test_setup_recovery_codes(self): - self.login_as('user') - self.add_recovery_codes() - r = self.client.get(path=url_for('mfa.setup'), follow_redirects=True) - dump('mfa_setup_only_recovery_codes', r) - self.assertEqual(r.status_code, 200) - - def test_setup_enabled(self): - self.login_as('user') - self.add_recovery_codes() - self.add_totp() - self.add_webauthn() - r = self.client.get(path=url_for('mfa.setup'), follow_redirects=True) - dump('mfa_setup_enabled', r) - self.assertEqual(r.status_code, 200) - - def test_setup_few_recovery_codes(self): - self.login_as('user') - self.add_totp() - self.add_recovery_codes(1) - r = self.client.get(path=url_for('mfa.setup'), follow_redirects=True) - dump('mfa_setup_few_recovery_codes', r) - self.assertEqual(r.status_code, 200) - - def test_setup_no_recovery_codes(self): - self.login_as('user') - self.add_totp() - r = self.client.get(path=url_for('mfa.setup'), follow_redirects=True) - dump('mfa_setup_no_recovery_codes', r) - self.assertEqual(r.status_code, 200) - - def test_disable(self): - baserole = Role(name='baserole', is_default=True) - db.session.add(baserole) - baserole.groups[self.get_access_group()] = RoleGroup() - db.session.commit() - self.login_as('user') - self.add_recovery_codes() - self.add_totp() - admin_methods = len(MFAMethod.query.filter_by(user=self.get_admin()).all()) - r = self.client.get(path=url_for('mfa.disable'), follow_redirects=True) - dump('mfa_disable', r) - self.assertEqual(r.status_code, 200) - r = self.client.post(path=url_for('mfa.disable_confirm'), follow_redirects=True) - dump('mfa_disable_submit', r) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(MFAMethod.query.filter_by(user=request.user).all()), 0) - self.assertEqual(len(MFAMethod.query.filter_by(user=self.get_admin()).all()), admin_methods) - - def test_disable_recovery_only(self): - baserole = Role(name='baserole', is_default=True) - db.session.add(baserole) - baserole.groups[self.get_access_group()] = RoleGroup() - db.session.commit() - self.login_as('user') - self.add_recovery_codes() - admin_methods = len(MFAMethod.query.filter_by(user=self.get_admin()).all()) - self.assertNotEqual(len(MFAMethod.query.filter_by(user=request.user).all()), 0) - r = self.client.get(path=url_for('mfa.disable'), follow_redirects=True) - dump('mfa_disable_recovery_only', r) - self.assertEqual(r.status_code, 200) - r = self.client.post(path=url_for('mfa.disable_confirm'), follow_redirects=True) - dump('mfa_disable_recovery_only_submit', r) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(MFAMethod.query.filter_by(user=request.user).all()), 0) - self.assertEqual(len(MFAMethod.query.filter_by(user=self.get_admin()).all()), admin_methods) - - def test_admin_disable(self): - for method in MFAMethod.query.filter_by(user=self.get_admin()).all(): - if not isinstance(method, RecoveryCodeMethod): - db.session.delete(method) - db.session.commit() - self.add_recovery_codes() - self.add_totp() - self.login_as('admin') - self.assertIsNotNone(request.user) - admin_methods = len(MFAMethod.query.filter_by(user=self.get_admin()).all()) - r = self.client.get(path=url_for('mfa.admin_disable', id=self.get_user().id), follow_redirects=True) - dump('mfa_admin_disable', r) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(MFAMethod.query.filter_by(user=self.get_user()).all()), 0) - self.assertEqual(len(MFAMethod.query.filter_by(user=self.get_admin()).all()), admin_methods) - - def test_setup_recovery(self): - self.login_as('user') - self.assertEqual(len(RecoveryCodeMethod.query.filter_by(user=request.user).all()), 0) - r = self.client.post(path=url_for('mfa.setup_recovery'), follow_redirects=True) - dump('mfa_setup_recovery', r) - self.assertEqual(r.status_code, 200) - methods = RecoveryCodeMethod.query.filter_by(user=request.user).all() - self.assertNotEqual(len(methods), 0) - r = self.client.post(path=url_for('mfa.setup_recovery'), follow_redirects=True) - dump('mfa_setup_recovery_reset', r) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(RecoveryCodeMethod.query.filter_by(id=methods[0].id).all()), 0) - self.assertNotEqual(len(methods), 0) - - def test_setup_totp(self): - self.login_as('user') - self.add_recovery_codes() - r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True) - dump('mfa_setup_totp', r) - self.assertEqual(r.status_code, 200) - self.assertNotEqual(len(session.get('mfa_totp_key', '')), 0) - - def test_setup_totp_without_recovery(self): - self.login_as('user') - r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True) - dump('mfa_setup_totp_without_recovery', r) - self.assertEqual(r.status_code, 200) - - def test_setup_totp_finish(self): - baserole = Role(name='baserole', is_default=True) - db.session.add(baserole) - baserole.groups[self.get_access_group()] = RoleGroup() - db.session.commit() - self.login_as('user') - self.add_recovery_codes() - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) - r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True) - method = TOTPMethod(request.user, key=session.get('mfa_totp_key', '')) - code = _hotp(int(time.time()/30), method.raw_key) - r = self.client.post(path=url_for('mfa.setup_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True) - dump('mfa_setup_totp_finish', r) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 1) - - def test_setup_totp_finish_without_recovery(self): - self.login_as('user') - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) - r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True) - method = TOTPMethod(request.user, key=session.get('mfa_totp_key', '')) - code = _hotp(int(time.time()/30), method.raw_key) - r = self.client.post(path=url_for('mfa.setup_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True) - dump('mfa_setup_totp_finish_without_recovery', r) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) - - def test_setup_totp_finish_wrong_code(self): - self.login_as('user') - self.add_recovery_codes() - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) - r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True) - method = TOTPMethod(request.user, key=session.get('mfa_totp_key', '')) - code = _hotp(int(time.time()/30), method.raw_key) - code = str(int(code[0])+1)[-1] + code[1:] - r = self.client.post(path=url_for('mfa.setup_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True) - dump('mfa_setup_totp_finish_wrong_code', r) - self.assertEqual(r.status_code, 200) - db_flush() - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) - - def test_setup_totp_finish_empty_code(self): - self.login_as('user') - self.add_recovery_codes() - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) - r = self.client.get(path=url_for('mfa.setup_totp', name='My TOTP Authenticator'), follow_redirects=True) - r = self.client.post(path=url_for('mfa.setup_totp_finish', name='My TOTP Authenticator'), data={'code': ''}, follow_redirects=True) - dump('mfa_setup_totp_finish_empty_code', r) - self.assertEqual(r.status_code, 200) - db_flush() - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) - - def test_delete_totp(self): - baserole = Role(name='baserole', is_default=True) - db.session.add(baserole) - baserole.groups[self.get_access_group()] = RoleGroup() - db.session.commit() - self.login_as('user') - self.add_recovery_codes() - self.add_totp() - method = TOTPMethod(request.user, name='test') - db.session.add(method) - db.session.commit() - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 2) - r = self.client.get(path=url_for('mfa.delete_totp', id=method.id), follow_redirects=True) - dump('mfa_delete_totp', r) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(TOTPMethod.query.filter_by(id=method.id).all()), 0) - self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 1) - - # TODO: webauthn setup tests - - def test_auth_integration(self): - self.add_recovery_codes() - self.add_totp() - db.session.commit() - self.assertIsNone(request.user) - r = self.login_as('user') - dump('mfa_auth_redirected', r) - self.assertEqual(r.status_code, 200) - self.assertIn(b'/mfa/auth', r.data) - self.assertIsNone(request.user) - r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False) - dump('mfa_auth', r) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - - def test_auth_disabled(self): - self.assertIsNone(request.user) - self.login_as('user') - r = self.client.get(path=url_for('mfa.auth', ref='/redirecttarget'), follow_redirects=False) - self.assertEqual(r.status_code, 302) - self.assertTrue(r.location.endswith('/redirecttarget')) - self.assertIsNotNone(request.user) - - def test_auth_recovery_only(self): - self.add_recovery_codes() - self.assertIsNone(request.user) - self.login_as('user') - r = self.client.get(path=url_for('mfa.auth', ref='/redirecttarget'), follow_redirects=False) - self.assertEqual(r.status_code, 302) - self.assertTrue(r.location.endswith('/redirecttarget')) - self.assertIsNotNone(request.user) - - def test_auth_recovery_code(self): - self.add_recovery_codes() - self.add_totp() - method = RecoveryCodeMethod(user=self.get_user()) - db.session.add(method) - db.session.commit() - method_id = method.id - self.login_as('user') - r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False) - dump('mfa_auth_recovery_code', r) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - r = self.client.post(path=url_for('mfa.auth_finish', ref='/redirecttarget'), data={'code': method.code}) - self.assertEqual(r.status_code, 302) - self.assertTrue(r.location.endswith('/redirecttarget')) - self.assertIsNotNone(request.user) - self.assertEqual(len(RecoveryCodeMethod.query.filter_by(id=method_id).all()), 0) - - def test_auth_totp_code(self): - self.add_recovery_codes() - self.add_totp() - method = TOTPMethod(user=self.get_user(), name='testname') - raw_key = method.raw_key - db.session.add(method) - db.session.commit() - self.login_as('user') - r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False) - dump('mfa_auth_totp_code', r) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - code = _hotp(int(time.time()/30), raw_key) - r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': code}, follow_redirects=True) - dump('mfa_auth_totp_code_submit', r) - self.assertEqual(r.status_code, 200) - self.assertIsNotNone(request.user) - - def test_auth_empty_code(self): - self.add_recovery_codes() - self.add_totp() - self.login_as('user') - r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': ''}, follow_redirects=True) - dump('mfa_auth_empty_code', r) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - - def test_auth_invalid_code(self): - self.add_recovery_codes() - self.add_totp() - method = TOTPMethod(user=self.get_user(), name='testname') - raw_key = method.raw_key - db.session.add(method) - db.session.commit() - self.login_as('user') - r = self.client.get(path=url_for('mfa.auth'), follow_redirects=False) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - code = _hotp(int(time.time()/30), raw_key) - code = str(int(code[0])+1)[-1] + code[1:] - r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': code}, follow_redirects=True) - dump('mfa_auth_invalid_code', r) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - - def test_auth_ratelimit(self): - self.add_recovery_codes() - self.add_totp() - method = TOTPMethod(user=self.get_user(), name='testname') - raw_key = method.raw_key - db.session.add(method) - db.session.commit() - self.login_as('user') - self.assertIsNone(request.user) - code = _hotp(int(time.time()/30), raw_key) - inv_code = str(int(code[0])+1)[-1] + code[1:] - for i in range(20): - r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': inv_code}, follow_redirects=True) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - r = self.client.post(path=url_for('mfa.auth_finish'), data={'code': code}, follow_redirects=True) - dump('mfa_auth_ratelimit', r) - self.assertEqual(r.status_code, 200) - self.assertIsNone(request.user) - - # TODO: webauthn auth tests diff --git a/tests/views/test_oauth2.py b/tests/views/test_oauth2.py index 6d26baf6b5f1f910df17cbe9a04a3011eae1a169..8efd0a84fea789cf069665158ce0c875ecc6b274 100644 --- a/tests/views/test_oauth2.py +++ b/tests/views/test_oauth2.py @@ -1,13 +1,16 @@ +import unittest from urllib.parse import urlparse, parse_qs +import jwt from flask import url_for, session from uffd.database import db from uffd.password_hash import PlaintextPasswordHash from uffd.remailer import remailer -from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, RemailerMode +from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, RemailerMode, OAuth2Key, Session from tests.utils import dump, UffdTestCase +from tests.models.test_oauth2 import TEST_JWK class TestViews(UffdTestCase): def setUpDB(self): @@ -69,6 +72,9 @@ class TestViews(UffdTestCase): self.assertTrue(oauth2_client.client_secret.verify('testsecret')) def test_authorization_without_redirect_uri(self): + client = OAuth2Client.query.filter_by(client_id='test').one() + client.redirect_uris.remove('http://localhost:5009/callback2') + db.session.commit() self.login_as('user') r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', scope='profile'), follow_redirects=False) self.assert_authorization(r) @@ -84,6 +90,15 @@ class TestViews(UffdTestCase): r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', scope='', redirect_uri='http://localhost:5009/callback'), follow_redirects=False) self.assert_authorization(r) + def test_authorization_access_denied(self): + client = OAuth2Client.query.filter_by(client_id='test').one() + client.service.limit_access = True + db.session.commit() + self.login_as('user') + r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False) + self.assertEqual(r.status_code, 403) + dump('oauth2_authorization_access_denied', r) + def test_authorization_invalid_scope(self): self.login_as('user') r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='invalid'), follow_redirects=False) @@ -128,7 +143,7 @@ class TestViews(UffdTestCase): with self.client.session_transaction() as _session: initiation = OAuth2DeviceLoginInitiation(client=OAuth2Client.query.filter_by(client_id='test').one()) db.session.add(initiation) - confirmation = DeviceLoginConfirmation(initiation=initiation, user=self.get_user()) + confirmation = DeviceLoginConfirmation(initiation=initiation, session=Session(user=self.get_user())) db.session.add(confirmation) db.session.commit() _session['devicelogin_id'] = initiation.id @@ -143,7 +158,7 @@ class TestViews(UffdTestCase): with self.client.session_transaction() as _session: initiation = OAuth2DeviceLoginInitiation(client=OAuth2Client.query.filter_by(client_id='test').one()) db.session.add(initiation) - confirmation = DeviceLoginConfirmation(initiation=initiation, user=self.get_user()) + confirmation = DeviceLoginConfirmation(initiation=initiation, session=Session(user=self.get_user())) db.session.add(confirmation) db.session.commit() _session['devicelogin_id'] = initiation.id @@ -190,20 +205,35 @@ class TestViews(UffdTestCase): def test_token_invalid_code(self): r = self.client.post(path=url_for('oauth2.token'), data={'grant_type': 'authorization_code', 'code': 'abcdef', 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True) - self.assertIn(r.status_code, [400, 401]) # oauthlib behaviour changed between v2.1.0 and v3.1.0 + self.assertEqual(r.status_code, 400) self.assertEqual(r.content_type, 'application/json') + self.assertEqual(r.json['error'], 'invalid_grant') + + def test_token_code_invalidation(self): + code = self.get_auth_code() + r = self.client.post(path=url_for('oauth2.token'), + data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback'}, + headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'}, follow_redirects=True) + self.assertEqual(r.status_code, 200) + r = self.client.post(path=url_for('oauth2.token'), + data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback'}, + headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'}, follow_redirects=True) + self.assertEqual(r.status_code, 400) + self.assertEqual(r.json['error'], 'invalid_grant') def test_token_invalid_client(self): r = self.client.post(path=url_for('oauth2.token'), data={'grant_type': 'authorization_code', 'code': self.get_auth_code(), 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'invalid_client', 'client_secret': 'invalid_client_secret'}, follow_redirects=True) self.assertEqual(r.status_code, 401) self.assertEqual(r.content_type, 'application/json') + self.assertEqual(r.json['error'], 'invalid_client') def test_token_unauthorized_client(self): r = self.client.post(path=url_for('oauth2.token'), data={'grant_type': 'authorization_code', 'code': self.get_auth_code(), 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test'}, follow_redirects=True) self.assertEqual(r.status_code, 401) self.assertEqual(r.content_type, 'application/json') + self.assertEqual(r.json['error'], 'invalid_client') def test_token_unsupported_grant_type(self): r = self.client.post(path=url_for('oauth2.token'), @@ -218,15 +248,16 @@ class TestViews(UffdTestCase): db.session.commit() r = self.client.post(path=url_for('oauth2.token'), data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True) - self.assertIn(r.status_code, [400, 401]) # oauthlib behaviour changed between v2.1.0 and v3.1.0 + self.assertEqual(r.status_code, 400) self.assertEqual(r.content_type, 'application/json') + self.assertEqual(r.json['error'], 'invalid_grant') def test_userinfo_invalid_access_token(self): token = 'invalidtoken' r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%token)], follow_redirects=True) self.assertEqual(r.status_code, 401) - def test_userinfo_invalid_access_token(self): + def test_userinfo_deactivated_user(self): r = self.client.post(path=url_for('oauth2.token'), data={'grant_type': 'authorization_code', 'code': self.get_auth_code(), 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True) token = r.json['access_token'] @@ -234,3 +265,680 @@ class TestViews(UffdTestCase): db.session.commit() r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%token)], follow_redirects=True) self.assertEqual(r.status_code, 401) + +class TestOIDCConfigurationProfile(UffdTestCase): + def setUpDB(self): + db.session.add(OAuth2Key(**TEST_JWK)) + + def test_discover_spec(self): + ISSUER = 'https://sso.example.com' + r = self.client.get(base_url=ISSUER, path='/.well-known/openid-configuration') + + # OIDC Discovery 1.0 section 4.2: + # > A successful response MUST use the 200 OK HTTP status code and return a + # > JSON object using the application/json content type that contains a set + # > of Claims as its members that are a subset of the Metadata values defined + # > in Section 3. + self.assertEqual(r.status_code, 200) + self.assertEqual(r.content_type, 'application/json') + self.assertIsInstance(r.json, dict) # also validates JSON syntax + + # OIDC Discovery 1.0 section 4.2: + # > Claims that return multiple values are represented as JSON arrays. + # > Claims with zero elements MUST be omitted from the response. + for key, value in r.json.items(): + if isinstance(value, list): + self.assertNotEqual(len(value), 0) + + # OIDC Discovery 1.0 section 3 (REQUIRED metadata values) + required_fields = {'issuer', 'authorization_endpoint', 'jwks_uri', 'response_types_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported'} + if 'code' in r.json.get('response_types_supported', []): + required_fields.add('token_endpoint') + if 'authorization_code' in r.json.get('grant_types_supported', ['authorization_code', 'implicit']): + required_fields.add('token_endpoint') + for field in required_fields: + self.assertIn(field, r.json) + + # OIDC Discovery 1.0 section 3 (metadata value types) + bool_fields = ('claims_parameter_supported', 'request_parameter_supported', 'request_uri_parameter_supported', 'require_request_uri_registration') + list_fields = ('scopes_supported', 'response_types_supported', 'response_modes_supported', 'grant_types_supported', 'acr_values_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported', 'id_token_encryption_alg_values_supported', 'id_token_encryption_enc_values_supported', 'userinfo_signing_alg_values_supported', 'userinfo_encryption_alg_values_supported', 'userinfo_encryption_enc_values_supported', 'request_object_signing_alg_values_supported', 'request_object_encryption_alg_values_supported', 'request_object_encryption_enc_values_supported', 'token_endpoint_auth_methods_supported', 'token_endpoint_auth_signing_alg_values_supported', 'display_values_supported', 'claim_types_supported', 'claims_supported', 'claims_locales_supported', 'ui_locales_supported') + https_url_fields = ('issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri', 'registration_endpoint') + url_fields = ('service_documentation', 'op_policy_uri', 'op_tos_uri') + for field in bool_fields: + if field in r.json: + self.assertIsInstance(r.json[field], bool) + for field in list_fields: + if field in r.json: + self.assertIsInstance(r.json[field], list) + for field in https_url_fields: + if field in r.json: + self.assertIsInstance(r.json[field], str) + self.assertTrue(r.json[field].lower().startswith('https://')) + for field in url_fields: + if field in r.json: + self.assertIsInstance(r.json[field], str) + self.assertTrue(r.json[field].lower().startswith('http')) + + # OIDC Discovery 1.0 section 3 (MUSTs on metadata values except https scheme and jwks_uri) + self.assertEqual(r.json['issuer'], ISSUER) + if 'scopes_supported' in r.json: + self.assertIsInstance(r.json['scopes_supported'], list) + for item in r.json['scopes_supported']: + self.assertIsInstance(item, str) + self.assertRegex(item, r'^[!#-\[\]-~]+$') # 1*( %x21 / %x23-5B / %x5D-7E ) + self.assertIn('openid', r.json['scopes_supported']) + self.assertIn('RS256', r.json['id_token_signing_alg_values_supported']) + if 'token_endpoint_auth_signing_alg_values_supported' in r.json: + self.assertNotIn('none', r.json['token_endpoint_auth_signing_alg_values_supported']) + + # OIDC Discovery 1.0 section 3 (jwks_uri) and RFC7517 + self.assertTrue(r.json['jwks_uri'].startswith(ISSUER)) # Not a requirement by spec, but technically neccessary for this test + r_jwks = self.client.get(base_url=ISSUER, path=r.json['jwks_uri'][len(ISSUER):]) + self.assertEqual(r_jwks.status_code, 200) + # The jwks_uri SHOULD include a Cache-Control header in the response that contains a max-age directive ... + self.assertIn('Cache-Control', r_jwks.headers) + self.assertIsInstance(r_jwks.json, dict) # also validates JSON syntax + self.assertIn('keys', r_jwks.json) + self.assertIsInstance(r_jwks.json['keys'], list) + has_sign_keys = False + has_encrypt_keys = False + kids = set() + for key in r_jwks.json['keys']: + self.assertIn('kty', key) + self.assertIsInstance(key['kty'], str) + if 'use' in key: + self.assertIsInstance(key['use'], str) + if key['use'] == 'sig': + has_sign_keys = True + if key['use'] == 'enc': + has_enc_keys = True + if 'key_ops' in key: + self.assertIsInstance(key['key_ops'], list) + self.assertNotIn('use', key) # SHOULD + for value in key['key_ops']: + self.assertIsInstance(value, str) + self.assertEqual(key['key_ops'].count(value), 1) + # OIDC: "The JWK Set MUST NOT contain private or symmetric key values." + self.assertNotIn('decrypt', key['key_ops']) + self.assertNotIn('sign', key['key_ops']) + if 'verify' in key['key_ops']: + has_sign_keys = True + if 'encrypt' in key['key_ops']: + has_enc_keys = True + if 'alg' in key: + self.assertIsInstance(key['alg'], str) + if 'kid' in key: + self.assertIsInstance(key['kid'], str) + self.assertNotIn(key['kid'], kids) # SHOULD + kids.add(key['kid']) + # ignoring everything X.509 related + # TODO: Validate algorithm-specific part of JWK + if has_sign_keys and has_encrypt_keys: + for key in r_jwks.json['keys']: + self.assertIn('use', key) + +class TestOIDCBasicProfile(UffdTestCase): + def setUpDB(self): + db.session.add(OAuth2Key(**TEST_JWK)) + db.session.add(OAuth2Client(service=Service(name='test', limit_access=False), client_id='test', client_secret='testsecret', redirect_uris=['https://service/callback'])) + + # Helper + def validate_claim_syntax(self, name, value): + # Strip language tag + if '#' in name: + name = name.split('#')[0] + str_claims = ('sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname', 'preferred_username', 'profile', 'picture', 'website', 'email', 'gender', 'birthdate', 'zoneinfo', 'locale', 'phone_number', 'acr') + if name in str_claims: + self.assertIsInstance(value, str) + if name in ('profile', 'picture', 'website'): + self.assertTrue(value.lower().startswith('http')) + if name in ('email_verified', 'phone_number_verified'): + self.assertIsInstance(value, bool) + if name in ('updated_at', 'auth_time'): + self.assertTrue(isinstance(value, int) or isinstance(value, float)) + if name == 'address': + self.assertIsInstance(value, dict) + if name == 'amr': + self.assertIsInstance(value, list) + for item in value: + self.assertIsInstance(item, str) + + def validate_id_token(self, id_token, nonce='testnonce', client_id='test'): + headers = jwt.get_unverified_header(id_token) + self.assertIn('kid', headers) + self.assertIsInstance(headers['kid'], str) + # This checks signature and exp + data = OAuth2Key.decode_jwt(id_token, options={'verify_aud': False}) + self.assertIn('iss', data) + self.assertIsInstance(data['iss'], str) + self.assertIn('sub', data) + self.assertIn('aud', data) + self.assertIsInstance(data['aud'], str) + if client_id is not None: + self.assertEqual(data['aud'], client_id) + self.assertIn('iat', data) + self.assertTrue(isinstance(data['iat'], int) or isinstance(data['iat'], float)) + if 'nonce' in data: + self.assertIsInstance(data['nonce'], str) + self.assertEqual(data.get('nonce'), nonce) + if 'azp' in data: + self.assertIsInstance(data['azp'], str) + for name, value in data.items(): + self.validate_claim_syntax(name, value) + return data + + def is_login_page(self, location): + url = urlparse(location) + return url.netloc in ('localhost', '') and url.path == url_for('session.login') + + def is_callback(self, location): + return location.startswith('https://service/callback') + + def do_auth_request(self, client_id='test', state='teststate', nonce='testnonce', redirect_uri='https://service/callback', scope='openid', follow_redirects=True, **kwargs): + r = self.client.get(path=url_for('oauth2.authorize', client_id=client_id, state=state, nonce=nonce, redirect_uri=redirect_uri, scope=scope, **kwargs), follow_redirects=False) + while follow_redirects and r.status_code == 302 and not self.is_login_page(r.location) and not self.is_callback(r.location): + r = self.client.get(path=r.location, follow_redirects=False) + return r + + def do_login(self, r, loginname='testuser', password='userpassword'): + self.assertEqual(r.status_code, 302) + self.assertTrue(self.is_login_page(r.location)) + self.client.get(path=url_for('session.logout'), follow_redirects=True) + args = parse_qs(urlparse(r.location).query) + r = self.client.post(path=url_for('session.login', ref=args['ref'][0]), data={'loginname': loginname, 'password': password}, follow_redirects=False) + while r.status_code == 302 and not self.is_login_page(r.location) and not self.is_callback(r.location): + r = self.client.get(path=r.location, follow_redirects=False) + return r + + def validate_auth_response(self, r, state='teststate'): + self.assertEqual(r.status_code, 302) + self.assertTrue(self.is_callback(r.location)) + args = parse_qs(urlparse(r.location).query) + for key in args: + self.assertNotIn(key, ('error', 'error_description', 'error_uri')) + self.assertEqual(len(args[key]), 1) # Not generally specified, but still a good check + if state is not None: + self.assertIn('state', args) + self.assertEqual(args['state'], [state]) + else: + self.assertNotIn('state', args) + return {key: values[0] for key, values in args.items()} + + def assert_auth_error(self, r, *errors, state='teststate'): + self.assertEqual(r.status_code, 302) + self.assertTrue(self.is_callback(r.location)) + args = parse_qs(urlparse(r.location).query) + for key in args: + self.assertIn(key, ('error', 'error_description', 'error_uri', 'state')) + self.assertEqual(len(args[key]), 1) + self.assertIn('error', args) + if state is not None: + self.assertIn('state', args) + self.assertEqual(args['state'][0], state) + else: + self.assertNotIn('state', args) + if errors: + self.assertIn(args['error'][0], errors) + self.assertRegex(args['error'][0], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E ) + if 'error_description' in args: + self.assertRegex(args['error_description'][0], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E ) + if 'error_uri' in args: + self.assertRegex(args['error_uri'][0], r'^[!#-\[\]-~]+$') # 1*( %x21 / %x23-5B / %x5D-7E ) + + def do_token_request(self, client_id='test', client_secret='testsecret', redirect_uri='https://service/callback', **kwargs): + data = {'redirect_uri': redirect_uri, 'client_id': client_id, 'client_secret': client_secret} + data.update(kwargs) + return self.client.post(path=url_for('oauth2.token'), data=data, follow_redirects=True) + + def validate_token_response(self, r, nonce='testnonce', client_id='test'): + self.assertEqual(r.status_code, 200) + self.assertEqual(r.content_type, 'application/json') + self.assertIn('Cache-Control', r.headers) + self.assertEqual(r.headers['Cache-Control'].lower(), 'no-store') + for key in r.json: + self.assertNotIn(key, ('error', 'error_description', 'error_uri')) + self.assertIn('access_token', r.json) + self.assertIsInstance(r.json['access_token'], str) + self.assertIn('token_type', r.json) + self.assertIsInstance(r.json['token_type'], str) + # OIDC Core 1.0 section 3.1.3.3: + # > The OAuth 2.0 token_type response parameter value MUST be Bearer, + # > [...] unless another Token Type has been negotiated with the Client. + self.assertEqual(r.json['token_type'].lower(), 'bearer') + if 'expires_in' in r.json: + self.assertTrue(isinstance(r.json['expires_in'], int) or isinstance(data['expires_in'], float)) + if 'refresh_token' in r.json: + self.assertIsInstance(r.json['refresh_token'], str) + if 'scope' in r.json: + self.assertIsInstance(r.json['scope'], str) + # scope = scope-token *( SP scope-token ) + # scope-token = 1*( %x21 / %x23-5B / %x5D-7E ) + self.assertRegex(r.json['scope'], r'^[!#-\[\]-~]+( [!#-\[\]-~]+)*$') + # OIDC Core 1.0 section 3.1.3.3: + # > All Token Responses that contain tokens, secrets, or other sensitive + # > information MUST include the following HTTP response header fields and values: + # > Cache-Control: no-store + self.assertIn('id_token', r.json) + return self.validate_id_token(r.json['id_token'], nonce=nonce, client_id=client_id) + + def assert_token_error(self, r, *errors): + self.assertEqual(r.content_type, 'application/json') + if r.json.get('error', '') == 'invalid_client': + self.assertEqual(r.status_code, 401) + else: + self.assertEqual(r.status_code, 400) + for key in r.json: + self.assertIn(key, ('error', 'error_description', 'error_uri')) + self.assertIn('error', r.json) + if errors: + self.assertIn(r.json['error'], errors) + self.assertRegex(r.json['error'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E ) + if 'error_description' in r.json: + self.assertRegex(r.json['error_description'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E ) + if 'error_uri' in r.json: + self.assertRegex(r.json['error_uri'], r'^[!#-\[\]-~]+$') # 1*( %x21 / %x23-5B / %x5D-7E ) + + def do_userinfo_request(self, access_token): + return self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%access_token)], follow_redirects=True) + + def validate_userinfo_response(self, r): + self.assertEqual(r.status_code, 200) + # We ignore everything related to encrypted/signed JWT userinfo here + self.assertEqual(r.content_type, 'application/json') + self.assertIn('sub', r.json) + for name, value in r.json.items(): + self.validate_claim_syntax(name, value) + + def assert_userinfo_error(self, r): + self.assertEqual(r.status_code, 401) + self.assertEqual(len(r.headers.getlist('WWW-Authenticate')), 1) + method, args = (r.headers['WWW-Authenticate'].split(' ', 1) + [''])[:2] + args = {item.split('=', 1)[0]: item.split('=', 1)[1].strip(' \n"') for item in args.split(',') if item.strip()} + if 'scope' in args: + self.assertRegex(args['scope'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E ) + if 'error' in args: + self.assertRegex(args['error'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E ) + if 'error_description' in args: + self.assertRegex(args['error_description'], r'^[ -!#-\[\]-~]+$') # 1*( %x20-x21 / %x23-5B / %x5D-7E ) + + def test(self): + self.login_as('user') + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + id_token = self.validate_token_response(r) + self.assertEqual(id_token['sub'], '10000') + r = self.do_userinfo_request(r.json['access_token']) + self.validate_userinfo_response(r) + self.assertEqual(r.json['sub'], '10000') + + def test_notloggedin(self): + r = self.do_auth_request(response_type='code') + r = self.do_login(r) + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + id_token = self.validate_token_response(r) + self.assertEqual(id_token['sub'], '10000') + + def test_no_state(self): + self.login_as('user') + r = self.do_auth_request(response_type='code', state=None) + args = self.validate_auth_response(r, state=None) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + id_token = self.validate_token_response(r) + self.assertEqual(id_token['sub'], '10000') + + def test_no_nonce(self): + self.login_as('user') + r = self.do_auth_request(response_type='code', nonce=None) + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + id_token = self.validate_token_response(r, nonce=None) + self.assertEqual(id_token['sub'], '10000') + + def test_redirect_uri(self): + self.login_as('user') + # No redirect_uri in auth request is fine if there is only one uri registered + r = self.do_auth_request(response_type='code', redirect_uri=None) + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], redirect_uri=None) + id_token = self.validate_token_response(r) + self.assertEqual(id_token['sub'], '10000') + # If auth request has redirect_uri, it is required in token request + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], redirect_uri=None) + self.assert_token_error(r) + # If auth request has redirect_uri, it the redirect_uri in the token request must be the same + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], redirect_uri='https://foobar/callback') + self.assert_token_error(r) + # Invalid redirect_uri + r = self.do_auth_request(response_type='code', redirect_uri='http://foobar/callback') + self.assertEqual(r.status_code, 400) # No redirect! + # redirect_uri is required in auth request if there is more than a single uri registered + client = OAuth2Client.query.one() + client.redirect_uris.append('https://service/callback2') + db.session.commit() + r = self.do_auth_request(response_type='code', redirect_uri=None) + self.assertEqual(r.status_code, 400) # No redirect! + + def test_auth_errors(self): + # Missing response_type + r = self.do_auth_request() + self.assert_auth_error(r, 'invalid_request') + # Invalid response_type + r = self.do_auth_request(response_type='foobar') + self.assert_auth_error(r, 'unsupported_response_type') + # Missing client_id + r = self.do_auth_request(response_type='code', client_id=None) + self.assertEqual(r.status_code, 400) # No redirect! + # Invalid client_id + r = self.do_auth_request(response_type='code', client_id='foobar') + self.assertEqual(r.status_code, 400) # No redirect! + # Duplicate parameter + r = self.do_auth_request(response_type='code', client_id=['test', 'foobar']) + self.assertEqual(r.status_code, 400) # No redirect! + + def test_access_denied(self): + Service.query.one().limit_access = True + db.session.commit() + self.login_as('user') + r = self.do_auth_request(response_type='code') + self.assert_auth_error(r, 'access_denied') + + def test_auth_request_uri(self): + self.login_as('user') + r = self.do_auth_request(response_type='code', request_uri='https://localhost/myrequest_uri') + self.assert_auth_error(r, 'request_uri_not_supported') + + def test_auth_request_unsigned(self): + self.login_as('user') + request_params = { + 'response_type': 'code', + 'client_id': 'test', + 'redirect_uri': 'http://service/callback', + 'scope': 'openid', + 'state': 'teststate', + 'nonce': 'testnonce', + 'claims': { + 'userinfo': { + 'name': None, + 'email': {'essential': True}, + 'email_verified': {'essential': True}, + }, + 'id_token': { + 'email': None, + } + } + } + r = self.do_auth_request(response_type='code', request=jwt.encode(request_params, algorithm='none', key=None)) + self.assert_auth_error(r, 'request_not_supported') + + def test_token_client_auth(self): + self.login_as('user') + # Auth via body -> ACCEPT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + self.validate_token_response(r) + # Auth via header -> ACCEPT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.client.post( + path=url_for('oauth2.token'), + data={'redirect_uri': 'https://service/callback', 'grant_type': 'authorization_code', 'code': args['code']}, + headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'}, + follow_redirects=True, + ) + self.validate_token_response(r) + # Auth via header, but same client id also in body -> ACCEPT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.client.post( + path=url_for('oauth2.token'), + data={'redirect_uri': 'https://service/callback', 'grant_type': 'authorization_code', 'client_id': 'test', 'code': args['code']}, + headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'}, + follow_redirects=True, + ) + self.validate_token_response(r) + # Different client id in body and header -> REJECT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.client.post( + path=url_for('oauth2.token'), + data={'redirect_uri': 'https://service/callback', 'grant_type': 'authorization_code', 'client_id': 'XXXX', 'code': args['code']}, + headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'}, + follow_redirects=True, + ) + self.assert_token_error(r, 'invalid_request') + # Duplicate client id in body -> REJECT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id=['test', 'XXXX']) + self.assert_token_error(r, 'invalid_request') + # Duplicate client secret in body -> REJECT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_secret=['testsecret', 'XXXXX']) + self.assert_token_error(r, 'invalid_request') + # Client secret in body and header -> REJECT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.client.post( + path=url_for('oauth2.token'), + data={'redirect_uri': 'https://service/callback', 'grant_type': 'authorization_code', 'client_id': 'test', 'client_secret': 'testsecret', 'code': args['code']}, + headers={'Authorization': f'Basic dGVzdDp0ZXN0c2VjcmV0'}, + follow_redirects=True, + ) + self.assert_token_error(r, 'invalid_request') + # No secret -> REJECT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_secret=None) + self.assert_token_error(r, 'invalid_client') + # No client id but secret -> REJECT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id=None) + self.assert_token_error(r, 'invalid_client') + # No client id and no secret -> REJECT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id=None, client_secret=None) + self.assert_token_error(r, 'invalid_client') + # Unknown client id -> REJECT + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id='XXXX') + self.assert_token_error(r, 'invalid_client') + + def test_token_errors(self): + self.login_as('user') + # Missing grant_type parameter + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(code=args['code']) + self.assert_token_error(r, 'invalid_request') + # Missing code parameter + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code') + self.assert_token_error(r, 'invalid_request') + # redirect_uri behaviour is already tested in test_redirect_uri + # Invalid grant type + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='foobar', code=args['code']) + self.assert_token_error(r, 'unsupported_grant_type') + # Duplicate code parameter + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=[args['code'], 'XXXXX']) + self.assert_token_error(r, 'invalid_request') + # Invalid code parameter + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code='XXXXX') + self.assert_token_error(r, 'invalid_grant') + # Invalid code parameter + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'].split('-')[0]+'-XXXXX') + self.assert_token_error(r, 'invalid_grant') + # Code was issued to different client + db.session.add(OAuth2Client(service=Service(name='test2', limit_access=False), client_id='test2', client_secret='testsecret2', redirect_uris=['https://service2/callback'])) + db.session.commit() + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code'], client_id='test2', client_secret='testsecret2') + self.assert_token_error(r, 'invalid_grant') + + def test_userinfo_auth(self): + self.login_as('user') + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + self.validate_token_response(r) + access_token = r.json['access_token'] + # GET + Bearer + r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%access_token)], follow_redirects=True) + self.validate_userinfo_response(r) + self.assertEqual(r.json['sub'], '10000') + # POST + Bearer + r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%access_token)], follow_redirects=True) + self.validate_userinfo_response(r) + self.assertEqual(r.json['sub'], '10000') + # "Bearer" is case-insensitive + r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('authorization', 'bearer %s'%access_token)], follow_redirects=True) + self.validate_userinfo_response(r) + self.assertEqual(r.json['sub'], '10000') + # Invalid auth scheme + r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Basic dGVzdDp0ZXN0c2VjcmV0')], follow_redirects=True) + self.assert_userinfo_error(r) + # Invalid bearer token + r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s-XXXXX'%access_token.split('-')[0])], follow_redirects=True) + self.assert_userinfo_error(r) + r = self.client.post(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer XXXXX')], follow_redirects=True) + self.assert_userinfo_error(r) + # POST + body + r = self.client.post(path=url_for('oauth2.userinfo'), data={'access_token': access_token}, follow_redirects=True) + self.validate_userinfo_response(r) + self.assertEqual(r.json['sub'], '10000') + # GET + query + r = self.client.get(path=url_for('oauth2.userinfo', access_token=access_token), follow_redirects=True) + self.validate_userinfo_response(r) + self.assertEqual(r.json['sub'], '10000') + # POST + Bearer + body -> REJECT + r = self.client.post(path=url_for('oauth2.userinfo'), data={'access_token': access_token}, headers=[('Authorization', 'Bearer %s'%access_token)], follow_redirects=True) + self.assert_userinfo_error(r) + # No auth -> REJECT + r = self.client.post(path=url_for('oauth2.userinfo'), follow_redirects=True) + self.assert_userinfo_error(r) + + def test_scope(self): + self.login_as('user') + # Scope values used that are not understood by an implementation SHOULD be ignored. + r = self.do_auth_request(response_type='code', scope='openid profile email address phone groups foobar') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + id_token = self.validate_token_response(r) + self.assertEqual(id_token['sub'], '10000') + r = self.do_userinfo_request(r.json['access_token']) + self.validate_userinfo_response(r) + self.assertEqual(r.json['sub'], '10000') + self.assertEqual(r.json['name'], 'Test User') + self.assertEqual(r.json['preferred_username'], 'testuser') + self.assertEqual(r.json['email'], 'test@example.com') + self.assertEqual(sorted(r.json['groups']), sorted(['users', 'uffd_access'])) + + def test_claims(self): + self.login_as('user') + # Scope values used that are not understood by an implementation SHOULD be ignored. + r = self.do_auth_request(response_type='code', claims='{"userinfo": {"name": {"essential": true}}, "id_token": {"preferred_username": {"essential": true}, "email": null}}') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + id_token = self.validate_token_response(r) + self.assertEqual(id_token['sub'], '10000') + self.assertEqual(id_token['preferred_username'], 'testuser') + self.assertEqual(id_token['email'], 'test@example.com') + self.assertNotIn('name', r.json) + r = self.do_userinfo_request(r.json['access_token']) + self.validate_userinfo_response(r) + self.assertEqual(r.json['sub'], '10000') + self.assertEqual(r.json['name'], 'Test User') + self.assertNotIn('email', r.json) + + def test_prompt_none(self): + r = self.do_auth_request(response_type='code', prompt='none') + self.assert_auth_error(r, 'login_required') + self.login_as('user') + r = self.do_auth_request(response_type='code', prompt='none') + args = self.validate_auth_response(r) + self.assertIn('code', args) + # OIDC Core 1.0 section 3.1.2.1.: + # > If this parameter contains none with any other value, an error is returned. + r = self.do_auth_request(response_type='code', prompt='none login') + self.assert_auth_error(r) + + @unittest.skip('prompt=login is not implemented') # MUST + def test_prompt_login(self): + self.login_as('user') + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + self.assertIn('code', args) + r = self.do_auth_request(response_type='code', prompt='login') + self.assertEqual(r.status_code, 302) + self.assertTrue(self.is_login_page(r.location)) + + # TODO: max_age + + def test_sub_value(self): + # Via id_token_hint or claims.id_token.sub.value + self.login_as('user') + r = self.do_auth_request(response_type='code', prompt='none') + args = self.validate_auth_response(r) + r = self.do_token_request(grant_type='authorization_code', code=args['code']) + self.validate_token_response(r) + id_token = r.json['id_token'] + r = self.do_auth_request(response_type='code', prompt='none', id_token_hint=id_token) + args = self.validate_auth_response(r) + self.assertIn('code', args) + r = self.do_auth_request(response_type='code', prompt='none', id_token_hint='XXXXX') + self.assert_auth_error(r, 'invalid_request') + r = self.do_auth_request(response_type='code', prompt='none', claims='{"id_token": {"sub": {"value": "10000"}}}') + args = self.validate_auth_response(r) + r = self.do_auth_request(response_type='code', prompt='none', claims='{"id_token": {"sub": {"value": "10001"}}}') + self.assert_auth_error(r, 'login_required') + # sub value in id_token_hint and claims is the same -> Not ambiguous + r = self.do_auth_request(response_type='code', prompt='none', id_token_hint=id_token, claims='{"id_token": {"sub": {"value": "10000"}}}') + args = self.validate_auth_response(r) + self.assertIn('code', args) + # sub value in id_token_hint and claims differs -> Ambiguous + r = self.do_auth_request(response_type='code', prompt='none', id_token_hint=id_token, claims='{"id_token": {"sub": {"value": "10001"}}}') + self.assert_auth_error(r, 'invalid_request') + self.login_as('admin') + r = self.do_auth_request(response_type='code', prompt='none', id_token_hint=id_token) + self.assert_auth_error(r, 'login_required') + + def test_code_reuse(self): + self.login_as('user') + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r1 = self.do_token_request(grant_type='authorization_code', code=args['code']) + self.validate_token_response(r1) + r2 = self.do_token_request(grant_type='authorization_code', code=args['code']) + self.assert_token_error(r2, 'invalid_grant') + + @unittest.skip('Token revoking on reuse is not implemented') # SHOULD + def test_code_reuse_revoke(self): + self.login_as('user') + r = self.do_auth_request(response_type='code') + args = self.validate_auth_response(r) + r1 = self.do_token_request(grant_type='authorization_code', code=args['code']) + self.validate_token_response(r1) + r2 = self.do_token_request(grant_type='authorization_code', code=args['code']) + self.assert_token_error(r2, 'invalid_grant') + r = self.do_userinfo_request(r1.json['access_token']) + self.assert_userinfo_error(r) diff --git a/tests/views/test_selfservice.py b/tests/views/test_selfservice.py index b5b202414faa3ba2be6825e21e74574c5b2544ee..eb7430d225fb5a204c07f5c3ba3093f4c7c92fa6 100644 --- a/tests/views/test_selfservice.py +++ b/tests/views/test_selfservice.py @@ -1,12 +1,14 @@ import datetime import re +import time -from flask import url_for, request +from flask import url_for, request, session from uffd.database import db -from uffd.models import PasswordToken, UserEmail, Role, RoleGroup, Service, ServiceUser, FeatureFlag +from uffd.models import PasswordToken, UserEmail, Role, RoleGroup, Service, ServiceUser, FeatureFlag, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod +from uffd.models.mfa import _hotp -from tests.utils import dump, UffdTestCase +from tests.utils import dump, UffdTestCase, db_flush class TestSelfservice(UffdTestCase): def test_index(self): @@ -21,25 +23,23 @@ class TestSelfservice(UffdTestCase): def test_update_displayname(self): self.login_as('user') - user = request.user r = self.client.post(path=url_for('selfservice.update_profile'), data={'displayname': 'New Display Name'}, follow_redirects=True) dump('update_displayname', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertEqual(_user.displayname, 'New Display Name') + user = self.get_user() + self.assertEqual(user.displayname, 'New Display Name') def test_update_displayname_invalid(self): self.login_as('user') - user = request.user r = self.client.post(path=url_for('selfservice.update_profile'), data={'displayname': ''}, follow_redirects=True) dump('update_displayname_invalid', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertNotEqual(_user.displayname, '') + user = self.get_user() + self.assertNotEqual(user.displayname, '') def test_add_email(self): self.login_as('user') @@ -52,7 +52,7 @@ class TestSelfservice(UffdTestCase): m = re.search(r'/email/([0-9]+)/verify/(.*)', str(self.app.last_mail.get_content())) email_id, secret = m.groups() email = UserEmail.query.get(email_id) - self.assertEqual(email.user, request.user) + self.assertEqual(email.user.id, request.user.id) self.assertEqual(email.address, 'new@example.com') self.assertFalse(email.verified) self.assertFalse(email.verification_expired) @@ -164,7 +164,7 @@ class TestSelfservice(UffdTestCase): m = re.search(r'/email/([0-9]+)/verify/(.*)', str(self.app.last_mail.get_content())) email_id, secret = m.groups() email = UserEmail.query.get(email_id) - self.assertEqual(email.user, request.user) + self.assertEqual(email.user.id, request.user.id) self.assertEqual(email.address, 'new@example.com') self.assertFalse(email.verified) self.assertFalse(email.verification_expired) @@ -245,17 +245,20 @@ class TestSelfservice(UffdTestCase): r = self.client.post(path=url_for('selfservice.update_email_preferences'), data={'primary_email': str(email_id), 'recovery_email': 'primary'}, follow_redirects=True) - self.assertEqual(self.get_user().primary_email.address, 'test@example.com') + with self.app.test_request_context(): + self.assertEqual(self.get_user().primary_email.address, 'test@example.com') with self.assertRaises(Exception): r = self.client.post(path=url_for('selfservice.update_email_preferences'), data={'primary_email': str(old_email_id), 'recovery_email': str(email_id)}, follow_redirects=True) - self.assertIsNone(self.get_user().recovery_email) + with self.app.test_request_context(): + self.assertIsNone(self.get_user().recovery_email) with self.assertRaises(Exception): r = self.client.post(path=url_for('selfservice.update_email_preferences'), data={'primary_email': str(old_email_id), 'recovery_email': 'primary', f'service_{service_id}_email': str(email_id)}, follow_redirects=True) - self.assertIsNone(ServiceUser.query.get((service_id, user_id)).service_email) + with self.app.test_request_context(): + self.assertIsNone(ServiceUser.query.get((service_id, user_id)).service_email) def test_update_email_preferences_invalid(self): self.login_as('user') @@ -284,52 +287,47 @@ class TestSelfservice(UffdTestCase): def test_change_password(self): self.login_as('user') - user = request.user r = self.client.post(path=url_for('selfservice.change_password'), data={'password1': 'newpassword', 'password2': 'newpassword'}, follow_redirects=True) dump('change_password', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertTrue(_user.password.verify('newpassword')) + self.assertTrue(self.get_user().password.verify('newpassword')) def test_change_password_invalid(self): self.login_as('user') - user = request.user r = self.client.post(path=url_for('selfservice.change_password'), data={'password1': 'shortpw', 'password2': 'shortpw'}, follow_redirects=True) dump('change_password_invalid', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertFalse(_user.password.verify('shortpw')) - self.assertTrue(_user.password.verify('userpassword')) + user = self.get_user() + self.assertFalse(user.password.verify('shortpw')) + self.assertTrue(user.password.verify('userpassword')) # Regression test for #100 (login not possible if password contains character disallowed by SASLprep) def test_change_password_samlprep_invalid(self): self.login_as('user') - user = request.user r = self.client.post(path=url_for('selfservice.change_password'), data={'password1': 'shortpw\n', 'password2': 'shortpw\n'}, follow_redirects=True) dump('change_password_samlprep_invalid', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertFalse(_user.password.verify('shortpw\n')) - self.assertTrue(_user.password.verify('userpassword')) + user = self.get_user() + self.assertFalse(user.password.verify('shortpw\n')) + self.assertTrue(user.password.verify('userpassword')) def test_change_password_mismatch(self): self.login_as('user') - user = request.user r = self.client.post(path=url_for('selfservice.change_password'), data={'password1': 'newpassword1', 'password2': 'newpassword2'}, follow_redirects=True) dump('change_password_mismatch', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertFalse(_user.password.verify('newpassword1')) - self.assertFalse(_user.password.verify('newpassword2')) - self.assertTrue(_user.password.verify('userpassword')) + user = self.get_user() + self.assertFalse(user.password.verify('newpassword1')) + self.assertFalse(user.password.verify('newpassword2')) + self.assertTrue(user.password.verify('userpassword')) def test_leave_role(self): baserole = Role(name='baserole', is_default=True) @@ -482,3 +480,207 @@ class TestSelfservice(UffdTestCase): dump('token_password_different_passwords_submit', r) self.assertEqual(r.status_code, 200) self.assertTrue(self.get_user().password.verify('userpassword')) + +def get_fido2_test_cred(self): + try: + from uffd.fido2_compat import AttestedCredentialData + except ImportError: + self.skipTest('fido2 could not be imported') + # Example public key from webauthn spec 6.5.1.1 + return AttestedCredentialData(bytes.fromhex('00000000000000000000000000000000'+'0040'+'053cbcc9d37a61d3bac87cdcc77ee326256def08ab15775d3a720332e4101d14fae95aeee3bc9698781812e143c0597dc6e180595683d501891e9dd030454c0a'+'A501020326200121582065eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d2258201e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c')) + +class TestMfaViews(UffdTestCase): + def setUp(self): + super().setUp() + db.session.add(RecoveryCodeMethod(user=self.get_admin())) + db.session.add(TOTPMethod(user=self.get_admin(), name='Admin Phone')) + # We don't want to skip all tests only because fido2 is not installed! + #db.session.add(WebauthnMethod(user=get_testadmin(), cred=get_fido2_test_cred(self), name='Admin FIDO2 dongle')) + db.session.commit() + + def add_recovery_codes(self, count=10): + user = self.get_user() + for _ in range(count): + db.session.add(RecoveryCodeMethod(user=user)) + db.session.commit() + + def add_totp(self): + db.session.add(TOTPMethod(user=self.get_user(), name='My phone')) + db.session.commit() + + def add_webauthn(self): + db.session.add(WebauthnMethod(user=self.get_user(), cred=get_fido2_test_cred(self), name='My FIDO2 dongle')) + db.session.commit() + + def test_setup_disabled(self): + self.login_as('user') + r = self.client.get(path=url_for('selfservice.setup_mfa'), follow_redirects=True) + dump('mfa_setup_disabled', r) + self.assertEqual(r.status_code, 200) + + def test_setup_recovery_codes(self): + self.login_as('user') + self.add_recovery_codes() + r = self.client.get(path=url_for('selfservice.setup_mfa'), follow_redirects=True) + dump('mfa_setup_only_recovery_codes', r) + self.assertEqual(r.status_code, 200) + + def test_setup_enabled(self): + self.login_as('user') + self.add_recovery_codes() + self.add_totp() + self.add_webauthn() + r = self.client.get(path=url_for('selfservice.setup_mfa'), follow_redirects=True) + dump('mfa_setup_enabled', r) + self.assertEqual(r.status_code, 200) + + def test_setup_few_recovery_codes(self): + self.login_as('user') + self.add_totp() + self.add_recovery_codes(1) + r = self.client.get(path=url_for('selfservice.setup_mfa'), follow_redirects=True) + dump('mfa_setup_few_recovery_codes', r) + self.assertEqual(r.status_code, 200) + + def test_setup_no_recovery_codes(self): + self.login_as('user') + self.add_totp() + r = self.client.get(path=url_for('selfservice.setup_mfa'), follow_redirects=True) + dump('mfa_setup_no_recovery_codes', r) + self.assertEqual(r.status_code, 200) + + def test_disable(self): + baserole = Role(name='baserole', is_default=True) + db.session.add(baserole) + baserole.groups[self.get_access_group()] = RoleGroup() + db.session.commit() + self.login_as('user') + self.add_recovery_codes() + self.add_totp() + admin_methods = len(MFAMethod.query.filter_by(user=self.get_admin()).all()) + r = self.client.get(path=url_for('selfservice.disable_mfa'), follow_redirects=True) + dump('mfa_disable', r) + self.assertEqual(r.status_code, 200) + r = self.client.post(path=url_for('selfservice.disable_mfa_confirm'), follow_redirects=True) + dump('mfa_disable_submit', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(MFAMethod.query.filter_by(user=request.user).all()), 0) + self.assertEqual(len(MFAMethod.query.filter_by(user=self.get_admin()).all()), admin_methods) + + def test_disable_recovery_only(self): + baserole = Role(name='baserole', is_default=True) + db.session.add(baserole) + baserole.groups[self.get_access_group()] = RoleGroup() + db.session.commit() + self.login_as('user') + self.add_recovery_codes() + admin_methods = len(MFAMethod.query.filter_by(user=self.get_admin()).all()) + self.assertNotEqual(len(MFAMethod.query.filter_by(user=request.user).all()), 0) + r = self.client.get(path=url_for('selfservice.disable_mfa'), follow_redirects=True) + dump('mfa_disable_recovery_only', r) + self.assertEqual(r.status_code, 200) + r = self.client.post(path=url_for('selfservice.disable_mfa_confirm'), follow_redirects=True) + dump('mfa_disable_recovery_only_submit', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(MFAMethod.query.filter_by(user=request.user).all()), 0) + self.assertEqual(len(MFAMethod.query.filter_by(user=self.get_admin()).all()), admin_methods) + + def test_setup_recovery(self): + self.login_as('user') + self.assertEqual(len(RecoveryCodeMethod.query.filter_by(user=request.user).all()), 0) + r = self.client.post(path=url_for('selfservice.setup_mfa_recovery'), follow_redirects=True) + dump('mfa_setup_recovery', r) + self.assertEqual(r.status_code, 200) + methods = RecoveryCodeMethod.query.filter_by(user=request.user).all() + self.assertNotEqual(len(methods), 0) + r = self.client.post(path=url_for('selfservice.setup_mfa_recovery'), follow_redirects=True) + dump('mfa_setup_recovery_reset', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(RecoveryCodeMethod.query.filter_by(id=methods[0].id).all()), 0) + self.assertNotEqual(len(methods), 0) + + def test_setup_totp(self): + self.login_as('user') + self.add_recovery_codes() + r = self.client.get(path=url_for('selfservice.setup_mfa_totp', name='My TOTP Authenticator'), follow_redirects=True) + dump('mfa_setup_totp', r) + self.assertEqual(r.status_code, 200) + self.assertNotEqual(len(session.get('mfa_totp_key', '')), 0) + + def test_setup_totp_without_recovery(self): + self.login_as('user') + r = self.client.get(path=url_for('selfservice.setup_mfa_totp', name='My TOTP Authenticator'), follow_redirects=True) + dump('mfa_setup_totp_without_recovery', r) + self.assertEqual(r.status_code, 200) + + def test_setup_totp_finish(self): + baserole = Role(name='baserole', is_default=True) + db.session.add(baserole) + baserole.groups[self.get_access_group()] = RoleGroup() + db.session.commit() + self.login_as('user') + self.add_recovery_codes() + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) + r = self.client.get(path=url_for('selfservice.setup_mfa_totp', name='My TOTP Authenticator'), follow_redirects=True) + method = TOTPMethod(request.user, key=session.get('mfa_totp_key', '')) + code = _hotp(int(time.time()/30), method.raw_key) + r = self.client.post(path=url_for('selfservice.setup_mfa_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True) + dump('mfa_setup_totp_finish', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 1) + + def test_setup_totp_finish_without_recovery(self): + self.login_as('user') + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) + r = self.client.get(path=url_for('selfservice.setup_mfa_totp', name='My TOTP Authenticator'), follow_redirects=True) + method = TOTPMethod(request.user, key=session.get('mfa_totp_key', '')) + code = _hotp(int(time.time()/30), method.raw_key) + r = self.client.post(path=url_for('selfservice.setup_mfa_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True) + dump('mfa_setup_totp_finish_without_recovery', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) + + def test_setup_totp_finish_wrong_code(self): + self.login_as('user') + self.add_recovery_codes() + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) + r = self.client.get(path=url_for('selfservice.setup_mfa_totp', name='My TOTP Authenticator'), follow_redirects=True) + method = TOTPMethod(request.user, key=session.get('mfa_totp_key', '')) + code = _hotp(int(time.time()/30), method.raw_key) + code = str(int(code[0])+1)[-1] + code[1:] + r = self.client.post(path=url_for('selfservice.setup_mfa_totp_finish', name='My TOTP Authenticator'), data={'code': code}, follow_redirects=True) + dump('mfa_setup_totp_finish_wrong_code', r) + self.assertEqual(r.status_code, 200) + db_flush() + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) + + def test_setup_totp_finish_empty_code(self): + self.login_as('user') + self.add_recovery_codes() + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) + r = self.client.get(path=url_for('selfservice.setup_mfa_totp', name='My TOTP Authenticator'), follow_redirects=True) + r = self.client.post(path=url_for('selfservice.setup_mfa_totp_finish', name='My TOTP Authenticator'), data={'code': ''}, follow_redirects=True) + dump('mfa_setup_totp_finish_empty_code', r) + self.assertEqual(r.status_code, 200) + db_flush() + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 0) + + def test_delete_totp(self): + baserole = Role(name='baserole', is_default=True) + db.session.add(baserole) + baserole.groups[self.get_access_group()] = RoleGroup() + db.session.commit() + self.login_as('user') + self.add_recovery_codes() + self.add_totp() + method = TOTPMethod(request.user, name='test') + db.session.add(method) + db.session.commit() + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 2) + r = self.client.get(path=url_for('selfservice.delete_mfa_totp', id=method.id), follow_redirects=True) + dump('mfa_delete_totp', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(TOTPMethod.query.filter_by(id=method.id).all()), 0) + self.assertEqual(len(TOTPMethod.query.filter_by(user=request.user).all()), 1) + + # TODO: webauthn setup tests diff --git a/tests/views/test_session.py b/tests/views/test_session.py index 1780ac87fa4ed8e49f61e5ae597d25492968d948..3debfb23c02ac2ab389060982807cadc43b8b684 100644 --- a/tests/views/test_session.py +++ b/tests/views/test_session.py @@ -5,7 +5,8 @@ from flask import url_for, request from uffd.database import db from uffd.password_hash import PlaintextPasswordHash -from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, User +from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, User, RecoveryCodeMethod, TOTPMethod +from uffd.models.mfa import _hotp from uffd.views.session import login_required from tests.utils import dump, UffdTestCase, db_flush @@ -148,7 +149,6 @@ class TestSession(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertLoggedOut() - @unittest.skip('See #29') def test_timeout(self): self.login() time.sleep(3) @@ -183,8 +183,162 @@ class TestSession(UffdTestCase): self.assertEqual(r.status_code, 200) initiation = OAuth2DeviceLoginInitiation.query.filter_by(code=code).one() self.assertEqual(len(initiation.confirmations), 1) - self.assertEqual(initiation.confirmations[0].user.loginname, 'testuser') + self.assertEqual(initiation.confirmations[0].session.user.loginname, 'testuser') self.assertIn(initiation.confirmations[0].code.encode(), r.data) r = self.client.get(path=url_for('session.deviceauth_finish'), follow_redirects=True) self.assertEqual(r.status_code, 200) self.assertEqual(DeviceLoginConfirmation.query.all(), []) + +class TestMfaViews(UffdTestCase): + def add_recovery_codes(self, count=10): + user = self.get_user() + for _ in range(count): + db.session.add(RecoveryCodeMethod(user=user)) + db.session.commit() + + def add_totp(self): + db.session.add(TOTPMethod(user=self.get_user(), name='My phone')) + db.session.commit() + + def test_auth_integration(self): + self.add_recovery_codes() + self.add_totp() + db.session.commit() + self.assertIsNone(request.user) + r = self.login_as('user') + dump('mfa_auth_redirected', r) + self.assertEqual(r.status_code, 200) + self.assertIn(b'/mfa/auth', r.data) + self.assertIsNone(request.user) + r = self.client.get(path=url_for('session.mfa_auth'), follow_redirects=False) + dump('mfa_auth', r) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + + def test_auth_disabled(self): + self.assertIsNone(request.user) + self.login_as('user') + r = self.client.get(path=url_for('session.mfa_auth', ref='/redirecttarget'), follow_redirects=False) + self.assertEqual(r.status_code, 302) + self.assertTrue(r.location.endswith('/redirecttarget')) + self.assertIsNotNone(request.user) + + def test_auth_recovery_only(self): + self.add_recovery_codes() + self.assertIsNone(request.user) + self.login_as('user') + r = self.client.get(path=url_for('session.mfa_auth', ref='/redirecttarget'), follow_redirects=False) + self.assertEqual(r.status_code, 302) + self.assertTrue(r.location.endswith('/redirecttarget')) + self.assertIsNotNone(request.user) + + def test_auth_recovery_code(self): + self.add_recovery_codes() + self.add_totp() + method = RecoveryCodeMethod(user=self.get_user()) + db.session.add(method) + db.session.commit() + method_id = method.id + self.login_as('user') + r = self.client.get(path=url_for('session.mfa_auth'), follow_redirects=False) + dump('mfa_auth_recovery_code', r) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + r = self.client.post(path=url_for('session.mfa_auth_finish', ref='/redirecttarget'), data={'code': method.code}) + self.assertEqual(r.status_code, 302) + self.assertTrue(r.location.endswith('/redirecttarget')) + self.assertIsNotNone(request.user) + self.assertEqual(len(RecoveryCodeMethod.query.filter_by(id=method_id).all()), 0) + + def test_auth_totp_code(self): + self.add_recovery_codes() + self.add_totp() + method = TOTPMethod(user=self.get_user(), name='testname') + raw_key = method.raw_key + db.session.add(method) + db.session.commit() + self.login_as('user') + r = self.client.get(path=url_for('session.mfa_auth'), follow_redirects=False) + dump('mfa_auth_totp_code', r) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + code = _hotp(int(time.time()/30), raw_key) + r = self.client.post(path=url_for('session.mfa_auth_finish'), data={'code': code}, follow_redirects=True) + dump('mfa_auth_totp_code_submit', r) + self.assertEqual(r.status_code, 200) + self.assertIsNotNone(request.user) + + def test_auth_totp_code_reuse(self): + self.add_recovery_codes() + self.add_totp() + method = TOTPMethod(user=self.get_user(), name='testname') + raw_key = method.raw_key + db.session.add(method) + db.session.commit() + self.login_as('user') + r = self.client.get(path=url_for('session.mfa_auth'), follow_redirects=False) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + code = _hotp(int(time.time()/30), raw_key) + r = self.client.post(path=url_for('session.mfa_auth_finish'), data={'code': code}, follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertIsNotNone(request.user) + self.login_as('user') + r = self.client.get(path=url_for('session.mfa_auth'), follow_redirects=False) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + r = self.client.post(path=url_for('session.mfa_auth_finish'), data={'code': code}, follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + + def test_auth_empty_code(self): + self.add_recovery_codes() + self.add_totp() + self.login_as('user') + r = self.client.get(path=url_for('session.mfa_auth'), follow_redirects=False) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + r = self.client.post(path=url_for('session.mfa_auth_finish'), data={'code': ''}, follow_redirects=True) + dump('mfa_auth_empty_code', r) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + + def test_auth_invalid_code(self): + self.add_recovery_codes() + self.add_totp() + method = TOTPMethod(user=self.get_user(), name='testname') + raw_key = method.raw_key + db.session.add(method) + db.session.commit() + self.login_as('user') + r = self.client.get(path=url_for('session.mfa_auth'), follow_redirects=False) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + code = _hotp(int(time.time()/30), raw_key) + code = str(int(code[0])+1)[-1] + code[1:] + r = self.client.post(path=url_for('session.mfa_auth_finish'), data={'code': code}, follow_redirects=True) + dump('mfa_auth_invalid_code', r) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + + def test_auth_ratelimit(self): + self.add_recovery_codes() + self.add_totp() + method = TOTPMethod(user=self.get_user(), name='testname') + raw_key = method.raw_key + db.session.add(method) + db.session.commit() + self.login_as('user') + self.assertIsNone(request.user) + code = _hotp(int(time.time()/30), raw_key) + inv_code = str(int(code[0])+1)[-1] + code[1:] + for i in range(20): + r = self.client.post(path=url_for('session.mfa_auth_finish'), data={'code': inv_code}, follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + r = self.client.post(path=url_for('session.mfa_auth_finish'), data={'code': code}, follow_redirects=True) + dump('mfa_auth_ratelimit', r) + self.assertEqual(r.status_code, 200) + self.assertIsNone(request.user) + + # TODO: webauthn auth tests diff --git a/tests/views/test_user.py b/tests/views/test_user.py index 6bffa84d0dbae6b62799d492546fc0f4367bc167..2987428c23e803798f871a833f63ca5fb00c52d0 100644 --- a/tests/views/test_user.py +++ b/tests/views/test_user.py @@ -1,7 +1,7 @@ -from flask import url_for +from flask import url_for, request from uffd.database import db -from uffd.models import User, UserEmail, Group, Role, Service, ServiceUser, FeatureFlag +from uffd.models import User, UserEmail, Group, Role, Service, ServiceUser, FeatureFlag, MFAMethod, RecoveryCodeMethod, TOTPMethod from tests.utils import dump, UffdTestCase @@ -378,6 +378,21 @@ class TestUserViews(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertFalse(self.get_user().is_deactivated) + def test_disable_mfa(self): + db.session.add(RecoveryCodeMethod(user=self.get_admin())) + user = self.get_user() + for _ in range(10): + db.session.add(RecoveryCodeMethod(user=user)) + db.session.add(TOTPMethod(user=self.get_user(), name='My phone')) + db.session.commit() + self.login_as('admin') + admin_methods = len(MFAMethod.query.filter_by(user=self.get_admin()).all()) + r = self.client.get(path=url_for('user.disable_mfa', id=self.get_user().id), follow_redirects=True) + dump('user_disable_mfa', r) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(MFAMethod.query.filter_by(user=self.get_user()).all()), 0) + self.assertEqual(len(MFAMethod.query.filter_by(user=self.get_admin()).all()), admin_methods) + def test_csvimport(self): role1 = Role(name='role1') db.session.add(role1) diff --git a/uffd/__init__.py b/uffd/__init__.py index b669c0057ab7de84694da0d18edd9b4caee55abf..f90154800982d718f51693bf3dbbb7a3edcb4f0d 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -46,9 +46,10 @@ def init_config(app: Flask, test_config): if load_config_file(app, os.path.join(app.instance_path, filename), silent=True): break - if app.env == "production" and app.secret_key is None: - raise Exception("SECRET_KEY not configured and we are running in production mode!") - app.config.setdefault("SECRET_KEY", secrets.token_hex(128)) + if app.secret_key is None: + if app.env == "production": + raise Exception("SECRET_KEY not configured and we are running in production mode!") + app.secret_key = secrets.token_hex(128) def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-statements app = Flask(__name__, instance_relative_config=False) diff --git a/uffd/babel.cfg b/uffd/babel.cfg index f0234b326f94f1f420103b7ba1017f58dbe99417..759e805aef560ac08b9ad07c1414a1ea6541f12c 100644 --- a/uffd/babel.cfg +++ b/uffd/babel.cfg @@ -1,3 +1,2 @@ [python: **.py] [jinja2: **/templates/**.html] -extensions=jinja2.ext.autoescape,jinja2.ext.with_ diff --git a/uffd/fido2_compat.py b/uffd/fido2_compat.py index dd8fd9ba0f88c5f1cafa175c6fea8715c740926a..f682b78bb108d377d8001b2c0c1630a07e32a935 100644 --- a/uffd/fido2_compat.py +++ b/uffd/fido2_compat.py @@ -1,26 +1,46 @@ # pylint: skip-file -import fido2 as __fido2 +from flask_babel import gettext as _ +from warnings import warn +from flask import request, current_app +import urllib.parse -if __fido2.__version__.startswith('0.5.'): - from fido2.client import ClientData - from fido2.server import Fido2Server, RelyingParty as __PublicKeyCredentialRpEntity - from fido2.ctap2 import AttestationObject, AuthenticatorData, AttestedCredentialData - from fido2 import cbor - cbor.encode = cbor.dumps - cbor.decode = lambda arg: cbor.loads(arg)[0] - class PublicKeyCredentialRpEntity(__PublicKeyCredentialRpEntity): - def __init__(self, name, id): - super().__init__(id, name) -elif __fido2.__version__.startswith('0.9.'): - from fido2.client import ClientData - from fido2.webauthn import PublicKeyCredentialRpEntity - from fido2.server import Fido2Server - from fido2.ctap2 import AttestationObject, AuthenticatorData, AttestedCredentialData - from fido2 import cbor -elif __fido2.__version__.startswith('1.'): - from fido2.webauthn import PublicKeyCredentialRpEntity, CollectedClientData as ClientData, AttestationObject, AuthenticatorData, AttestedCredentialData - from fido2.server import Fido2Server - from fido2 import cbor -else: - raise ImportError(f'Unsupported fido2 version: {__fido2.__version__}') + +# WebAuthn support is optional because fido2 has a pretty unstable +# interface and might be difficult to install with the correct version + +try: + import fido2 as __fido2 + + if __fido2.__version__.startswith('0.5.'): + from fido2.client import ClientData + from fido2.server import Fido2Server, RelyingParty as __PublicKeyCredentialRpEntity + from fido2.ctap2 import AttestationObject, AuthenticatorData, AttestedCredentialData + from fido2 import cbor + cbor.encode = cbor.dumps + cbor.decode = lambda arg: cbor.loads(arg)[0] + class PublicKeyCredentialRpEntity(__PublicKeyCredentialRpEntity): + def __init__(self, name, id): + super().__init__(id, name) + elif __fido2.__version__.startswith('0.9.'): + from fido2.client import ClientData + from fido2.webauthn import PublicKeyCredentialRpEntity + from fido2.server import Fido2Server + from fido2.ctap2 import AttestationObject, AuthenticatorData, AttestedCredentialData + from fido2 import cbor + elif __fido2.__version__.startswith('1.'): + from fido2.webauthn import PublicKeyCredentialRpEntity, CollectedClientData as ClientData, AttestationObject, AuthenticatorData, AttestedCredentialData + from fido2.server import Fido2Server + from fido2 import cbor + else: + raise ImportError(f'Unsupported fido2 version: {__fido2.__version__}') + + def get_webauthn_server(): + hostname = urllib.parse.urlsplit(request.url).hostname + return Fido2Server(PublicKeyCredentialRpEntity(id=current_app.config.get('MFA_RP_ID', hostname), + name=current_app.config['MFA_RP_NAME'])) + + WEBAUTHN_SUPPORTED = True +except ImportError as err: + warn(_('2FA WebAuthn support disabled because import of the fido2 module failed (%s)')%err) + WEBAUTHN_SUPPORTED = False diff --git a/uffd/migrations/versions/01fdd7820f29_openid_connect_support.py b/uffd/migrations/versions/01fdd7820f29_openid_connect_support.py new file mode 100644 index 0000000000000000000000000000000000000000..c7a97b2f0ba1659224e5b63a7a8785a86bbde89f --- /dev/null +++ b/uffd/migrations/versions/01fdd7820f29_openid_connect_support.py @@ -0,0 +1,148 @@ +"""OpenID Connect Support + +Revision ID: 01fdd7820f29 +Revises: a9b449776953 +Create Date: 2023-11-09 16:52:20.860871 + +""" +from alembic import op +import sqlalchemy as sa + +import datetime +import secrets +import math +import logging + +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.backends import default_backend # Only required for Buster +import jwt + +# pyjwt v1.7.x compat (Buster/Bullseye) +if not hasattr(jwt, 'get_algorithm_by_name'): + jwt.get_algorithm_by_name = lambda name: jwt.algorithms.get_default_algorithms()[name] + +# revision identifiers, used by Alembic. +revision = '01fdd7820f29' +down_revision = 'a9b449776953' +branch_labels = None +depends_on = None + +logger = logging.getLogger('alembic.runtime.migration.01fdd7820f29') + +def token_with_alphabet(alphabet, nbytes=None): + '''Return random text token that consists of characters from `alphabet`''' + if nbytes is None: + nbytes = max(secrets.DEFAULT_ENTROPY, 32) + nbytes_per_char = math.log(len(alphabet), 256) + nchars = math.ceil(nbytes / nbytes_per_char) + return ''.join([secrets.choice(alphabet) for _ in range(nchars)]) + +def token_urlfriendly(nbytes=None): + '''Return random text token that is urlsafe and works around common parsing bugs''' + alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + return token_with_alphabet(alphabet, nbytes=nbytes) + +def upgrade(): + logger.info('Generating 3072 bit RSA key pair (RS256) for OpenID Connect support ...') + private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072, backend=default_backend()) + + meta = sa.MetaData(bind=op.get_bind()) + oauth2_key = op.create_table('oauth2_key', + sa.Column('id', sa.String(length=64), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('active', sa.Boolean(create_constraint=False), nullable=False), + sa.Column('algorithm', sa.String(length=32), nullable=False), + sa.Column('private_key_jwk', sa.Text(), nullable=False), + sa.Column('public_key_jwk', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2_key')) + ) + algorithm = jwt.get_algorithm_by_name('RS256') + op.bulk_insert(oauth2_key, [{ + 'id': token_urlfriendly(), + 'created': datetime.datetime.utcnow(), + 'active': True, + 'algorithm': 'RS256', + 'private_key_jwk': algorithm.to_jwk(private_key), + 'public_key_jwk': algorithm.to_jwk(private_key.public_key()), + }]) + + with op.batch_alter_table('oauth2grant', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_oauth2grant_code')) + oauth2grant = sa.Table('oauth2grant', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=255), nullable=False), + sa.Column('redirect_uri', sa.String(length=255), nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2grant_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant')) + ) + with op.batch_alter_table('oauth2grant', copy_from=oauth2grant) as batch_op: + batch_op.add_column(sa.Column('nonce', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('claims', sa.Text(), nullable=True)) + batch_op.alter_column('redirect_uri', existing_type=sa.VARCHAR(length=255), nullable=True) + + oauth2token = sa.Table('oauth2token', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('token_type', sa.String(length=40), nullable=False), + sa.Column('access_token', sa.String(length=255), nullable=False), + sa.Column('refresh_token', sa.String(length=255), nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2token_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')), + sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')), + sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token')) + ) + with op.batch_alter_table('oauth2token', copy_from=oauth2token) as batch_op: + batch_op.add_column(sa.Column('claims', sa.Text(), nullable=True)) + +def downgrade(): + meta = sa.MetaData(bind=op.get_bind()) + + oauth2token = sa.Table('oauth2token', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('token_type', sa.String(length=40), nullable=False), + sa.Column('access_token', sa.String(length=255), nullable=False), + sa.Column('refresh_token', sa.String(length=255), nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.Column('claims', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2token_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')), + sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')), + sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token')) + ) + with op.batch_alter_table('oauth2token', copy_from=oauth2token) as batch_op: + batch_op.drop_column('claims') + + oauth2grant = sa.Table('oauth2grant', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=255), nullable=False), + sa.Column('redirect_uri', sa.String(length=255), nullable=True), + sa.Column('nonce', sa.Text(), nullable=True), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.Column('claims', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oauth2grant_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant')) + ) + with op.batch_alter_table('oauth2grant', copy_from=oauth2grant) as batch_op: + batch_op.alter_column('redirect_uri', existing_type=sa.VARCHAR(length=255), nullable=False) + batch_op.drop_column('claims') + batch_op.drop_column('nonce') + batch_op.create_index(batch_op.f('ix_oauth2grant_code'), ['code'], unique=False) + + op.drop_table('oauth2_key') diff --git a/uffd/migrations/versions/23293f32b503_deactivate_users.py b/uffd/migrations/versions/23293f32b503_deactivate_users.py index c25dbf6cbc1045a0f398f0a2730da096c466806e..d6b9c9c243bef49537f29bf142f6e1df472e3ced 100644 --- a/uffd/migrations/versions/23293f32b503_deactivate_users.py +++ b/uffd/migrations/versions/23293f32b503_deactivate_users.py @@ -16,15 +16,15 @@ depends_on = None def upgrade(): meta = sa.MetaData(bind=op.get_bind()) with op.batch_alter_table('service', schema=None) as batch_op: - batch_op.add_column(sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column('hide_deactivated_users', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false())) service = sa.Table('service', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False), sa.Column('access_group_id', sa.Integer(), nullable=True), - sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False), - sa.Column('enable_email_preferences', sa.Boolean(), nullable=False), - sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False), + sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('hide_deactivated_users', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()), sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), sa.UniqueConstraint('name', name=op.f('uq_service_name')) @@ -32,7 +32,7 @@ def upgrade(): with op.batch_alter_table('service', copy_from=service) as batch_op: batch_op.alter_column('hide_deactivated_users', server_default=None) with op.batch_alter_table('user', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_deactivated', sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column('is_deactivated', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false())) user = sa.Table('user', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('unix_uid', sa.Integer(), nullable=False), @@ -41,8 +41,8 @@ def upgrade(): sa.Column('primary_email_id', sa.Integer(), nullable=False), sa.Column('recovery_email_id', sa.Integer(), nullable=True), sa.Column('pwhash', sa.Text(), nullable=True), - sa.Column('is_service_user', sa.Boolean(), nullable=False), - sa.Column('is_deactivated', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('is_deactivated', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()), sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), sa.ForeignKeyConstraint(['unix_uid'], ['uid_allocation.id'], name=op.f('fk_user_unix_uid_uid_allocation')), @@ -63,8 +63,8 @@ def downgrade(): sa.Column('primary_email_id', sa.Integer(), nullable=False), sa.Column('recovery_email_id', sa.Integer(), nullable=True), sa.Column('pwhash', sa.Text(), nullable=True), - sa.Column('is_service_user', sa.Boolean(), nullable=False), - sa.Column('is_deactivated', sa.Boolean(), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('is_deactivated', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), sa.ForeignKeyConstraint(['unix_uid'], ['uid_allocation.id'], name=op.f('fk_user_unix_uid_uid_allocation')), @@ -77,11 +77,11 @@ def downgrade(): service = sa.Table('service', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False), sa.Column('access_group_id', sa.Integer(), nullable=True), - sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False), - sa.Column('enable_email_preferences', sa.Boolean(), nullable=False), - sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False), + sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False), + sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('hide_deactivated_users', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), sa.UniqueConstraint('name', name=op.f('uq_service_name')) diff --git a/uffd/migrations/versions/2b68f688bec1_remailer_v2.py b/uffd/migrations/versions/2b68f688bec1_remailer_v2.py index bdf95ccc793e6cd5eb3f50c7a0d6bdd158e67428..d48c3af170d40a6a1e412fceedcd3ed0489711f9 100644 --- a/uffd/migrations/versions/2b68f688bec1_remailer_v2.py +++ b/uffd/migrations/versions/2b68f688bec1_remailer_v2.py @@ -15,22 +15,22 @@ depends_on = None def upgrade(): with op.batch_alter_table('service', schema=None) as batch_op: - batch_op.add_column(sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False, server_default='DISABLED')) + batch_op.add_column(sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False, server_default='DISABLED')) service = sa.table('service', sa.column('id', sa.Integer), - sa.column('use_remailer', sa.Boolean), - sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode')), + sa.column('use_remailer', sa.Boolean(create_constraint=True)), + sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode')), ) op.execute(service.update().values(remailer_mode='ENABLED_V1').where(service.c.use_remailer)) meta = sa.MetaData(bind=op.get_bind()) service = sa.Table('service', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False), sa.Column('access_group_id', sa.Integer(), nullable=True), - sa.Column('use_remailer', sa.Boolean(), nullable=False), - sa.Column('enable_email_preferences', sa.Boolean(), nullable=False), - sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False, server_default='DISABLED'), + sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False, server_default='DISABLED'), sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), sa.UniqueConstraint('name', name=op.f('uq_service_name')) @@ -44,19 +44,19 @@ def downgrade(): batch_op.add_column(sa.Column('use_remailer', sa.BOOLEAN(), nullable=False, server_default=sa.false())) service = sa.table('service', sa.column('id', sa.Integer), - sa.column('use_remailer', sa.Boolean), - sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode')), + sa.column('use_remailer', sa.Boolean(create_constraint=True)), + sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode')), ) op.execute(service.update().values(use_remailer=sa.true()).where(service.c.remailer_mode != 'DISABLED')) meta = sa.MetaData(bind=op.get_bind()) service = sa.Table('service', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False), sa.Column('access_group_id', sa.Integer(), nullable=True), - sa.Column('use_remailer', sa.Boolean(), nullable=False, server_default=sa.false()), - sa.Column('enable_email_preferences', sa.Boolean(), nullable=False), - sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False), + sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()), + sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False), sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), sa.UniqueConstraint('name', name=op.f('uq_service_name')) diff --git a/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py b/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py index 2c776195e443b307262c203f8ea94fee48ab05c9..b9bc7058a55bf9b81eadd43f2a93d6ad82065812 100644 --- a/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py +++ b/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py @@ -37,16 +37,16 @@ def iter_rows_paged(table, pk='id', limit=1000): def upgrade(): with op.batch_alter_table('user_email', schema=None) as batch_op: batch_op.add_column(sa.Column('address_normalized', sa.String(length=128), nullable=True)) - batch_op.add_column(sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True)) - batch_op.alter_column('verified', existing_type=sa.Boolean(), nullable=True) + batch_op.add_column(sa.Column('enable_strict_constraints', sa.Boolean(create_constraint=True), nullable=True)) + batch_op.alter_column('verified', existing_type=sa.Boolean(create_constraint=True), nullable=True) meta = sa.MetaData(bind=op.get_bind()) user_email_table = sa.Table('user_email', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('address', sa.String(length=128), nullable=False), sa.Column('address_normalized', sa.String(length=128), nullable=True), - sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True), - sa.Column('verified', sa.Boolean(), nullable=True), + sa.Column('enable_strict_constraints', sa.Boolean(create_constraint=True), nullable=True), + sa.Column('verified', sa.Boolean(create_constraint=True), nullable=True), sa.Column('verification_legacy_id', sa.Integer(), nullable=True), sa.Column('verification_secret', sa.Text(), nullable=True), sa.Column('verification_expires', sa.DateTime(), nullable=True), @@ -81,8 +81,8 @@ def downgrade(): sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('address', sa.String(length=128), nullable=False), sa.Column('address_normalized', sa.String(length=128), nullable=False), - sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True), - sa.Column('verified', sa.Boolean(), nullable=True), + sa.Column('enable_strict_constraints', sa.Boolean(create_constraint=True), nullable=True), + sa.Column('verified', sa.Boolean(create_constraint=True), nullable=True), sa.Column('verification_legacy_id', sa.Integer(), nullable=True), sa.Column('verification_secret', sa.Text(), nullable=True), sa.Column('verification_expires', sa.DateTime(), nullable=True), @@ -96,7 +96,7 @@ def downgrade(): with op.batch_alter_table('user_email', copy_from=user_email_table) as batch_op: batch_op.drop_constraint('uq_user_email_user_id_address_normalized', type_='unique') batch_op.drop_constraint('uq_user_email_address_normalized_verified', type_='unique') - batch_op.alter_column('verified', existing_type=sa.Boolean(), nullable=False) + batch_op.alter_column('verified', existing_type=sa.Boolean(create_constraint=True), nullable=False) batch_op.drop_column('enable_strict_constraints') batch_op.drop_column('address_normalized') op.drop_table('feature_flag') diff --git a/uffd/migrations/versions/54b2413586fd_invite_pk_change.py b/uffd/migrations/versions/54b2413586fd_invite_pk_change.py index a59e2ca69336fd8027a967e9436e250605b4a6b8..75d60783b5783a7ba6ca55952eb96a95adad8503 100644 --- a/uffd/migrations/versions/54b2413586fd_invite_pk_change.py +++ b/uffd/migrations/versions/54b2413586fd_invite_pk_change.py @@ -38,10 +38,10 @@ def upgrade(): sa.Column('token', sa.String(length=128), nullable=False), sa.Column('created', sa.DateTime(), nullable=False), sa.Column('valid_until', sa.DateTime(), nullable=False), - sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False), - sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False), - sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False), - sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False), + sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False), + sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False), + sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False), + sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False), sa.PrimaryKeyConstraint('token', name=op.f('pk_invite')) ) with op.batch_alter_table('invite_grant', schema=None) as batch_op: @@ -115,10 +115,10 @@ def downgrade(): sa.Column('token', sa.String(length=128), nullable=False), sa.Column('created', sa.DateTime(), nullable=False), sa.Column('valid_until', sa.DateTime(), nullable=False), - sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False), - sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False), - sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False), - sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False), + sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False), + sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False), + sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False), + sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False), sa.PrimaryKeyConstraint('id', name=op.f('pk_invite')), sa.UniqueConstraint('token', name=op.f('uq_invite_token')) ) diff --git a/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py b/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py index 8933610d6ea6032407268bbe304c14d12b926cbb..3e63561b974033632be6643ee8450ea7eed6e24e 100644 --- a/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py +++ b/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py @@ -21,19 +21,19 @@ def upgrade(): # table. To keep the resulting database consistent, we remove the # server_default afterwards. with op.batch_alter_table('api_client') as batch_op: - batch_op.add_column(sa.Column('perm_remailer', sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false())) with op.batch_alter_table('service') as batch_op: - batch_op.add_column(sa.Column('use_remailer', sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false())) meta = sa.MetaData(bind=op.get_bind()) api_client = sa.Table('api_client', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('auth_username', sa.String(length=40), nullable=False), sa.Column('auth_password', sa.Text(), nullable=False), - sa.Column('perm_users', sa.Boolean(), nullable=False), - sa.Column('perm_checkpassword', sa.Boolean(), nullable=False), - sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False), - sa.Column('perm_remailer', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()), sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')), sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username')) @@ -41,9 +41,9 @@ def upgrade(): service = sa.Table('service', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False), sa.Column('access_group_id', sa.Integer(), nullable=True), - sa.Column('use_remailer', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()), sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), sa.UniqueConstraint('name', name=op.f('uq_service_name')) @@ -60,10 +60,10 @@ def downgrade(): sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('auth_username', sa.String(length=40), nullable=False), sa.Column('auth_password', sa.Text(), nullable=False), - sa.Column('perm_users', sa.Boolean(), nullable=False), - sa.Column('perm_checkpassword', sa.Boolean(), nullable=False), - sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False), - sa.Column('perm_remailer', sa.Boolean(), nullable=False), + sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')), sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username')) @@ -71,9 +71,9 @@ def downgrade(): service = sa.Table('service', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False), sa.Column('access_group_id', sa.Integer(), nullable=True), - sa.Column('use_remailer', sa.Boolean(), nullable=False), + sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), sa.UniqueConstraint('name', name=op.f('uq_service_name')) diff --git a/uffd/migrations/versions/878b25c4fae7_ldap_to_db.py b/uffd/migrations/versions/878b25c4fae7_ldap_to_db.py index 2249f75919e1bf6a605b7684707e46bf2fb6997d..d8d6313636778dbe7f2bb07629cebff1be8f5a63 100644 --- a/uffd/migrations/versions/878b25c4fae7_ldap_to_db.py +++ b/uffd/migrations/versions/878b25c4fae7_ldap_to_db.py @@ -205,7 +205,7 @@ def upgrade(): sa.Column('displayname', sa.String(length=128), nullable=False), sa.Column('mail', sa.String(length=128), nullable=False), sa.Column('pwhash', sa.String(length=256), nullable=True), - sa.Column('is_service_user', sa.Boolean(name=op.f('ck_user_is_service_user')), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True, name=op.f('ck_user_is_service_user')), nullable=False), sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) @@ -337,10 +337,10 @@ def upgrade(): sa.Column('creator_id', sa.Integer(), nullable=True), sa.Column('creator_dn', sa.String(length=128), nullable=True), sa.Column('valid_until', sa.DateTime(), nullable=False), - sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False), - sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False), - sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False), - sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False), + sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False), + sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False), + sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False), + sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False), sa.ForeignKeyConstraint(['creator_id'], ['user.id'], name=op.f('fk_invite_creator_id_user'), onupdate='CASCADE'), sa.PrimaryKeyConstraint('id', name=op.f('pk_invite')), sa.UniqueConstraint('token', name=op.f('uq_invite_token')) @@ -373,7 +373,7 @@ def upgrade(): batch_op.create_foreign_key(batch_op.f('fk_mfa_method_user_id_user'), 'user', ['user_id'], ['id']) mfa_method = sa.Table('mfa_method', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='ck_mfa_method_type'), nullable=True), + sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=True), sa.Column('created', sa.DateTime(), nullable=True), sa.Column('name', sa.String(length=128), nullable=True), sa.Column('user_id', sa.Integer(), nullable=True), @@ -390,7 +390,7 @@ def upgrade(): with op.batch_alter_table('mfa_method', copy_from=mfa_method) as batch_op: batch_op.alter_column('user_id', nullable=False, existing_type=sa.Integer()) batch_op.alter_column('created', existing_type=sa.DateTime(), nullable=False) - batch_op.alter_column('type', existing_type=sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='ck_mfa_method_type'), nullable=False) + batch_op.alter_column('type', existing_type=sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=False) batch_op.drop_constraint('fk_mfa_method_user_id_user', type_='foreignkey') batch_op.create_foreign_key(batch_op.f('fk_mfa_method_user_id_user'), 'user', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') batch_op.drop_column('dn') @@ -457,8 +457,8 @@ def upgrade(): sa.Column('description', sa.Text(), nullable=True), sa.Column('moderator_group_id', sa.Integer(), nullable=True), sa.Column('moderator_group_dn', sa.String(length=128), nullable=True), - sa.Column('locked', sa.Boolean(name=op.f('ck_role_locked')), nullable=False), - sa.Column('is_default', sa.Boolean(name=op.f('ck_role_is_default')), nullable=False), + sa.Column('locked', sa.Boolean(create_constraint=True, name=op.f('ck_role_locked')), nullable=False), + sa.Column('is_default', sa.Boolean(create_constraint=True, name=op.f('ck_role_is_default')), nullable=False), sa.PrimaryKeyConstraint('id', name=op.f('pk_role')), sa.UniqueConstraint('name', name=op.f('uq_role_name')) ) @@ -630,7 +630,7 @@ def downgrade(): sa.Column('displayname', sa.String(length=128), nullable=False), sa.Column('mail', sa.String(length=128), nullable=False), sa.Column('pwhash', sa.String(length=256), nullable=True), - sa.Column('is_service_user', sa.Boolean(name=op.f('ck_user_is_service_user')), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True, name=op.f('ck_user_is_service_user')), nullable=False), sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) @@ -742,8 +742,8 @@ def downgrade(): sa.Column('description', sa.Text(), nullable=True), sa.Column('moderator_group_id', sa.Integer(), nullable=True), sa.Column('moderator_group_dn', sa.String(length=128), nullable=True), - sa.Column('locked', sa.Boolean(name=op.f('ck_role_locked')), nullable=False), - sa.Column('is_default', sa.Boolean(name=op.f('ck_role_is_default')), nullable=False), + sa.Column('locked', sa.Boolean(create_constraint=True, name=op.f('ck_role_locked')), nullable=False), + sa.Column('is_default', sa.Boolean(create_constraint=True, name=op.f('ck_role_is_default')), nullable=False), sa.ForeignKeyConstraint(['moderator_group_id'], ['group.id'], name=op.f('fk_role_moderator_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_role')), sa.UniqueConstraint('name', name=op.f('uq_role_name')) @@ -813,7 +813,7 @@ def downgrade(): batch_op.add_column(sa.Column('dn', sa.String(length=128), nullable=True)) mfa_method = sa.Table('mfa_method', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='ck_mfa_method_type'), nullable=False), + sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=False), sa.Column('created', sa.DateTime(), nullable=False), sa.Column('name', sa.String(length=128), nullable=True), sa.Column('user_id', sa.Integer(), nullable=False), @@ -829,7 +829,7 @@ def downgrade(): op.execute(mfa_method.delete().where(mfa_method.c.dn==None)) with op.batch_alter_table('mfa_method', copy_from=mfa_method) as batch_op: batch_op.drop_constraint('fk_mfa_method_user_id_user', 'foreignkey') - batch_op.alter_column('type', existing_type=sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='ck_mfa_method_type'), nullable=True) + batch_op.alter_column('type', existing_type=sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=True) batch_op.alter_column('created', existing_type=sa.DateTime(), nullable=True) batch_op.drop_column('user_id') @@ -861,10 +861,10 @@ def downgrade(): sa.Column('creator_id', sa.Integer(), nullable=True), sa.Column('creator_dn', sa.String(length=128), nullable=True), sa.Column('valid_until', sa.DateTime(), nullable=False), - sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False), - sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False), - sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False), - sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False), + sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False), + sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False), + sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False), + sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False), sa.ForeignKeyConstraint(['creator_id'], ['user.id'], name=op.f('fk_invite_creator_id_user')), sa.PrimaryKeyConstraint('id', name=op.f('pk_invite')), sa.UniqueConstraint('token', name=op.f('uq_invite_token')) diff --git a/uffd/migrations/versions/87cb93a329bf_server_side_sessions.py b/uffd/migrations/versions/87cb93a329bf_server_side_sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..e8fea9021337958a8ce4723e2e1f1e8b85ff0efc --- /dev/null +++ b/uffd/migrations/versions/87cb93a329bf_server_side_sessions.py @@ -0,0 +1,31 @@ +"""Server-side sessions + +Revision ID: 87cb93a329bf +Revises: 01fdd7820f29 +Create Date: 2024-03-23 23:57:44.019456 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '87cb93a329bf' +down_revision = '01fdd7820f29' +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table('session', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('secret', sa.Text(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('last_used', sa.DateTime(), nullable=False), + sa.Column('user_agent', sa.Text(), nullable=False), + sa.Column('ip_address', sa.Text(), nullable=True), + sa.Column('mfa_done', sa.Boolean(create_constraint=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_session_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_session')) + ) + +def downgrade(): + op.drop_table('session') diff --git a/uffd/migrations/versions/99df71f0f4a0_migrate_device_login_from_user_to_session.py b/uffd/migrations/versions/99df71f0f4a0_migrate_device_login_from_user_to_session.py new file mode 100644 index 0000000000000000000000000000000000000000..32655553e045368101418d3643cbe4623cb12d06 --- /dev/null +++ b/uffd/migrations/versions/99df71f0f4a0_migrate_device_login_from_user_to_session.py @@ -0,0 +1,63 @@ +"""Migrate device login from user to session + +Revision ID: 99df71f0f4a0 +Revises: 87cb93a329bf +Create Date: 2024-05-18 16:41:33.923207 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '99df71f0f4a0' +down_revision = '87cb93a329bf' +branch_labels = None +depends_on = None + +def upgrade(): + op.drop_table('device_login_confirmation') + op.create_table('device_login_confirmation', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('initiation_id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('code0', sa.String(length=32), nullable=False), + sa.Column('code1', sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint(['initiation_id'], ['device_login_initiation.id'], name='fk_device_login_confirmation_initiation_id_', onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['session.id'], name=op.f('fk_device_login_confirmation_session_id_session'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_device_login_confirmation')), + sa.UniqueConstraint('initiation_id', 'code0', name='uq_device_login_confirmation_initiation_id_code0'), + sa.UniqueConstraint('initiation_id', 'code1', name='uq_device_login_confirmation_initiation_id_code1'), + sa.UniqueConstraint('session_id', name=op.f('uq_device_login_confirmation_session_id')) + ) + +def downgrade(): + # We don't drop and recreate the table here to improve fuzzy migration test coverage + with op.batch_alter_table('device_login_confirmation', schema=None) as batch_op: + batch_op.add_column(sa.Column('user_id', sa.Integer(), nullable=True)) + meta = sa.MetaData(bind=op.get_bind()) + device_login_confirmation = sa.Table('device_login_confirmation', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('initiation_id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('code0', sa.String(length=32), nullable=False), + sa.Column('code1', sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint(['initiation_id'], ['device_login_initiation.id'], name='fk_device_login_confirmation_initiation_id_', onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['session.id'], name=op.f('fk_device_login_confirmation_session_id_session'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_device_login_confirmation')), + sa.UniqueConstraint('initiation_id', 'code0', name='uq_device_login_confirmation_initiation_id_code0'), + sa.UniqueConstraint('initiation_id', 'code1', name='uq_device_login_confirmation_initiation_id_code1'), + sa.UniqueConstraint('session_id', name=op.f('uq_device_login_confirmation_session_id')) + ) + session = sa.table('session', + sa.column('id', sa.Integer), + sa.column('user_id', sa.Integer()), + ) + op.execute(device_login_confirmation.update().values(user_id=sa.select([session.c.user_id]).where(device_login_confirmation.c.session_id==session.c.id).as_scalar())) + op.execute(device_login_confirmation.delete().where(device_login_confirmation.c.user_id==None)) + with op.batch_alter_table('device_login_confirmation', copy_from=device_login_confirmation) as batch_op: + batch_op.alter_column('user_id', nullable=False, existing_type=sa.Integer()) + batch_op.create_foreign_key('fk_device_login_confirmation_user_id_user', 'user', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + batch_op.create_unique_constraint('uq_device_login_confirmation_user_id', ['user_id']) + batch_op.drop_constraint(batch_op.f('fk_device_login_confirmation_session_id_session'), type_='foreignkey') + batch_op.drop_constraint(batch_op.f('uq_device_login_confirmation_session_id'), type_='unique') + batch_op.drop_column('session_id') diff --git a/uffd/migrations/versions/a29870f95175_initial_migration.py b/uffd/migrations/versions/a29870f95175_initial_migration.py index 828fee28a5a4229cf83cde69ee9e5939eaba9bc8..156d03c148ac8a45b8eb8bbd3b10b9ed0eebab12 100644 --- a/uffd/migrations/versions/a29870f95175_initial_migration.py +++ b/uffd/migrations/versions/a29870f95175_initial_migration.py @@ -21,10 +21,10 @@ def upgrade(): sa.Column('token', sa.String(length=128), nullable=False), sa.Column('created', sa.DateTime(), nullable=False), sa.Column('valid_until', sa.DateTime(), nullable=False), - sa.Column('single_use', sa.Boolean(), nullable=False), - sa.Column('allow_signup', sa.Boolean(), nullable=False), - sa.Column('used', sa.Boolean(), nullable=False), - sa.Column('disabled', sa.Boolean(), nullable=False), + sa.Column('single_use', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('allow_signup', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('used', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('disabled', sa.Boolean(create_constraint=True), nullable=False), sa.PrimaryKeyConstraint('token') ) op.create_table('mailToken', @@ -36,7 +36,7 @@ def upgrade(): ) op.create_table('mfa_method', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='mfatype'), nullable=True), + sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='mfatype'), nullable=True), sa.Column('created', sa.DateTime(), nullable=True), sa.Column('name', sa.String(length=128), nullable=True), sa.Column('dn', sa.String(length=128), nullable=True), diff --git a/uffd/migrations/versions/a594d3b3e05b_added_role_locked.py b/uffd/migrations/versions/a594d3b3e05b_added_role_locked.py index 5ca1b89a97010bdfb11c51f35410d9281187e3a8..18f54212fef06a495ebe5558dc7b4e95c9381cb6 100644 --- a/uffd/migrations/versions/a594d3b3e05b_added_role_locked.py +++ b/uffd/migrations/versions/a594d3b3e05b_added_role_locked.py @@ -16,8 +16,20 @@ depends_on = None def upgrade(): with op.batch_alter_table('role', schema=None) as batch_op: - batch_op.add_column(sa.Column('locked', sa.Boolean(name=op.f('ck_role_locked')), nullable=False, default=False)) + batch_op.add_column(sa.Column('locked', sa.Boolean(create_constraint=True, name=op.f('ck_role_locked')), nullable=False, default=False)) def downgrade(): - with op.batch_alter_table('role', schema=None) as batch_op: + meta = sa.MetaData(bind=op.get_bind()) + table = sa.Table('role', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=32), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('moderator_group_dn', sa.String(length=128), nullable=True), + sa.Column('locked', sa.Boolean(create_constraint=False), nullable=False), + sa.CheckConstraint('locked IN (0, 1)', name=op.f('ck_role_locked')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_role')), + sa.UniqueConstraint('name', name=op.f('uq_role_name')) + ) + with op.batch_alter_table('role', copy_from=table) as batch_op: + batch_op.drop_constraint(op.f('ck_role_locked'), 'check') batch_op.drop_column('locked') diff --git a/uffd/migrations/versions/a60ce68b9214_fix_not_null_on_role_groups_group_id.py b/uffd/migrations/versions/a60ce68b9214_fix_not_null_on_role_groups_group_id.py index 3f97f8c113a8ca7f8d586a7f6f9562e3df9dcf1f..d131db68e34ddc35bd3d77928ae07d5bdce6ba1a 100644 --- a/uffd/migrations/versions/a60ce68b9214_fix_not_null_on_role_groups_group_id.py +++ b/uffd/migrations/versions/a60ce68b9214_fix_not_null_on_role_groups_group_id.py @@ -22,7 +22,7 @@ def upgrade(): role_groups = sa.Table('role_groups', meta, sa.Column('role_id', sa.Integer(), nullable=False), sa.Column('group_id', sa.Integer(), nullable=True), - sa.Column('requires_mfa', sa.Boolean(), nullable=False), + sa.Column('requires_mfa', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['group_id'], ['group.id'], name=op.f('fk_role_groups_group_id_group'), onupdate='CASCADE', ondelete='CASCADE'), sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role_groups_role_id_role'), onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('role_id', 'group_id', name=op.f('pk_role_groups')) @@ -35,7 +35,7 @@ def downgrade(): role_groups = sa.Table('role_groups', meta, sa.Column('role_id', sa.Integer(), nullable=False), sa.Column('group_id', sa.Integer(), nullable=False), - sa.Column('requires_mfa', sa.Boolean(), nullable=False), + sa.Column('requires_mfa', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['group_id'], ['group.id'], name=op.f('fk_role_groups_group_id_group'), onupdate='CASCADE', ondelete='CASCADE'), sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role_groups_role_id_role'), onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('role_id', 'group_id', name=op.f('pk_role_groups')) diff --git a/uffd/migrations/versions/a8c6b6e91c28_device_login.py b/uffd/migrations/versions/a8c6b6e91c28_device_login.py index efdbc3018b5980a588c5bf490f18f77ebeb82cde..e4b3a3370d02cb6189859b7dc1f04786a55adb4f 100644 --- a/uffd/migrations/versions/a8c6b6e91c28_device_login.py +++ b/uffd/migrations/versions/a8c6b6e91c28_device_login.py @@ -17,7 +17,7 @@ depends_on = None def upgrade(): op.create_table('device_login_initiation', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False), + sa.Column('type', sa.Enum('OAUTH2', create_constraint=True, name='devicelogintype'), nullable=False), sa.Column('code0', sa.String(length=32), nullable=False), sa.Column('code1', sa.String(length=32), nullable=False), sa.Column('secret', sa.String(length=128), nullable=False), diff --git a/uffd/migrations/versions/a9b449776953_add_mfa_method_totp_last_counter.py b/uffd/migrations/versions/a9b449776953_add_mfa_method_totp_last_counter.py new file mode 100644 index 0000000000000000000000000000000000000000..b391db8ab82f33075574e01a78b0f450dcf61703 --- /dev/null +++ b/uffd/migrations/versions/a9b449776953_add_mfa_method_totp_last_counter.py @@ -0,0 +1,51 @@ +"""Add mfa_method.totp_last_counter + +Revision ID: a9b449776953 +Revises: 23293f32b503 +Create Date: 2023-11-07 12:09:23.843865 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'a9b449776953' +down_revision = '23293f32b503' +branch_labels = None +depends_on = None + +def upgrade(): + meta = sa.MetaData(bind=op.get_bind()) + mfa_method = sa.Table('mfa_method', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('recovery_salt', sa.String(length=64), nullable=True), + sa.Column('recovery_hash', sa.String(length=256), nullable=True), + sa.Column('totp_key', sa.String(length=64), nullable=True), + sa.Column('webauthn_cred', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_mfa_method_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_mfa_method')) + ) + with op.batch_alter_table('mfa_method', copy_from=mfa_method) as batch_op: + batch_op.add_column(sa.Column('totp_last_counter', sa.Integer(), nullable=True)) + +def downgrade(): + meta = sa.MetaData(bind=op.get_bind()) + mfa_method = sa.Table('mfa_method', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('recovery_salt', sa.String(length=64), nullable=True), + sa.Column('recovery_hash', sa.String(length=256), nullable=True), + sa.Column('totp_key', sa.String(length=64), nullable=True), + sa.Column('totp_last_counter', sa.Integer(), nullable=True), + sa.Column('webauthn_cred', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_mfa_method_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_mfa_method')) + ) + with op.batch_alter_table('mfa_method', copy_from=mfa_method) as batch_op: + batch_op.drop_column('totp_last_counter') diff --git a/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py b/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py index 204cad29b81fc2843d31fd41f6d96eb58517e3ec..69fc5e1dcbc3332a6ba03e92d2ba6f15de97520e 100644 --- a/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py +++ b/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py @@ -26,7 +26,7 @@ def upgrade(): sa.Column('primary_email_id', sa.Integer(), nullable=False), sa.Column('recovery_email_id', sa.Integer(), nullable=True), sa.Column('pwhash', sa.Text(), nullable=True), - sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), @@ -137,7 +137,7 @@ def downgrade(): sa.Column('primary_email_id', sa.Integer(), nullable=False), sa.Column('recovery_email_id', sa.Integer(), nullable=True), sa.Column('pwhash', sa.Text(), nullable=True), - sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), sa.ForeignKeyConstraint(['unix_uid'], ['uid_allocation.id'], name=op.f('fk_user_unix_uid_uid_allocation')), diff --git a/uffd/migrations/versions/af07cea65391_unified_password_hashing_for_user_and_signup.py b/uffd/migrations/versions/af07cea65391_unified_password_hashing_for_user_and_signup.py index 58fb2f48533b561b2f8f752d4dedb7bdc974400b..55bbf485b52e92e25522d2f3c1caa4b82797cf95 100644 --- a/uffd/migrations/versions/af07cea65391_unified_password_hashing_for_user_and_signup.py +++ b/uffd/migrations/versions/af07cea65391_unified_password_hashing_for_user_and_signup.py @@ -37,7 +37,7 @@ def upgrade(): sa.Column('displayname', sa.String(length=128), nullable=False), sa.Column('mail', sa.String(length=128), nullable=False), sa.Column('pwhash', sa.String(length=256), nullable=True), - sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False), sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) @@ -71,7 +71,7 @@ def downgrade(): sa.Column('displayname', sa.String(length=128), nullable=False), sa.Column('mail', sa.String(length=128), nullable=False), sa.Column('pwhash', sa.Text(), nullable=True), - sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False), sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) diff --git a/uffd/migrations/versions/aff5f350dcdf_added_role_is_default.py b/uffd/migrations/versions/aff5f350dcdf_added_role_is_default.py index b903fbd7078d2bedc91c9ffe3adb99de1f98583e..909b15d0cf0e1bb24030268f4d8d05367a469d98 100644 --- a/uffd/migrations/versions/aff5f350dcdf_added_role_is_default.py +++ b/uffd/migrations/versions/aff5f350dcdf_added_role_is_default.py @@ -16,8 +16,22 @@ depends_on = None def upgrade(): with op.batch_alter_table('role', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_default', sa.Boolean(name=op.f('ck_role_is_default')), nullable=False, default=False)) + batch_op.add_column(sa.Column('is_default', sa.Boolean(create_constraint=True, name=op.f('ck_role_is_default')), nullable=False, default=False)) def downgrade(): - with op.batch_alter_table('role', schema=None) as batch_op: + meta = sa.MetaData(bind=op.get_bind()) + table = sa.Table('role', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=32), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('moderator_group_dn', sa.String(length=128), nullable=True), + sa.Column('locked', sa.Boolean(create_constraint=False), nullable=False), + sa.Column('is_default', sa.Boolean(create_constraint=False), nullable=False), + sa.CheckConstraint('locked IN (0, 1)', name=op.f('ck_role_locked')), + sa.CheckConstraint('is_default IN (0, 1)', name=op.f('ck_role_is_default')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_role')), + sa.UniqueConstraint('name', name=op.f('uq_role_name')) + ) + with op.batch_alter_table('role', copy_from=table) as batch_op: + batch_op.drop_constraint(op.f('ck_role_is_default'), 'check') batch_op.drop_column('is_default') diff --git a/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py b/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py index ca83799f275997ef41de980d802aece4aebf8a81..adb8e107be0e60d9a4d6d1059c8372855ed86f30 100644 --- a/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py +++ b/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py @@ -36,7 +36,7 @@ def upgrade(): sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('address', sa.String(length=128), nullable=False), - sa.Column('verified', sa.Boolean(), nullable=False), + sa.Column('verified', sa.Boolean(create_constraint=True), nullable=False), sa.Column('verification_legacy_id', sa.Integer(), nullable=True), sa.Column('verification_secret', sa.Text(), nullable=True), sa.Column('verification_expires', sa.DateTime(), nullable=True), @@ -50,7 +50,7 @@ def upgrade(): ) op.execute(user_email_table.insert().from_select( ['user_id', 'address', 'verified'], - sa.select([user_table.c.id, user_table.c.mail, sa.literal(True, sa.Boolean())]) + sa.select([user_table.c.id, user_table.c.mail, sa.literal(True, sa.Boolean(create_constraint=True))]) )) with op.batch_alter_table('user', schema=None) as batch_op: batch_op.add_column(sa.Column('primary_email_id', sa.Integer(), nullable=True)) @@ -67,7 +67,7 @@ def upgrade(): sa.Column('primary_email_id', sa.Integer(), nullable=True), sa.Column('recovery_email_id', sa.Integer(), nullable=True), sa.Column('pwhash', sa.Text(), nullable=True), - sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), @@ -110,7 +110,7 @@ def downgrade(): sa.Column('primary_email_id', sa.Integer(), nullable=False), sa.Column('recovery_email_id', sa.Integer(), nullable=True), sa.Column('pwhash', sa.Text(), nullable=True), - sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), diff --git a/uffd/migrations/versions/b8fbefca3675_added_api_permission_for_metrics.py b/uffd/migrations/versions/b8fbefca3675_added_api_permission_for_metrics.py index ca86b66a621d01fe35b67268cc5acb676cf375f3..326e8231a58d01614d27e0935786849582d2e042 100644 --- a/uffd/migrations/versions/b8fbefca3675_added_api_permission_for_metrics.py +++ b/uffd/migrations/versions/b8fbefca3675_added_api_permission_for_metrics.py @@ -20,16 +20,16 @@ def upgrade(): sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('auth_username', sa.String(length=40), nullable=False), sa.Column('auth_password', sa.Text(), nullable=False), - sa.Column('perm_users', sa.Boolean(), nullable=False), - sa.Column('perm_checkpassword', sa.Boolean(), nullable=False), - sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False), - sa.Column('perm_remailer', sa.Boolean(), nullable=False), + sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')), sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username')) ) with op.batch_alter_table('api_client', copy_from=api_client) as batch_op: - batch_op.add_column(sa.Column('perm_metrics', sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column('perm_metrics', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false())) def downgrade(): meta = sa.MetaData(bind=op.get_bind()) @@ -38,11 +38,11 @@ def downgrade(): sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('auth_username', sa.String(length=40), nullable=False), sa.Column('auth_password', sa.Text(), nullable=False), - sa.Column('perm_users', sa.Boolean(), nullable=False), - sa.Column('perm_checkpassword', sa.Boolean(), nullable=False), - sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False), - sa.Column('perm_remailer', sa.Boolean(), nullable=False), - sa.Column('perm_metrics', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_metrics', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()), sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')), sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username')) diff --git a/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py b/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py index 34c1a4fdff31cf76c4e9e7e17cb54ee662add8c5..0fd1230c2e8d0e98be8edf5121bfe5514c4c85a7 100644 --- a/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py +++ b/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py @@ -99,7 +99,7 @@ def upgrade(): service_table = op.create_table('service', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False), sa.Column('access_group_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), @@ -118,9 +118,9 @@ def upgrade(): sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('auth_username', sa.String(length=40), nullable=False), sa.Column('auth_password', sa.Text(), nullable=False), - sa.Column('perm_users', sa.Boolean(), nullable=False), - sa.Column('perm_checkpassword', sa.Boolean(), nullable=False), - sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False), + sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False), sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')), sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username')) @@ -164,7 +164,7 @@ def upgrade(): batch_op.create_foreign_key(batch_op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), 'oauth2client', ['oauth2_client_db_id'], ['db_id'], onupdate='CASCADE', ondelete='CASCADE') device_login_initiation_table = sa.Table('device_login_initiation', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False), + sa.Column('type', sa.Enum('OAUTH2', create_constraint=True, name='devicelogintype'), nullable=False), sa.Column('code0', sa.String(length=32), nullable=False), sa.Column('code1', sa.String(length=32), nullable=False), sa.Column('secret', sa.String(length=128), nullable=False), @@ -293,7 +293,7 @@ def downgrade(): batch_op.add_column(sa.Column('oauth2_client_id', sa.VARCHAR(length=40), nullable=True)) device_login_initiation_table = sa.Table('device_login_initiation', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False), + sa.Column('type', sa.Enum('OAUTH2', create_constraint=True, name='devicelogintype'), nullable=False), sa.Column('code0', sa.String(length=32), nullable=False), sa.Column('code1', sa.String(length=32), nullable=False), sa.Column('secret', sa.String(length=128), nullable=False), diff --git a/uffd/migrations/versions/bad6fc529510_added_rolegroup_requires_mfa_and_cleanup.py b/uffd/migrations/versions/bad6fc529510_added_rolegroup_requires_mfa_and_cleanup.py index c97861859c4bd5d9c8f642f813672555c7d4df85..bd0dc72f410722492947cfee0da3c0636dac87aa 100644 --- a/uffd/migrations/versions/bad6fc529510_added_rolegroup_requires_mfa_and_cleanup.py +++ b/uffd/migrations/versions/bad6fc529510_added_rolegroup_requires_mfa_and_cleanup.py @@ -31,7 +31,7 @@ def upgrade(): batch_op.drop_column('id') batch_op.alter_column('dn', new_column_name='group_dn', nullable=False, existing_type=sa.String(128)) batch_op.alter_column('role_id', nullable=False, existing_type=sa.Integer()) - batch_op.add_column(sa.Column('requires_mfa', sa.Boolean(name=op.f('ck_role-group_requires_mfa')), nullable=False, default=False)) + batch_op.add_column(sa.Column('requires_mfa', sa.Boolean(create_constraint=True, name=op.f('ck_role-group_requires_mfa')), nullable=False, default=False)) batch_op.create_primary_key(batch_op.f('pk_role-group'), ['role_id', 'group_dn']) def downgrade(): @@ -39,7 +39,7 @@ def downgrade(): table = sa.Table('role-group', meta, sa.Column('role_id', sa.Integer(), nullable=False), sa.Column('group_dn', sa.String(128), nullable=False), - sa.Column('requires_mfa', sa.Boolean(name=op.f('ck_role-group_requires_mfa')), nullable=False, default=False), + sa.Column('requires_mfa', sa.Boolean(create_constraint=True, name=op.f('ck_role-group_requires_mfa')), nullable=False, default=False), sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role-group_role_id_role')), sa.PrimaryKeyConstraint('role_id', 'group_dn', name=op.f('pk_role-group')) ) diff --git a/uffd/migrations/versions/cbca20cf64d9_constraint_name_fixes.py b/uffd/migrations/versions/cbca20cf64d9_constraint_name_fixes.py index 541e1dbf1f3d0a2edc980cb34aa83e909d4a0e16..db84513f5598e8122d49532f6ad685b09f7c0cee 100644 --- a/uffd/migrations/versions/cbca20cf64d9_constraint_name_fixes.py +++ b/uffd/migrations/versions/cbca20cf64d9_constraint_name_fixes.py @@ -80,10 +80,10 @@ def upgrade(): sa.Column('token', sa.String(length=128), nullable=False), sa.Column('created', sa.DateTime(), nullable=False), sa.Column('valid_until', sa.DateTime(), nullable=False), - sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False), - sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False), - sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False), - sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False), + sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False), + sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False), + sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False), + sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False), sa.PrimaryKeyConstraint('token', name=op.f('pk_invite')) ) with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op: @@ -99,7 +99,7 @@ def upgrade(): pass table = sa.Table('mfa_method', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='mfatype'), nullable=True), + sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='mfatype'), nullable=True), sa.Column('created', sa.DateTime(), nullable=True), sa.Column('name', sa.String(length=128), nullable=True), sa.Column('dn', sa.String(length=128), nullable=True), diff --git a/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py b/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py index e7afe43e324c04dd0b56964734047c4d49337397..1652358f1d4dbf6a33ba1705beab4e71692ed5bc 100644 --- a/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py +++ b/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py @@ -15,7 +15,7 @@ depends_on = None def upgrade(): with op.batch_alter_table('service', schema=None) as batch_op: - batch_op.add_column(sa.Column('enable_email_preferences', sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false())) with op.batch_alter_table('service_user', schema=None) as batch_op: batch_op.add_column(sa.Column('service_email_id', sa.Integer(), nullable=True)) batch_op.create_foreign_key(batch_op.f('fk_service_user_service_email_id_user_email'), 'user_email', ['service_email_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL') @@ -23,10 +23,10 @@ def upgrade(): service = sa.Table('service', meta, sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False), sa.Column('access_group_id', sa.Integer(), nullable=True), - sa.Column('use_remailer', sa.Boolean(), nullable=False), - sa.Column('enable_email_preferences', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False), + sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()), sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), sa.UniqueConstraint('name', name=op.f('uq_service_name')) diff --git a/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py b/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py index 10c617ee70e3f14d9be935cc4171724f36b5a666..12ebc6ce06cea2d2928804251e2210466d1c380a 100644 --- a/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py +++ b/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py @@ -26,14 +26,14 @@ def upgrade(): sa.PrimaryKeyConstraint('service_id', 'user_id', name=op.f('pk_service_user')) ) with op.batch_alter_table('service_user', copy_from=service_user) as batch_op: - batch_op.add_column(sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=True)) + batch_op.add_column(sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=True)) def downgrade(): meta = sa.MetaData(bind=op.get_bind()) service_user = sa.Table('service_user', meta, sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=True), + sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=True), sa.Column('service_email_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['service_email_id'], ['user_email.id'], name=op.f('fk_service_user_service_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_service_user_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'), diff --git a/uffd/migrations/versions/e71e29cc605a_migrate_oauth2_state_from_user_to_session.py b/uffd/migrations/versions/e71e29cc605a_migrate_oauth2_state_from_user_to_session.py new file mode 100644 index 0000000000000000000000000000000000000000..f70dadce200c24a46d10a3203c37fc6b4f85e9d1 --- /dev/null +++ b/uffd/migrations/versions/e71e29cc605a_migrate_oauth2_state_from_user_to_session.py @@ -0,0 +1,108 @@ +"""Migrate oauth2 state from user to session + +Revision ID: e71e29cc605a +Revises: 99df71f0f4a0 +Create Date: 2024-05-18 21:59:20.435912 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'e71e29cc605a' +down_revision = '99df71f0f4a0' +branch_labels = None +depends_on = None + +def upgrade(): + op.drop_table('oauth2grant') + op.drop_table('oauth2token') + op.create_table('oauth2grant', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=255), nullable=False), + sa.Column('redirect_uri', sa.String(length=255), nullable=True), + sa.Column('nonce', sa.Text(), nullable=True), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.Column('claims', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['session.id'], name=op.f('fk_oauth2grant_session_id_session'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant')) + ) + op.create_table('oauth2token', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('token_type', sa.String(length=40), nullable=False), + sa.Column('access_token', sa.String(length=255), nullable=False), + sa.Column('refresh_token', sa.String(length=255), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.Column('claims', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['session.id'], name=op.f('fk_oauth2token_session_id_session'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')), + sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')), + sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token')) + ) + +def downgrade(): + # We don't drop and recreate the table here to improve fuzzy migration test coverage + meta = sa.MetaData(bind=op.get_bind()) + session = sa.table('session', + sa.column('id', sa.Integer), + sa.column('user_id', sa.Integer()), + ) + + with op.batch_alter_table('oauth2token', schema=None) as batch_op: + batch_op.add_column(sa.Column('user_id', sa.INTEGER(), nullable=True)) + oauth2token = sa.Table('oauth2token', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('token_type', sa.String(length=40), nullable=False), + sa.Column('access_token', sa.String(length=255), nullable=False), + sa.Column('refresh_token', sa.String(length=255), nullable=False), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.Column('claims', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2token_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['session.id'], name=op.f('fk_oauth2token_session_id_session'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2token')), + sa.UniqueConstraint('access_token', name=op.f('uq_oauth2token_access_token')), + sa.UniqueConstraint('refresh_token', name=op.f('uq_oauth2token_refresh_token')) + ) + op.execute(oauth2token.update().values(user_id=sa.select([session.c.user_id]).where(oauth2token.c.session_id==session.c.id).as_scalar())) + op.execute(oauth2token.delete().where(oauth2token.c.user_id==None)) + with op.batch_alter_table('oauth2token', copy_from=oauth2token) as batch_op: + batch_op.alter_column('user_id', nullable=False, existing_type=sa.Integer()) + batch_op.create_foreign_key('fk_oauth2token_user_id_user', 'user', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + batch_op.drop_constraint(batch_op.f('fk_oauth2token_session_id_session'), type_='foreignkey') + batch_op.drop_column('session_id') + + with op.batch_alter_table('oauth2grant', schema=None) as batch_op: + batch_op.add_column(sa.Column('user_id', sa.INTEGER(), nullable=True)) + oauth2grant = sa.Table('oauth2grant', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('client_db_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=255), nullable=False), + sa.Column('redirect_uri', sa.String(length=255), nullable=True), + sa.Column('nonce', sa.Text(), nullable=True), + sa.Column('_scopes', sa.Text(), nullable=False), + sa.Column('claims', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['client_db_id'], ['oauth2client.db_id'], name=op.f('fk_oauth2grant_client_db_id_oauth2client'), onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['session.id'], name=op.f('fk_oauth2grant_session_id_session'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth2grant')) + ) + op.execute(oauth2grant.update().values(user_id=sa.select([session.c.user_id]).where(oauth2grant.c.session_id==session.c.id).as_scalar())) + op.execute(oauth2grant.delete().where(oauth2grant.c.user_id==None)) + with op.batch_alter_table('oauth2grant', copy_from=oauth2grant) as batch_op: + batch_op.alter_column('user_id', nullable=False, existing_type=sa.Integer()) + batch_op.create_foreign_key('fk_oauth2grant_user_id_user', 'user', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + batch_op.drop_constraint(batch_op.f('fk_oauth2grant_session_id_session'), type_='foreignkey') + batch_op.drop_column('session_id') diff --git a/uffd/migrations/versions/f2eb2c52a61f_add_serviceuser.py b/uffd/migrations/versions/f2eb2c52a61f_add_serviceuser.py index 2440efa28d126299ff017ac1dcc832c0d71b2020..2ee336c5f4c13a5489c415aeb9e28cc0176df8f2 100644 --- a/uffd/migrations/versions/f2eb2c52a61f_add_serviceuser.py +++ b/uffd/migrations/versions/f2eb2c52a61f_add_serviceuser.py @@ -23,7 +23,10 @@ def upgrade(): ) service = sa.table('service', sa.column('id')) user = sa.table('user', sa.column('id')) - op.execute(service_user.insert().from_select(['service_id', 'user_id'], sa.select([service.c.id, user.c.id]))) + op.execute(service_user.insert().from_select( + ['service_id', 'user_id'], + sa.select([service.c.id, user.c.id]).select_from(sa.join(service, user, sa.true())) + )) def downgrade(): op.drop_table('service_user') diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py index 5fe24173b3ce2f5782e9fa089c5d21b50533833a..2dcf9a713e92577d27f6aa82dadeba3cac364f22 100644 --- a/uffd/models/__init__.py +++ b/uffd/models/__init__.py @@ -2,11 +2,11 @@ from .api import APIClient from .invite import Invite, InviteGrant, InviteSignup from .mail import Mail, MailReceiveAddress, MailDestinationAddress from .mfa import MFAType, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod -from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation +from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, OAuth2Key from .role import Role, RoleGroup, RoleGroupMap from .selfservice import PasswordToken from .service import RemailerMode, Service, ServiceUser, get_services -from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation +from .session import Session, DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation from .signup import Signup from .user import User, UserEmail, Group, IDAllocator, IDRangeExhaustedError, IDAlreadyAllocatedError from .ratelimit import RatelimitEvent, Ratelimit, HostRatelimit, host_ratelimit, format_delay diff --git a/uffd/models/api.py b/uffd/models/api.py index c3dfd5e011ed9727663923cf6f4cf9ca44d17fbd..d57e36d34df82042aebe59c8533bb255d1c9e2e0 100644 --- a/uffd/models/api.py +++ b/uffd/models/api.py @@ -14,11 +14,11 @@ class APIClient(db.Model): auth_password = PasswordHashAttribute('_auth_password', HighEntropyPasswordHash) # Permissions are defined by adding an attribute named "perm_NAME" - perm_users = Column(Boolean(), default=False, nullable=False) - perm_checkpassword = Column(Boolean(), default=False, nullable=False) - perm_mail_aliases = Column(Boolean(), default=False, nullable=False) - perm_remailer = Column(Boolean(), default=False, nullable=False) - perm_metrics = Column(Boolean(), default=False, nullable=False) + perm_users = Column(Boolean(create_constraint=True), default=False, nullable=False) + perm_checkpassword = Column(Boolean(create_constraint=True), default=False, nullable=False) + perm_mail_aliases = Column(Boolean(create_constraint=True), default=False, nullable=False) + perm_remailer = Column(Boolean(create_constraint=True), default=False, nullable=False) + perm_metrics = Column(Boolean(create_constraint=True), default=False, nullable=False) @classmethod def permission_exists(cls, name): diff --git a/uffd/models/invite.py b/uffd/models/invite.py index ef8acc36f7f377c2a814f76882088ab37c8f47aa..793ade40650b9374e087e1cadeaa0650d1165317 100644 --- a/uffd/models/invite.py +++ b/uffd/models/invite.py @@ -23,10 +23,10 @@ class Invite(db.Model): creator_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE'), nullable=True) creator = relationship('User') valid_until = Column(DateTime, nullable=False) - single_use = Column(Boolean, default=True, nullable=False) - allow_signup = Column(Boolean, default=True, nullable=False) - used = Column(Boolean, default=False, nullable=False) - disabled = Column(Boolean, default=False, nullable=False) + single_use = Column(Boolean(create_constraint=True), default=True, nullable=False) + allow_signup = Column(Boolean(create_constraint=True), default=True, nullable=False) + used = Column(Boolean(create_constraint=True), default=False, nullable=False) + disabled = Column(Boolean(create_constraint=True), default=False, nullable=False) roles = relationship('Role', secondary=invite_roles) signups = relationship('InviteSignup', back_populates='invite', lazy=True, cascade='all, delete-orphan') grants = relationship('InviteGrant', back_populates='invite', lazy=True, cascade='all, delete-orphan') diff --git a/uffd/models/mfa.py b/uffd/models/mfa.py index e6b34eaa45f6c3859a31b16f8dad35cd9676a8f8..6a646c84c91e4067c2789b43598325b4e0241523 100644 --- a/uffd/models/mfa.py +++ b/uffd/models/mfa.py @@ -9,8 +9,7 @@ import hashlib import base64 import urllib.parse # imports for recovery codes -import crypt - +import crypt # pylint: disable=deprecated-module from flask import request, current_app from sqlalchemy import Column, Integer, Enum, String, DateTime, Text, ForeignKey from sqlalchemy.orm import relationship, backref @@ -19,6 +18,9 @@ from uffd.utils import nopad_b32decode, nopad_b32encode from uffd.database import db from .user import User +User.mfa_recovery_codes = relationship('RecoveryCodeMethod', viewonly=True) +User.mfa_totp_methods = relationship('TOTPMethod', viewonly=True) +User.mfa_webauthn_methods = relationship('WebauthnMethod', viewonly=True) User.mfa_enabled = property(lambda user: bool(user.mfa_totp_methods or user.mfa_webauthn_methods)) class MFAType(enum.Enum): @@ -29,7 +31,7 @@ class MFAType(enum.Enum): class MFAMethod(db.Model): __tablename__ = 'mfa_method' id = Column(Integer(), primary_key=True, autoincrement=True) - type = Column(Enum(MFAType), nullable=False) + type = Column(Enum(MFAType, create_constraint=True), nullable=False) created = Column(DateTime(), nullable=False, default=datetime.datetime.utcnow) name = Column(String(128)) user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) @@ -47,7 +49,6 @@ class MFAMethod(db.Model): class RecoveryCodeMethod(MFAMethod): code_salt = Column('recovery_salt', String(64)) code_hash = Column('recovery_hash', String(256)) - user = relationship('User', backref='mfa_recovery_codes') __mapper_args__ = { 'polymorphic_identity': MFAType.RECOVERY_CODE @@ -80,7 +81,7 @@ def _hotp(counter, key, digits=6): class TOTPMethod(MFAMethod): key = Column('totp_key', String(64)) - user = relationship('User', backref='mfa_totp_methods') + last_counter = Column('totp_last_counter', Integer()) __mapper_args__ = { 'polymorphic_identity': MFAType.TOTP @@ -121,15 +122,17 @@ class TOTPMethod(MFAMethod): :param code: String of digits (as entered by the user) :returns: True if code is valid, False otherwise''' - counter = int(time.time()/30) - for valid_code in [_hotp(counter-1, self.raw_key), _hotp(counter, self.raw_key)]: - if secrets.compare_digest(code, valid_code): - return True + current_counter = int(time.time()/30) + for counter in (current_counter - 1, current_counter): + if counter > (self.last_counter or 0): + valid_code = _hotp(counter, self.raw_key) + if secrets.compare_digest(code, valid_code): + self.last_counter = counter + return True return False class WebauthnMethod(MFAMethod): _cred = Column('webauthn_cred', Text()) - user = relationship('User', backref='mfa_webauthn_methods') __mapper_args__ = { 'polymorphic_identity': MFAType.WEBAUTHN diff --git a/uffd/models/oauth2.py b/uffd/models/oauth2.py index b79dd79286a17ce34a239e01b3c9701a78331243..ded3a2da9077888f6e18d9e37558bb3aa218ada9 100644 --- a/uffd/models/oauth2.py +++ b/uffd/models/oauth2.py @@ -1,17 +1,25 @@ import datetime import json +import secrets +import base64 -from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean from sqlalchemy.orm import relationship from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.associationproxy import association_proxy +import jwt from uffd.database import db, CommaSeparatedList from uffd.tasks import cleanup_task from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash +from uffd.utils import token_urlfriendly from .session import DeviceLoginInitiation, DeviceLoginType from .service import ServiceUser +# pyjwt v1.7.x compat (Buster/Bullseye) +if not hasattr(jwt, 'get_algorithm_by_name'): + jwt.get_algorithm_by_name = lambda name: jwt.algorithms.get_default_algorithms()[name] + class OAuth2Client(db.Model): __tablename__ = 'oauth2client' # Inconsistently named "db_id" instead of "id" because of the naming conflict @@ -28,17 +36,9 @@ class OAuth2Client(db.Model): redirect_uris = association_proxy('_redirect_uris', 'uri') logout_uris = relationship('OAuth2LogoutURI', cascade='all, delete-orphan') - @property - def client_type(self): - return 'confidential' - - @property - def default_scopes(self): - return ['profile'] - @property def default_redirect_uri(self): - return self.redirect_uris[0] + return self.redirect_uris[0] if len(self.redirect_uris) == 1 else None def access_allowed(self, user): service_user = ServiceUser.query.get((self.service_id, user.id)) @@ -69,48 +69,120 @@ class OAuth2Grant(db.Model): __tablename__ = 'oauth2grant' id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) - user = relationship('User') + EXPIRES_IN = 100 + expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=OAuth2Grant.EXPIRES_IN)) + + session_id = Column(Integer(), ForeignKey('session.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + session = relationship('Session') client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) client = relationship('OAuth2Client') - code = Column(String(255), index=True, nullable=False) - redirect_uri = Column(String(255), nullable=False) - expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=100)) + _code = Column('code', String(255), nullable=False, default=token_urlfriendly) + code = property(lambda self: f'{self.id}-{self._code}') + redirect_uri = Column(String(255), nullable=True) + nonce = Column(Text(), nullable=True) scopes = Column('_scopes', CommaSeparatedList(), nullable=False, default=tuple()) + _claims = Column('claims', Text(), nullable=True) + + @property + def claims(self): + return json.loads(self._claims) if self._claims is not None else None + + @claims.setter + def claims(self, value): + self._claims = json.dumps(value) if value is not None else None + + @property + def service_user(self): + return ServiceUser.query.get((self.client.service_id, self.session.user_id)) + @hybrid_property def expired(self): if self.expires is None: return False return self.expires < datetime.datetime.utcnow() -@cleanup_task.delete_by_attribute('expired') + @classmethod + def get_by_authorization_code(cls, code): + # pylint: disable=protected-access + if '-' not in code: + return None + grant_id, grant_code = code.split('-', 2) + grant = cls.query.filter_by(id=grant_id, expired=False).first() + if not grant or not secrets.compare_digest(grant._code, grant_code): + return None + if grant.session.expired or grant.session.user.is_deactivated: + return None + if not grant.service_user or not grant.service_user.has_access: + return None + return grant + + def make_token(self, **kwargs): + return OAuth2Token( + session=self.session, + client=self.client, + scopes=self.scopes, + claims=self.claims, + **kwargs + ) + +# OAuth2Token objects are cleaned-up when the session expires and is +# auto-deleted (or the user manually revokes it). class OAuth2Token(db.Model): __tablename__ = 'oauth2token' id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) - user = relationship('User') + EXPIRES_IN = 3600 + expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=OAuth2Token.EXPIRES_IN)) + + session_id = Column(Integer(), ForeignKey('session.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + session = relationship('Session') client_db_id = Column(Integer, ForeignKey('oauth2client.db_id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) client = relationship('OAuth2Client') # currently only bearer is supported - token_type = Column(String(40), nullable=False) - access_token = Column(String(255), unique=True, nullable=False) - refresh_token = Column(String(255), unique=True, nullable=False) - expires = Column(DateTime, nullable=False) + token_type = Column(String(40), nullable=False, default='bearer') + _access_token = Column('access_token', String(255), unique=True, nullable=False, default=token_urlfriendly) + access_token = property(lambda self: f'{self.id}-{self._access_token}') + _refresh_token = Column('refresh_token', String(255), unique=True, nullable=False, default=token_urlfriendly) + refresh_token = property(lambda self: f'{self.id}-{self._refresh_token}') scopes = Column('_scopes', CommaSeparatedList(), nullable=False, default=tuple()) + _claims = Column('claims', Text(), nullable=True) + + @property + def claims(self): + return json.loads(self._claims) if self._claims is not None else None + + @claims.setter + def claims(self, value): + self._claims = json.dumps(value) if value is not None else None + + @property + def service_user(self): + return ServiceUser.query.get((self.client.service_id, self.session.user_id)) + @hybrid_property def expired(self): return self.expires < datetime.datetime.utcnow() - def set_expires_in_seconds(self, seconds): - self.expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) - expires_in_seconds = property(fset=set_expires_in_seconds) + @classmethod + def get_by_access_token(cls, access_token): + # pylint: disable=protected-access + if '-' not in access_token: + return None + token_id, token_secret = access_token.split('-', 2) + token = cls.query.filter_by(id=token_id, expired=False).first() + if not token or not secrets.compare_digest(token._access_token, token_secret): + return None + if token.session.expired or token.session.user.is_deactivated: + return None + if not token.service_user or not token.service_user.has_access: + return None + return token class OAuth2DeviceLoginInitiation(DeviceLoginInitiation): __mapper_args__ = { @@ -122,3 +194,97 @@ class OAuth2DeviceLoginInitiation(DeviceLoginInitiation): @property def description(self): return self.client.service.name + +class OAuth2Key(db.Model): + __tablename__ = 'oauth2_key' + id = Column(String(64), primary_key=True, default=token_urlfriendly) + created = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + active = Column(Boolean(create_constraint=False), default=True, nullable=False) + algorithm = Column(String(32), nullable=False) + private_key_jwk = Column(Text(), nullable=False) + public_key_jwk = Column(Text(), nullable=False) + + def __init__(self, **kwargs): + if kwargs.get('algorithm') and kwargs.get('private_key') \ + and not kwargs.get('private_key_jwk') \ + and not kwargs.get('public_key_jwk'): + algorithm = jwt.get_algorithm_by_name(kwargs['algorithm']) + private_key = kwargs.pop('private_key') + kwargs['private_key_jwk'] = algorithm.to_jwk(private_key) + kwargs['public_key_jwk'] = algorithm.to_jwk(private_key.public_key()) + super().__init__(**kwargs) + + @property + def private_key(self): + # pylint: disable=protected-access,import-outside-toplevel + # cryptography performs expensive checks when loading RSA private keys. + # Since we only load keys we generated ourselves with help of cryptography, + # these checks are unnecessary. + import cryptography.hazmat.backends.openssl + cryptography.hazmat.backends.openssl.backend._rsa_skip_check_key = True + res = jwt.get_algorithm_by_name(self.algorithm).from_jwk(self.private_key_jwk) + cryptography.hazmat.backends.openssl.backend._rsa_skip_check_key = False + return res + + @property + def public_key(self): + return jwt.get_algorithm_by_name(self.algorithm).from_jwk(self.public_key_jwk) + + @property + def public_key_jwks_dict(self): + res = json.loads(self.public_key_jwk) + res['kid'] = self.id + res['alg'] = self.algorithm + res['use'] = 'sig' + # RFC7517 4.3 "The "use" and "key_ops" JWK members SHOULD NOT be used together [...]" + res.pop('key_ops', None) + return res + + def encode_jwt(self, payload): + if not self.active: + raise jwt.exceptions.InvalidKeyError(f'Key {self.id} not active') + res = jwt.encode(payload, key=self.private_key, algorithm=self.algorithm, headers={'kid': self.id}) + # pyjwt pre-v2 compat (Buster/Bullseye) + if isinstance(res, bytes): + res = res.decode() + return res + + # Hash algorithm for at_hash/c_hash from OpenID Connect Core 1.0 section 3.1.3.6 + def oidc_hash(self, value): + # pylint: disable=import-outside-toplevel + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.backends import default_backend # Only required for Buster + hash_alg = jwt.get_algorithm_by_name(self.algorithm).hash_alg + digest = hashes.Hash(hash_alg(), backend=default_backend()) + digest.update(value) + return base64.urlsafe_b64encode( + digest.finalize()[:hash_alg.digest_size // 2] + ).decode('ascii').rstrip('=') + + @classmethod + def get_preferred_key(cls, algorithm='RS256'): + return cls.query.filter_by(active=True, algorithm=algorithm).order_by(OAuth2Key.created.desc()).first() + + @classmethod + def get_available_algorithms(cls): + return ['RS256'] + + @classmethod + def decode_jwt(cls, data, algorithms=('RS256',), **kwargs): + headers = jwt.get_unverified_header(data) + if 'kid' not in headers: + raise jwt.exceptions.InvalidKeyError('JWT without kid') + kid = headers['kid'] + key = cls.query.get(kid) + if not key: + raise jwt.exceptions.InvalidKeyError(f'Key {kid} not found') + if not key.active: + raise jwt.exceptions.InvalidKeyError(f'Key {kid} not active') + return jwt.decode(data, key=key.public_key, algorithms=algorithms, **kwargs) + + @classmethod + def generate_rsa_key(cls, public_exponent=65537, key_size=3072): + # pylint: disable=import-outside-toplevel + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.backends import default_backend # Only required for Buster + return cls(algorithm='RS256', private_key=rsa.generate_private_key(public_exponent=public_exponent, key_size=key_size, backend=default_backend())) diff --git a/uffd/models/role.py b/uffd/models/role.py index 27111b8c0524a7c6b335989b5a91b24b9a439e2d..cc0c931dcae606d4bea1eaaa900849d8721b4d18 100644 --- a/uffd/models/role.py +++ b/uffd/models/role.py @@ -11,7 +11,7 @@ class RoleGroup(db.Model): role = relationship('Role', back_populates='groups') group_id = Column(Integer(), ForeignKey('group.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) group = relationship('Group') - requires_mfa = Column(Boolean(), default=False, nullable=False) + requires_mfa = Column(Boolean(create_constraint=True), default=False, nullable=False) # pylint: disable=E1101 role_members = db.Table('role_members', @@ -99,9 +99,9 @@ class Role(db.Model): # Roles that are managed externally (e.g. by Ansible) can be locked to # prevent accidental editing of name, moderator group, included roles # and groups as well as deletion in the web interface. - locked = Column(Boolean(), default=False, nullable=False) + locked = Column(Boolean(create_constraint=True), default=False, nullable=False) - is_default = Column(Boolean(), default=False, nullable=False) + is_default = Column(Boolean(create_constraint=True), default=False, nullable=False) @property def members_effective(self): diff --git a/uffd/models/service.py b/uffd/models/service.py index 1817954c7a0deab79adc915be6ac30151cea50e9..c37f6b37322d482fd1a09f914924024aeb661f01 100644 --- a/uffd/models/service.py +++ b/uffd/models/service.py @@ -26,16 +26,16 @@ class Service(db.Model): # parameter meant no access restrictions. Representing this state by # setting access_group_id to NULL would lead to a bad/unintuitive ondelete # behaviour. - limit_access = Column(Boolean(), default=True, nullable=False) + limit_access = Column(Boolean(create_constraint=True), default=True, nullable=False) access_group_id = Column(Integer(), ForeignKey('group.id', onupdate='CASCADE', ondelete='SET NULL'), nullable=True) access_group = relationship('Group') oauth2_clients = relationship('OAuth2Client', back_populates='service', cascade='all, delete-orphan') api_clients = relationship('APIClient', back_populates='service', cascade='all, delete-orphan') - remailer_mode = Column(Enum(RemailerMode), default=RemailerMode.DISABLED, nullable=False) - enable_email_preferences = Column(Boolean(), default=False, nullable=False) - hide_deactivated_users = Column(Boolean(), default=False, nullable=False) + remailer_mode = Column(Enum(RemailerMode, create_constraint=True), default=RemailerMode.DISABLED, nullable=False) + enable_email_preferences = Column(Boolean(create_constraint=True), default=False, nullable=False) + hide_deactivated_users = Column(Boolean(create_constraint=True), default=False, nullable=False) class ServiceUser(db.Model): '''Service-related configuration and state for a user @@ -63,7 +63,7 @@ class ServiceUser(db.Model): def has_email_preferences(self): return self.has_access and self.service.enable_email_preferences - remailer_overwrite_mode = Column(Enum(RemailerMode), default=None, nullable=True) + remailer_overwrite_mode = Column(Enum(RemailerMode, create_constraint=True), default=None, nullable=True) @property def effective_remailer_mode(self): @@ -116,6 +116,16 @@ class ServiceUser(db.Model): return remailer.build_v2_address(self.service_id, self.user_id) return self.real_email + # User.primary_email and ServiceUser.service_email can only be set to + # verified addresses, so this should always return True + @property + def email_verified(self): + if self.effective_remailer_mode != RemailerMode.DISABLED: + return True + if self.has_email_preferences and self.service_email: + return self.service_email.verified + return self.user.primary_email.verified + @classmethod def filter_query_by_email(cls, query, email): '''Filter query of ServiceUser by ServiceUser.email''' @@ -178,7 +188,7 @@ def create_service_users(session, flush_context): # pylint: disable=unused-argum return db.session.execute(db.insert(ServiceUser).from_select( ['service_id', 'user_id'], - db.select([Service.id, User.id]).where(db.or_( + db.select([Service.id, User.id]).select_from(db.join(Service, User, db.true())).where(db.or_( Service.id.in_(new_service_ids), User.id.in_(new_user_ids), )) @@ -193,7 +203,7 @@ def create_missing_service_users(): # pylint: disable=no-member db.session.execute(db.insert(ServiceUser).from_select( ['service_id', 'user_id'], - db.select([Service.id, User.id]).where(db.not_( + db.select([Service.id, User.id]).select_from(db.join(Service, User, db.true())).where(db.not_( ServiceUser.query.filter( ServiceUser.service_id == Service.id, ServiceUser.user_id == User.id diff --git a/uffd/models/session.py b/uffd/models/session.py index 19fa3ad868af7cc171903692ed0ebdb805bd7974..368dbaaefece27c27bbe8d8c34dcb1aa3f2019de 100644 --- a/uffd/models/session.py +++ b/uffd/models/session.py @@ -2,13 +2,94 @@ import datetime import secrets import enum -from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Enum +from flask import current_app +from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Enum, Text, Boolean from sqlalchemy.orm import relationship from sqlalchemy.ext.hybrid import hybrid_property +from flask_babel import gettext as _ + +try: + from ua_parser import user_agent_parser + USER_AGENT_PARSER_SUPPORTED = True +except ImportError: + USER_AGENT_PARSER_SUPPORTED = False from uffd.database import db from uffd.utils import token_typeable from uffd.tasks import cleanup_task +from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash + +@cleanup_task.delete_by_attribute('expired') +class Session(db.Model): + __tablename__ = 'session' + + id = Column(Integer(), primary_key=True, autoincrement=True) + _secret = Column('secret', Text) + secret = PasswordHashAttribute('_secret', HighEntropyPasswordHash) + + user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) + user = relationship('User', back_populates='sessions') + oauth2_grants = relationship('OAuth2Grant', back_populates='session', cascade='all, delete-orphan') + oauth2_tokens = relationship('OAuth2Token', back_populates='session', cascade='all, delete-orphan') + + created = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + last_used = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + user_agent = Column(Text, nullable=False, default='') + ip_address = Column(Text) + + mfa_done = Column(Boolean(create_constraint=True), default=False, nullable=False) + + @hybrid_property + def expired(self): + if self.created is None or self.last_used is None: + return False + if self.created < datetime.datetime.utcnow() - datetime.timedelta(seconds=current_app.config['SESSION_LIFETIME_SECONDS']): + return True + if self.last_used < datetime.datetime.utcnow() - current_app.permanent_session_lifetime: + return True + return False + + @expired.expression + def expired(cls): # pylint: disable=no-self-argument + return db.or_( + cls.created < datetime.datetime.utcnow() - datetime.timedelta(seconds=current_app.config['SESSION_LIFETIME_SECONDS']), + cls.last_used < datetime.datetime.utcnow() - current_app.permanent_session_lifetime, + ) + + @property + def user_agent_browser(self): + # pylint: disable=too-many-return-statements + if USER_AGENT_PARSER_SUPPORTED and not getattr(self, 'DISABLE_USER_AGENT_PARSER', False): + family = user_agent_parser.ParseUserAgent(self.user_agent)['family'] + return family if family != 'Other' else _('Unknown') + + if ' OPR/' in self.user_agent: + return 'Opera' + if ' Edg/' in self.user_agent: + return 'Microsoft Edge' + if ' Safari/' in self.user_agent and ' Chrome/' not in self.user_agent: + return 'Safari' + if ' Chrome/' in self.user_agent: + return 'Chrome' + if ' Firefox/' in self.user_agent: + return 'Firefox' + return _('Unknown') + + @property + def user_agent_platform(self): + if USER_AGENT_PARSER_SUPPORTED and not getattr(self, 'DISABLE_USER_AGENT_PARSER', False): + family = user_agent_parser.ParseOS(self.user_agent)['family'] + return family if family != 'Other' else _('Unknown') + + sysinfo = ([''] + self.user_agent.split('(', 1))[-1].split(')', 0)[0] + platforms = [ + 'Android', 'Linux', 'OpenBSD', 'FreeBSD', 'NetBSD', 'Windows', 'iPhone', + 'iPad', 'Macintosh', + ] + for platform in platforms: + if platform in sysinfo: + return platform + return _('Unknown') # Device login provides a convenient and secure way to log into SSO-enabled # services on a secondary device without entering the user password or @@ -70,12 +151,11 @@ class DeviceLoginInitiation(db.Model): existing and possibly attacker-controlled code). An initiation code is securly bound to the session that it was created - with by storing both id and secret in the encrypted and authenticated - session cookie.''' + with by storing both id and secret in the authenticated session cookie.''' __tablename__ = 'device_login_initiation' id = Column(Integer(), primary_key=True, autoincrement=True) - type = Column(Enum(DeviceLoginType), nullable=False) + type = Column(Enum(DeviceLoginType, create_constraint=True), nullable=False) code0 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3)) code1 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3)) secret = Column(String(128), nullable=False, default=lambda: secrets.token_hex(64)) @@ -107,7 +187,7 @@ class DeviceLoginConfirmation(db.Model): A confirmation code is generated and displayed when an authenticated user enters an initiation code and confirms the device login attempt. Every - instance is bound to both an initiation code and a user. + instance is bound to both an initiation code and a login session. The code attribute is formed out of two indepentently unique parts to ensure that at any time all existing codes differ in at least two @@ -120,8 +200,8 @@ class DeviceLoginConfirmation(db.Model): name='fk_device_login_confirmation_initiation_id_', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) initiation = relationship('DeviceLoginInitiation', back_populates='confirmations') - user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False, unique=True) - user = relationship('User') + session_id = Column(Integer(), ForeignKey('session.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False, unique=True) + session = relationship('Session') code0 = Column(String(32), nullable=False, default=lambda: token_typeable(1)) code1 = Column(String(32), nullable=False, default=lambda: token_typeable(1)) diff --git a/uffd/models/user.py b/uffd/models/user.py index 2a9339079f0c9b864e40fd214ea619323a83ee97..70cba2588dd54d666a704675a7d72c18531e53a8 100644 --- a/uffd/models/user.py +++ b/uffd/models/user.py @@ -167,13 +167,15 @@ class User(db.Model): _password = Column('pwhash', Text(), nullable=True) password = PasswordHashAttribute('_password', LowEntropyPasswordHash) - is_service_user = Column(Boolean(), default=False, nullable=False) - is_deactivated = Column(Boolean(), default=False, nullable=False) + is_service_user = Column(Boolean(create_constraint=True), default=False, nullable=False) + is_deactivated = Column(Boolean(create_constraint=True), default=False, nullable=False) groups = relationship('Group', secondary='user_groups', back_populates='members') roles = relationship('Role', secondary='role_members', back_populates='members') service_users = relationship('ServiceUser', viewonly=True) + sessions = relationship('Session', back_populates='user', cascade='all, delete-orphan') + def __init__(self, primary_email_address=None, **kwargs): super().__init__(**kwargs) if primary_email_address is not None: @@ -279,7 +281,7 @@ class UserEmail(db.Model): return value # True or None/NULL (not False, see constraints below) - _verified = Column('verified', Boolean(), nullable=True) + _verified = Column('verified', Boolean(create_constraint=True), nullable=True) @hybrid_property def verified(self): @@ -302,7 +304,7 @@ class UserEmail(db.Model): # on a per-row basis. # True or None/NULL if disabled (not False, see constraints below) enable_strict_constraints = Column( - Boolean(), + Boolean(create_constraint=True), nullable=True, default=db.select([db.case([(FeatureFlag.unique_email_addresses.expr, True)], else_=None)]) ) diff --git a/uffd/password_hash.py b/uffd/password_hash.py index bd941ea1a76befb7575e6435686bec1c7bfa17d1..e2909064699dc5bb118d7910e7fa75afacc6e7d1 100644 --- a/uffd/password_hash.py +++ b/uffd/password_hash.py @@ -1,7 +1,7 @@ import secrets import hashlib import base64 -from crypt import crypt +from crypt import crypt # pylint: disable=deprecated-module import argon2 def build_value(method_name, data): @@ -206,7 +206,7 @@ class InvalidPasswordHash: def __init__(self, value=None): self.value = value - # pylint: disable=no-self-use,unused-argument + # pylint: disable=unused-argument def verify(self, password): return False diff --git a/uffd/remailer.py b/uffd/remailer.py index f3ffd0792f59ba7c3f6886c92a100f47a845f8a0..14c42f0a5d40a31df8d2261170444e9f31f1f9dc 100644 --- a/uffd/remailer.py +++ b/uffd/remailer.py @@ -14,8 +14,6 @@ class Remailer: Version 2 of the remailer address format is tolerant to case conversions at the cost of being slightly longer.''' - # pylint: disable=no-self-use - @property def configured(self): return bool(current_app.config['REMAILER_DOMAIN']) diff --git a/uffd/static/bootstrap/bootstrap-prefers-dark-color-only.css b/uffd/static/bootstrap/bootstrap-prefers-dark-color-only.css new file mode 100644 index 0000000000000000000000000000000000000000..b6f67b5b46c4ff5230081959ecd30b34d1c80406 --- /dev/null +++ b/uffd/static/bootstrap/bootstrap-prefers-dark-color-only.css @@ -0,0 +1,2371 @@ +/*! + * Bootstrap-Dark v4.0.0 (https://github.com/ForEvolve/bootstrap-dark) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +@media (prefers-color-scheme:dark) { + body { + color: #d3d3d3; + background-color: #191d21 + } + abbr[data-original-title], + abbr[title] { + border-bottom: 0 + } + a { + color: #adadad; + background-color: transparent + } + a:hover { + color: #878787 + } + a:not([href]):not([class]) { + color: inherit + } + a:not([href]):not([class]):hover { + color: inherit + } + caption { + color: #6c757d + } + fieldset { + border: 0 + } + legend { + color: inherit + } + hr { + border: 0; + border-top: 1px solid rgba(255,255,255,.1) + } + .mark, + mark { + background-color: #fcf8e3 + } + .blockquote-footer { + color: #6c757d + } + .img-thumbnail { + background-color: #fff; + border: 1px solid #dee2e6 + } + .figure-caption { + color: #6c757d + } + code { + color: #e83e8c + } + a > code { + color: inherit + } + kbd { + color: #fff; + background-color: #212529 + } + pre { + color: #f8f9fa + } + pre code { + color: inherit + } +} +@media (prefers-color-scheme:dark) { + .table { + color: #d3d3d3 + } + .table td, + .table th { + border-top: 1px solid #343a40 + } + .table thead th { + border-bottom: 2px solid #343a40 + } + .table tbody + tbody { + border-top: 2px solid #343a40 + } + .table-bordered { + border: 1px solid #343a40 + } + .table-bordered td, + .table-bordered th { + border: 1px solid #343a40 + } + .table-borderless tbody + tbody, + .table-borderless td, + .table-borderless th, + .table-borderless thead th { + border: 0 + } + .table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(0,0,0,.05) + } + .table-hover tbody tr:hover { + color: #d3d3d3; + background-color: rgba(0,0,0,.075) + } + .table-primary, + .table-primary > td, + .table-primary > th { + background-color: #c6e1ff + } + .table-primary tbody + tbody, + .table-primary td, + .table-primary th, + .table-primary thead th { + border-color: #95c8ff + } + .table-hover .table-primary:hover { + background-color: #add4ff + } + .table-hover .table-primary:hover > td, + .table-hover .table-primary:hover > th { + background-color: #add4ff + } + .table-secondary, + .table-secondary > td, + .table-secondary > th { + background-color: #d6d8db + } + .table-secondary tbody + tbody, + .table-secondary td, + .table-secondary th, + .table-secondary thead th { + border-color: #b3b7bb + } + .table-hover .table-secondary:hover { + background-color: #c8cbcf + } + .table-hover .table-secondary:hover > td, + .table-hover .table-secondary:hover > th { + background-color: #c8cbcf + } + .table-success, + .table-success > td, + .table-success > th { + background-color: #c3e6cb + } + .table-success tbody + tbody, + .table-success td, + .table-success th, + .table-success thead th { + border-color: #8fd19e + } + .table-hover .table-success:hover { + background-color: #b1dfbb + } + .table-hover .table-success:hover > td, + .table-hover .table-success:hover > th { + background-color: #b1dfbb + } + .table-info, + .table-info > td, + .table-info > th { + background-color: #bee5eb + } + .table-info tbody + tbody, + .table-info td, + .table-info th, + .table-info thead th { + border-color: #86cfda + } + .table-hover .table-info:hover { + background-color: #abdde5 + } + .table-hover .table-info:hover > td, + .table-hover .table-info:hover > th { + background-color: #abdde5 + } + .table-warning, + .table-warning > td, + .table-warning > th { + background-color: #fedbbd + } + .table-warning tbody + tbody, + .table-warning td, + .table-warning th, + .table-warning thead th { + border-color: #febc85 + } + .table-hover .table-warning:hover { + background-color: #fecda4 + } + .table-hover .table-warning:hover > td, + .table-hover .table-warning:hover > th { + background-color: #fecda4 + } + .table-danger, + .table-danger > td, + .table-danger > th { + background-color: #f5c6cb + } + .table-danger tbody + tbody, + .table-danger td, + .table-danger th, + .table-danger thead th { + border-color: #ed969e + } + .table-hover .table-danger:hover { + background-color: #f1b0b7 + } + .table-hover .table-danger:hover > td, + .table-hover .table-danger:hover > th { + background-color: #f1b0b7 + } + .table-light, + .table-light > td, + .table-light > th { + background-color: #f6f7f8 + } + .table-light tbody + tbody, + .table-light td, + .table-light th, + .table-light thead th { + border-color: #eef0f2 + } + .table-hover .table-light:hover { + background-color: #e8eaed + } + .table-hover .table-light:hover > td, + .table-hover .table-light:hover > th { + background-color: #e8eaed + } + .table-dark, + .table-dark > td, + .table-dark > th { + background-color: #c6c8ca + } + .table-dark tbody + tbody, + .table-dark td, + .table-dark th, + .table-dark thead th { + border-color: #95999c + } + .table-hover .table-dark:hover { + background-color: #b9bbbe + } + .table-hover .table-dark:hover > td, + .table-hover .table-dark:hover > th { + background-color: #b9bbbe + } + .table-active, + .table-active > td, + .table-active > th { + background-color: rgba(0,0,0,.075) + } + .table-hover .table-active:hover { + background-color: rgba(0,0,0,.075) + } + .table-hover .table-active:hover > td, + .table-hover .table-active:hover > th { + background-color: rgba(0,0,0,.075) + } + .table .thead-dark th { + color: #dee2e6; + background-color: #343a40; + border-color: #454d55 + } + .table .thead-light th { + color: #495057; + background-color: #e9ecef; + border-color: #343a40 + } + .table-dark { + color: #dee2e6; + background-color: #343a40 + } + .table-dark td, + .table-dark th, + .table-dark thead th { + border-color: #454d55 + } + .table-dark.table-bordered { + border: 0 + } + .table-dark.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255,255,255,.05) + } + .table-dark.table-hover tbody tr:hover { + color: #fff; + background-color: rgba(255,255,255,.075) + } +} +@media (prefers-color-scheme:dark) and (max-width:575.98px) { + .table-responsive-sm > .table-bordered { + border: 0 + } +} +@media (prefers-color-scheme:dark) and (max-width:767.98px) { + .table-responsive-md > .table-bordered { + border: 0 + } +} +@media (prefers-color-scheme:dark) and (max-width:991.98px) { + .table-responsive-lg > .table-bordered { + border: 0 + } +} +@media (prefers-color-scheme:dark) and (max-width:1199.98px) { + .table-responsive-xl > .table-bordered { + border: 0 + } +} +@media (prefers-color-scheme:dark) { + .table-responsive > .table-bordered { + border: 0 + } + .table-primary, + .table-primary > td, + .table-primary > th { + color: #343a40 + } + .table-hover .table-primary:hover { + color: #343a40 + } + .table-hover .table-primary:hover > td, + .table-hover .table-primary:hover > th { + color: #343a40 + } + .table-secondary, + .table-secondary > td, + .table-secondary > th { + color: #343a40 + } + .table-hover .table-secondary:hover { + color: #343a40 + } + .table-hover .table-secondary:hover > td, + .table-hover .table-secondary:hover > th { + color: #343a40 + } + .table-success, + .table-success > td, + .table-success > th { + color: #343a40 + } + .table-hover .table-success:hover { + color: #343a40 + } + .table-hover .table-success:hover > td, + .table-hover .table-success:hover > th { + color: #343a40 + } + .table-info, + .table-info > td, + .table-info > th { + color: #343a40 + } + .table-hover .table-info:hover { + color: #343a40 + } + .table-hover .table-info:hover > td, + .table-hover .table-info:hover > th { + color: #343a40 + } + .table-warning, + .table-warning > td, + .table-warning > th { + color: #343a40 + } + .table-hover .table-warning:hover { + color: #343a40 + } + .table-hover .table-warning:hover > td, + .table-hover .table-warning:hover > th { + color: #343a40 + } + .table-danger, + .table-danger > td, + .table-danger > th { + color: #343a40 + } + .table-hover .table-danger:hover { + color: #343a40 + } + .table-hover .table-danger:hover > td, + .table-hover .table-danger:hover > th { + color: #343a40 + } + .table-light, + .table-light > td, + .table-light > th { + color: #343a40 + } + .table-hover .table-light:hover { + color: #343a40 + } + .table-hover .table-light:hover > td, + .table-hover .table-light:hover > th { + color: #343a40 + } + .table-dark, + .table-dark > td, + .table-dark > th { + color: #343a40 + } + .table-hover .table-dark:hover { + color: #343a40 + } + .table-hover .table-dark:hover > td, + .table-hover .table-dark:hover > th { + color: #343a40 + } + .table-active, + .table-active > td, + .table-active > th { + color: #e9ecef + } + .table-hover .table-active:hover { + color: #e9ecef + } + .table-hover .table-active:hover > td, + .table-hover .table-active:hover > th { + color: #e9ecef + } + .table-dark { + color: #dee2e6 + } + .form-control { + color: #dee2e6; + background-color: #000; + border: 1px solid #6c757d + } +} +@media (prefers-color-scheme:dark) { + .form-control::-ms-expand { + background-color: transparent; + border: 0 + } + .form-control:focus { + color: #dee2e6; + background-color: #191d21; + border-color: #b3d7ff; + box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) + } + .form-control::-webkit-input-placeholder { + color: #6c757d + } + .form-control::-moz-placeholder { + color: #6c757d + } + .form-control::-ms-input-placeholder { + color: #6c757d + } + .form-control::placeholder { + color: #6c757d + } + .form-control:disabled, + .form-control[readonly] { + background-color: #343a40 + } + select.form-control:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #dee2e6 + } + select.form-control:focus::-ms-value { + color: #dee2e6; + background-color: #000 + } + .form-control-plaintext { + color: #212529; + background-color: transparent; + border: solid transparent + } + .form-check-input:disabled ~ .form-check-label, + .form-check-input[disabled] ~ .form-check-label { + color: #6c757d + } + .valid-feedback { + color: #28a745 + } + .valid-tooltip { + color: #e9ecef; + background-color: rgba(40,167,69,.9) + } + .form-control.is-valid, + .was-validated .form-control:valid { + border-color: #28a745; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") + } + .form-control.is-valid:focus, + .was-validated .form-control:valid:focus { + border-color: #28a745; + box-shadow: 0 0 0 .2rem rgba(40,167,69,.25) + } + .custom-select.is-valid, + .was-validated .custom-select:valid { + border-color: #28a745; + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat,#000 url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat + } + .custom-select.is-valid:focus, + .was-validated .custom-select:valid:focus { + border-color: #28a745; + box-shadow: 0 0 0 .2rem rgba(40,167,69,.25) + } + .form-check-input.is-valid ~ .form-check-label, + .was-validated .form-check-input:valid ~ .form-check-label { + color: #28a745 + } + .custom-control-input.is-valid ~ .custom-control-label, + .was-validated .custom-control-input:valid ~ .custom-control-label { + color: #28a745 + } + .custom-control-input.is-valid ~ .custom-control-label::before, + .was-validated .custom-control-input:valid ~ .custom-control-label::before { + border-color: #28a745 + } + .custom-control-input.is-valid:checked ~ .custom-control-label::before, + .was-validated .custom-control-input:valid:checked ~ .custom-control-label::before { + border-color: #34ce57; + background-color: #34ce57 + } + .custom-control-input.is-valid:focus ~ .custom-control-label::before, + .was-validated .custom-control-input:valid:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 .2rem rgba(40,167,69,.25) + } + .custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before, + .was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before { + border-color: #28a745 + } + .custom-file-input.is-valid ~ .custom-file-label, + .was-validated .custom-file-input:valid ~ .custom-file-label { + border-color: #28a745 + } + .custom-file-input.is-valid:focus ~ .custom-file-label, + .was-validated .custom-file-input:valid:focus ~ .custom-file-label { + border-color: #28a745; + box-shadow: 0 0 0 .2rem rgba(40,167,69,.25) + } + .invalid-feedback { + color: #dc3545 + } + .invalid-tooltip { + color: #e9ecef; + background-color: rgba(220,53,69,.9) + } + .form-control.is-invalid, + .was-validated .form-control:invalid { + border-color: #dc3545; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") + } + .form-control.is-invalid:focus, + .was-validated .form-control:invalid:focus { + border-color: #dc3545; + box-shadow: 0 0 0 .2rem rgba(220,53,69,.25) + } + .custom-select.is-invalid, + .was-validated .custom-select:invalid { + border-color: #dc3545; + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat,#000 url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat + } + .custom-select.is-invalid:focus, + .was-validated .custom-select:invalid:focus { + border-color: #dc3545; + box-shadow: 0 0 0 .2rem rgba(220,53,69,.25) + } + .form-check-input.is-invalid ~ .form-check-label, + .was-validated .form-check-input:invalid ~ .form-check-label { + color: #dc3545 + } + .custom-control-input.is-invalid ~ .custom-control-label, + .was-validated .custom-control-input:invalid ~ .custom-control-label { + color: #dc3545 + } + .custom-control-input.is-invalid ~ .custom-control-label::before, + .was-validated .custom-control-input:invalid ~ .custom-control-label::before { + border-color: #dc3545 + } + .custom-control-input.is-invalid:checked ~ .custom-control-label::before, + .was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before { + border-color: #e4606d; + background-color: #e4606d + } + .custom-control-input.is-invalid:focus ~ .custom-control-label::before, + .was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 .2rem rgba(220,53,69,.25) + } + .custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before, + .was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before { + border-color: #dc3545 + } + .custom-file-input.is-invalid ~ .custom-file-label, + .was-validated .custom-file-input:invalid ~ .custom-file-label { + border-color: #dc3545 + } + .custom-file-input.is-invalid:focus ~ .custom-file-label, + .was-validated .custom-file-input:invalid:focus ~ .custom-file-label { + border-color: #dc3545; + box-shadow: 0 0 0 .2rem rgba(220,53,69,.25) + } +} +@media (prefers-color-scheme:dark) { + .btn { + color: #d3d3d3; + background-color: transparent; + border: 1px solid transparent + } +} +@media (prefers-color-scheme:dark) { + .btn:hover { + color: #d3d3d3 + } + .btn.focus, + .btn:focus { + box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) + } + .btn-primary { + color: #e9ecef; + background-color: #3395ff; + border-color: #3395ff + } + .btn-primary:hover { + color: #e9ecef; + background-color: #0d82ff; + border-color: #007bff + } + .btn-primary.focus, + .btn-primary:focus { + color: #e9ecef; + background-color: #0d82ff; + border-color: #007bff; + box-shadow: 0 0 0 .2rem rgba(78,162,253,.5) + } + .btn-primary.disabled, + .btn-primary:disabled { + color: #e9ecef; + background-color: #3395ff; + border-color: #3395ff + } + .btn-primary:not(:disabled):not(.disabled).active, + .btn-primary:not(:disabled):not(.disabled):active, + .show > .btn-primary.dropdown-toggle { + color: #e9ecef; + background-color: #007bff; + border-color: #0075f2 + } + .btn-primary:not(:disabled):not(.disabled).active:focus, + .btn-primary:not(:disabled):not(.disabled):active:focus, + .show > .btn-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(78,162,253,.5) + } + .btn-secondary { + color: #e9ecef; + background-color: #6c757d; + border-color: #6c757d + } + .btn-secondary:hover { + color: #e9ecef; + background-color: #5a6268; + border-color: #545b62 + } + .btn-secondary.focus, + .btn-secondary:focus { + color: #e9ecef; + background-color: #5a6268; + border-color: #545b62; + box-shadow: 0 0 0 .2rem rgba(127,135,142,.5) + } + .btn-secondary.disabled, + .btn-secondary:disabled { + color: #e9ecef; + background-color: #6c757d; + border-color: #6c757d + } + .btn-secondary:not(:disabled):not(.disabled).active, + .btn-secondary:not(:disabled):not(.disabled):active, + .show > .btn-secondary.dropdown-toggle { + color: #e9ecef; + background-color: #545b62; + border-color: #4e555b + } + .btn-secondary:not(:disabled):not(.disabled).active:focus, + .btn-secondary:not(:disabled):not(.disabled):active:focus, + .show > .btn-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(127,135,142,.5) + } + .btn-success { + color: #e9ecef; + background-color: #28a745; + border-color: #28a745 + } + .btn-success:hover { + color: #e9ecef; + background-color: #218838; + border-color: #1e7e34 + } + .btn-success.focus, + .btn-success:focus { + color: #e9ecef; + background-color: #218838; + border-color: #1e7e34; + box-shadow: 0 0 0 .2rem rgba(69,177,95,.5) + } + .btn-success.disabled, + .btn-success:disabled { + color: #e9ecef; + background-color: #28a745; + border-color: #28a745 + } + .btn-success:not(:disabled):not(.disabled).active, + .btn-success:not(:disabled):not(.disabled):active, + .show > .btn-success.dropdown-toggle { + color: #e9ecef; + background-color: #1e7e34; + border-color: #1c7430 + } + .btn-success:not(:disabled):not(.disabled).active:focus, + .btn-success:not(:disabled):not(.disabled):active:focus, + .show > .btn-success.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(69,177,95,.5) + } + .btn-info { + color: #e9ecef; + background-color: #17a2b8; + border-color: #17a2b8 + } + .btn-info:hover { + color: #e9ecef; + background-color: #138496; + border-color: #117a8b + } + .btn-info.focus, + .btn-info:focus { + color: #e9ecef; + background-color: #138496; + border-color: #117a8b; + box-shadow: 0 0 0 .2rem rgba(55,173,192,.5) + } + .btn-info.disabled, + .btn-info:disabled { + color: #e9ecef; + background-color: #17a2b8; + border-color: #17a2b8 + } + .btn-info:not(:disabled):not(.disabled).active, + .btn-info:not(:disabled):not(.disabled):active, + .show > .btn-info.dropdown-toggle { + color: #e9ecef; + background-color: #117a8b; + border-color: #10707f + } + .btn-info:not(:disabled):not(.disabled).active:focus, + .btn-info:not(:disabled):not(.disabled):active:focus, + .show > .btn-info.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(55,173,192,.5) + } + .btn-warning { + color: #343a40; + background-color: #fd7e14; + border-color: #fd7e14 + } + .btn-warning:hover { + color: #e9ecef; + background-color: #e96b02; + border-color: #dc6502 + } + .btn-warning.focus, + .btn-warning:focus { + color: #e9ecef; + background-color: #e96b02; + border-color: #dc6502; + box-shadow: 0 0 0 .2rem rgba(223,116,27,.5) + } + .btn-warning.disabled, + .btn-warning:disabled { + color: #343a40; + background-color: #fd7e14; + border-color: #fd7e14 + } + .btn-warning:not(:disabled):not(.disabled).active, + .btn-warning:not(:disabled):not(.disabled):active, + .show > .btn-warning.dropdown-toggle { + color: #e9ecef; + background-color: #dc6502; + border-color: #cf5f02 + } + .btn-warning:not(:disabled):not(.disabled).active:focus, + .btn-warning:not(:disabled):not(.disabled):active:focus, + .show > .btn-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(223,116,27,.5) + } + .btn-danger { + color: #e9ecef; + background-color: #dc3545; + border-color: #dc3545 + } + .btn-danger:hover { + color: #e9ecef; + background-color: #c82333; + border-color: #bd2130 + } + .btn-danger.focus, + .btn-danger:focus { + color: #e9ecef; + background-color: #c82333; + border-color: #bd2130; + box-shadow: 0 0 0 .2rem rgba(222,80,95,.5) + } + .btn-danger.disabled, + .btn-danger:disabled { + color: #e9ecef; + background-color: #dc3545; + border-color: #dc3545 + } + .btn-danger:not(:disabled):not(.disabled).active, + .btn-danger:not(:disabled):not(.disabled):active, + .show > .btn-danger.dropdown-toggle { + color: #e9ecef; + background-color: #bd2130; + border-color: #b21f2d + } + .btn-danger:not(:disabled):not(.disabled).active:focus, + .btn-danger:not(:disabled):not(.disabled):active:focus, + .show > .btn-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(222,80,95,.5) + } + .btn-light { + color: #343a40; + background-color: #dee2e6; + border-color: #dee2e6 + } + .btn-light:hover { + color: #343a40; + background-color: #c8cfd6; + border-color: #c1c9d0 + } + .btn-light.focus, + .btn-light:focus { + color: #343a40; + background-color: #c8cfd6; + border-color: #c1c9d0; + box-shadow: 0 0 0 .2rem rgba(197,201,205,.5) + } + .btn-light.disabled, + .btn-light:disabled { + color: #343a40; + background-color: #dee2e6; + border-color: #dee2e6 + } + .btn-light:not(:disabled):not(.disabled).active, + .btn-light:not(:disabled):not(.disabled):active, + .show > .btn-light.dropdown-toggle { + color: #343a40; + background-color: #c1c9d0; + border-color: #bac2cb + } + .btn-light:not(:disabled):not(.disabled).active:focus, + .btn-light:not(:disabled):not(.disabled):active:focus, + .show > .btn-light.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(197,201,205,.5) + } + .btn-dark { + color: #e9ecef; + background-color: #343a40; + border-color: #343a40 + } + .btn-dark:hover { + color: #e9ecef; + background-color: #23272b; + border-color: #1d2124 + } + .btn-dark.focus, + .btn-dark:focus { + color: #e9ecef; + background-color: #23272b; + border-color: #1d2124; + box-shadow: 0 0 0 .2rem rgba(79,85,90,.5) + } + .btn-dark.disabled, + .btn-dark:disabled { + color: #e9ecef; + background-color: #343a40; + border-color: #343a40 + } + .btn-dark:not(:disabled):not(.disabled).active, + .btn-dark:not(:disabled):not(.disabled):active, + .show > .btn-dark.dropdown-toggle { + color: #e9ecef; + background-color: #1d2124; + border-color: #171a1d + } + .btn-dark:not(:disabled):not(.disabled).active:focus, + .btn-dark:not(:disabled):not(.disabled):active:focus, + .show > .btn-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(79,85,90,.5) + } + .btn-outline-primary { + color: #3395ff; + border-color: #3395ff + } + .btn-outline-primary:hover { + color: #e9ecef; + background-color: #3395ff; + border-color: #3395ff + } + .btn-outline-primary.focus, + .btn-outline-primary:focus { + box-shadow: 0 0 0 .2rem rgba(51,149,255,.5) + } + .btn-outline-primary.disabled, + .btn-outline-primary:disabled { + color: #3395ff; + background-color: transparent + } + .btn-outline-primary:not(:disabled):not(.disabled).active, + .btn-outline-primary:not(:disabled):not(.disabled):active, + .show > .btn-outline-primary.dropdown-toggle { + color: #e9ecef; + background-color: #3395ff; + border-color: #3395ff + } + .btn-outline-primary:not(:disabled):not(.disabled).active:focus, + .btn-outline-primary:not(:disabled):not(.disabled):active:focus, + .show > .btn-outline-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(51,149,255,.5) + } + .btn-outline-secondary { + color: #6c757d; + border-color: #6c757d + } + .btn-outline-secondary:hover { + color: #e9ecef; + background-color: #6c757d; + border-color: #6c757d + } + .btn-outline-secondary.focus, + .btn-outline-secondary:focus { + box-shadow: 0 0 0 .2rem rgba(108,117,125,.5) + } + .btn-outline-secondary.disabled, + .btn-outline-secondary:disabled { + color: #6c757d; + background-color: transparent + } + .btn-outline-secondary:not(:disabled):not(.disabled).active, + .btn-outline-secondary:not(:disabled):not(.disabled):active, + .show > .btn-outline-secondary.dropdown-toggle { + color: #e9ecef; + background-color: #6c757d; + border-color: #6c757d + } + .btn-outline-secondary:not(:disabled):not(.disabled).active:focus, + .btn-outline-secondary:not(:disabled):not(.disabled):active:focus, + .show > .btn-outline-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(108,117,125,.5) + } + .btn-outline-success { + color: #28a745; + border-color: #28a745 + } + .btn-outline-success:hover { + color: #e9ecef; + background-color: #28a745; + border-color: #28a745 + } + .btn-outline-success.focus, + .btn-outline-success:focus { + box-shadow: 0 0 0 .2rem rgba(40,167,69,.5) + } + .btn-outline-success.disabled, + .btn-outline-success:disabled { + color: #28a745; + background-color: transparent + } + .btn-outline-success:not(:disabled):not(.disabled).active, + .btn-outline-success:not(:disabled):not(.disabled):active, + .show > .btn-outline-success.dropdown-toggle { + color: #e9ecef; + background-color: #28a745; + border-color: #28a745 + } + .btn-outline-success:not(:disabled):not(.disabled).active:focus, + .btn-outline-success:not(:disabled):not(.disabled):active:focus, + .show > .btn-outline-success.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(40,167,69,.5) + } + .btn-outline-info { + color: #17a2b8; + border-color: #17a2b8 + } + .btn-outline-info:hover { + color: #e9ecef; + background-color: #17a2b8; + border-color: #17a2b8 + } + .btn-outline-info.focus, + .btn-outline-info:focus { + box-shadow: 0 0 0 .2rem rgba(23,162,184,.5) + } + .btn-outline-info.disabled, + .btn-outline-info:disabled { + color: #17a2b8; + background-color: transparent + } + .btn-outline-info:not(:disabled):not(.disabled).active, + .btn-outline-info:not(:disabled):not(.disabled):active, + .show > .btn-outline-info.dropdown-toggle { + color: #e9ecef; + background-color: #17a2b8; + border-color: #17a2b8 + } + .btn-outline-info:not(:disabled):not(.disabled).active:focus, + .btn-outline-info:not(:disabled):not(.disabled):active:focus, + .show > .btn-outline-info.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(23,162,184,.5) + } + .btn-outline-warning { + color: #fd7e14; + border-color: #fd7e14 + } + .btn-outline-warning:hover { + color: #343a40; + background-color: #fd7e14; + border-color: #fd7e14 + } + .btn-outline-warning.focus, + .btn-outline-warning:focus { + box-shadow: 0 0 0 .2rem rgba(253,126,20,.5) + } + .btn-outline-warning.disabled, + .btn-outline-warning:disabled { + color: #fd7e14; + background-color: transparent + } + .btn-outline-warning:not(:disabled):not(.disabled).active, + .btn-outline-warning:not(:disabled):not(.disabled):active, + .show > .btn-outline-warning.dropdown-toggle { + color: #343a40; + background-color: #fd7e14; + border-color: #fd7e14 + } + .btn-outline-warning:not(:disabled):not(.disabled).active:focus, + .btn-outline-warning:not(:disabled):not(.disabled):active:focus, + .show > .btn-outline-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(253,126,20,.5) + } + .btn-outline-danger { + color: #dc3545; + border-color: #dc3545 + } + .btn-outline-danger:hover { + color: #e9ecef; + background-color: #dc3545; + border-color: #dc3545 + } + .btn-outline-danger.focus, + .btn-outline-danger:focus { + box-shadow: 0 0 0 .2rem rgba(220,53,69,.5) + } + .btn-outline-danger.disabled, + .btn-outline-danger:disabled { + color: #dc3545; + background-color: transparent + } + .btn-outline-danger:not(:disabled):not(.disabled).active, + .btn-outline-danger:not(:disabled):not(.disabled):active, + .show > .btn-outline-danger.dropdown-toggle { + color: #e9ecef; + background-color: #dc3545; + border-color: #dc3545 + } + .btn-outline-danger:not(:disabled):not(.disabled).active:focus, + .btn-outline-danger:not(:disabled):not(.disabled):active:focus, + .show > .btn-outline-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(220,53,69,.5) + } + .btn-outline-light { + color: #dee2e6; + border-color: #dee2e6 + } + .btn-outline-light:hover { + color: #343a40; + background-color: #dee2e6; + border-color: #dee2e6 + } + .btn-outline-light.focus, + .btn-outline-light:focus { + box-shadow: 0 0 0 .2rem rgba(222,226,230,.5) + } + .btn-outline-light.disabled, + .btn-outline-light:disabled { + color: #dee2e6; + background-color: transparent + } + .btn-outline-light:not(:disabled):not(.disabled).active, + .btn-outline-light:not(:disabled):not(.disabled):active, + .show > .btn-outline-light.dropdown-toggle { + color: #343a40; + background-color: #dee2e6; + border-color: #dee2e6 + } + .btn-outline-light:not(:disabled):not(.disabled).active:focus, + .btn-outline-light:not(:disabled):not(.disabled):active:focus, + .show > .btn-outline-light.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(222,226,230,.5) + } + .btn-outline-dark { + color: #343a40; + border-color: #343a40 + } + .btn-outline-dark:hover { + color: #e9ecef; + background-color: #343a40; + border-color: #343a40 + } + .btn-outline-dark.focus, + .btn-outline-dark:focus { + box-shadow: 0 0 0 .2rem rgba(52,58,64,.5) + } + .btn-outline-dark.disabled, + .btn-outline-dark:disabled { + color: #343a40; + background-color: transparent + } + .btn-outline-dark:not(:disabled):not(.disabled).active, + .btn-outline-dark:not(:disabled):not(.disabled):active, + .show > .btn-outline-dark.dropdown-toggle { + color: #e9ecef; + background-color: #343a40; + border-color: #343a40 + } + .btn-outline-dark:not(:disabled):not(.disabled).active:focus, + .btn-outline-dark:not(:disabled):not(.disabled):active:focus, + .show > .btn-outline-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 .2rem rgba(52,58,64,.5) + } + .btn-link { + color: #adadad + } + .btn-link:hover { + color: #878787 + } + .btn-link.disabled, + .btn-link:disabled { + color: #6c757d + } +} +@media (prefers-color-scheme:dark) { + .dropdown-toggle::after { + border-top: .3em solid; + border-right: .3em solid transparent; + border-bottom: 0; + border-left: .3em solid transparent + } + .dropdown-menu { + color: #d3d3d3; + background-color: #000; + border: 1px solid rgba(255,255,255,.15) + } +} +@media (prefers-color-scheme:dark) { + .dropup .dropdown-toggle::after { + border-top: 0; + border-right: .3em solid transparent; + border-bottom: .3em solid; + border-left: .3em solid transparent + } + .dropright .dropdown-toggle::after { + border-top: .3em solid transparent; + border-right: 0; + border-bottom: .3em solid transparent; + border-left: .3em solid + } + .dropleft .dropdown-toggle::before { + border-top: .3em solid transparent; + border-right: .3em solid; + border-bottom: .3em solid transparent + } + .dropdown-divider { + border-top: 1px solid #343a40 + } + .dropdown-item { + color: #f8f9fa; + background-color: transparent; + border: 0 + } + .dropdown-item:focus, + .dropdown-item:hover { + color: #fff; + background-color: #212529 + } + .dropdown-item.active, + .dropdown-item:active { + color: #000; + background-color: #3395ff + } + .dropdown-item.disabled, + .dropdown-item:disabled { + color: #ced4da; + background-color: transparent + } + .dropdown-header { + color: #ced4da + } + .dropdown-item-text { + color: #f8f9fa + } + .input-group-text { + color: #dee2e6; + background-color: #343a40; + border: 1px solid #6c757d + } + .custom-control-input:checked ~ .custom-control-label::before { + color: #fff; + border-color: #007bff; + background-color: #007bff + } + .custom-control-input:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) + } + .custom-control-input:focus:not(:checked) ~ .custom-control-label::before { + border-color: #80bdff + } + .custom-control-input:not(:disabled):active ~ .custom-control-label::before { + color: #fff; + background-color: #b3d7ff; + border-color: #b3d7ff + } + .custom-control-input:disabled ~ .custom-control-label, + .custom-control-input[disabled] ~ .custom-control-label { + color: #6c757d + } + .custom-control-input:disabled ~ .custom-control-label::before, + .custom-control-input[disabled] ~ .custom-control-label::before { + background-color: #e9ecef + } + .custom-control-label::before { + background-color: #fff; + border: 1px solid #adb5bd + } + .custom-control-label::after { + background: 50%/50% 50% no-repeat + } + .custom-checkbox .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e") + } + .custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before { + border-color: #007bff; + background-color: #007bff + } + .custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e") + } + .custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before { + background-color: rgba(0,123,255,.5) + } + .custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before { + background-color: rgba(0,123,255,.5) + } + .custom-radio .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e") + } + .custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before { + background-color: rgba(0,123,255,.5) + } + .custom-switch .custom-control-label::after { + background-color: #adb5bd + } +} +@media (prefers-color-scheme:dark) { + .custom-switch .custom-control-input:checked ~ .custom-control-label::after { + background-color: #fff + } + .custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before { + background-color: rgba(0,123,255,.5) + } + .custom-select { + color: #dee2e6; + background: #000 url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat; + border: 1px solid #6c757d + } + .custom-select:focus { + border-color: #80bdff; + box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) + } + .custom-select:focus::-ms-value { + color: #dee2e6; + background-color: #000 + } + .custom-select[multiple], + .custom-select[size]:not([size="1"]) { + background-image: none + } + .custom-select:disabled { + color: #ced4da; + background-color: #343a40 + } + .custom-select:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 #dee2e6 + } + .custom-file-input:focus ~ .custom-file-label { + border-color: #80bdff; + box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) + } + .custom-file-input:disabled ~ .custom-file-label, + .custom-file-input[disabled] ~ .custom-file-label { + background-color: #e9ecef + } + .custom-file-label { + color: #495057; + background-color: #fff; + border: 1px solid #ced4da + } + .custom-file-label::after { + color: #495057; + background-color: #e9ecef; + border-left: inherit + } + .custom-range { + background-color: transparent + } + .custom-range:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25) + } + .custom-range:focus::-moz-range-thumb { + box-shadow: 0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25) + } + .custom-range:focus::-ms-thumb { + box-shadow: 0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25) + } + .custom-range::-moz-focus-outer { + border: 0 + } + .custom-range::-webkit-slider-thumb { + background-color: #007bff; + border: 0 + } +} +@media (prefers-color-scheme:dark) { + .custom-range::-webkit-slider-thumb:active { + background-color: #b3d7ff + } + .custom-range::-webkit-slider-runnable-track { + color: transparent; + background-color: #dee2e6; + border-color: transparent + } + .custom-range::-moz-range-thumb { + background-color: #007bff; + border: 0 + } +} +@media (prefers-color-scheme:dark) { + .custom-range::-moz-range-thumb:active { + background-color: #b3d7ff + } + .custom-range::-moz-range-track { + color: transparent; + background-color: #dee2e6; + border-color: transparent + } + .custom-range::-ms-thumb { + background-color: #007bff; + border: 0 + } +} +@media (prefers-color-scheme:dark) { + .custom-range::-ms-thumb:active { + background-color: #b3d7ff + } + .custom-range::-ms-track { + color: transparent; + background-color: transparent; + border-color: transparent + } + .custom-range::-ms-fill-lower { + background-color: #dee2e6 + } + .custom-range::-ms-fill-upper { + background-color: #dee2e6 + } + .custom-range:disabled::-webkit-slider-thumb { + background-color: #adb5bd + } + .custom-range:disabled::-moz-range-thumb { + background-color: #adb5bd + } + .custom-range:disabled::-ms-thumb { + background-color: #adb5bd + } +} +@media (prefers-color-scheme:dark) { + .nav-link.disabled { + color: #6c757d + } + .nav-tabs { + border-bottom: 1px solid rgba(255,255,255,.125) + } + .nav-tabs .nav-link { + background-color: transparent; + border: 1px solid transparent + } + .nav-tabs .nav-link:focus, + .nav-tabs .nav-link:hover { + border-color: #495057 #495057 rgba(255,255,255,.125) + } + .nav-tabs .nav-link.disabled { + color: #6c757d; + background-color: transparent; + border-color: transparent + } + .nav-tabs .nav-item.show .nav-link, + .nav-tabs .nav-link.active { + color: #f8f9fa; + background-color: #191d21; + border-color: #495057 #495057 #191d21 + } + .nav-pills .nav-link { + background: 0 0; + border: 0 + } + .nav-pills .nav-link.active, + .nav-pills .show > .nav-link { + color: #fff; + background-color: #007bff + } + .navbar-toggler { + background-color: transparent; + border: 1px solid transparent + } + .navbar-toggler-icon { + background: 50%/100% 100% no-repeat + } +} +@media (prefers-color-scheme:dark) { + .navbar-light .navbar-brand { + color: rgba(0,0,0,.9) + } + .navbar-light .navbar-brand:focus, + .navbar-light .navbar-brand:hover { + color: rgba(0,0,0,.9) + } + .navbar-light .navbar-nav .nav-link { + color: rgba(0,0,0,.5) + } + .navbar-light .navbar-nav .nav-link:focus, + .navbar-light .navbar-nav .nav-link:hover { + color: rgba(0,0,0,.7) + } + .navbar-light .navbar-nav .nav-link.disabled { + color: rgba(0,0,0,.3) + } + .navbar-light .navbar-nav .active > .nav-link, + .navbar-light .navbar-nav .nav-link.active, + .navbar-light .navbar-nav .nav-link.show, + .navbar-light .navbar-nav .show > .nav-link { + color: rgba(0,0,0,.9) + } + .navbar-light .navbar-toggler { + color: rgba(0,0,0,.5); + border-color: rgba(0,0,0,.1) + } + .navbar-light .navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") + } + .navbar-light .navbar-text { + color: rgba(0,0,0,.5) + } + .navbar-light .navbar-text a { + color: rgba(0,0,0,.9) + } + .navbar-light .navbar-text a:focus, + .navbar-light .navbar-text a:hover { + color: rgba(0,0,0,.9) + } + .navbar-dark .navbar-brand, + .navbar-themed .navbar-brand { + color: #fff + } + .navbar-dark .navbar-brand:focus, + .navbar-dark .navbar-brand:hover, + .navbar-themed .navbar-brand:focus, + .navbar-themed .navbar-brand:hover { + color: #fff + } + .navbar-dark .navbar-nav .nav-link, + .navbar-themed .navbar-nav .nav-link { + color: rgba(255,255,255,.5) + } + .navbar-dark .navbar-nav .nav-link:focus, + .navbar-dark .navbar-nav .nav-link:hover, + .navbar-themed .navbar-nav .nav-link:focus, + .navbar-themed .navbar-nav .nav-link:hover { + color: rgba(255,255,255,.75) + } + .navbar-dark .navbar-nav .nav-link.disabled, + .navbar-themed .navbar-nav .nav-link.disabled { + color: rgba(255,255,255,.25) + } + .navbar-dark .navbar-nav .active > .nav-link, + .navbar-dark .navbar-nav .nav-link.active, + .navbar-dark .navbar-nav .nav-link.show, + .navbar-dark .navbar-nav .show > .nav-link, + .navbar-themed .navbar-nav .active > .nav-link, + .navbar-themed .navbar-nav .nav-link.active, + .navbar-themed .navbar-nav .nav-link.show, + .navbar-themed .navbar-nav .show > .nav-link { + color: #fff + } + .navbar-dark .navbar-toggler, + .navbar-themed .navbar-toggler { + color: rgba(255,255,255,.5); + border-color: rgba(255,255,255,.1) + } + .navbar-dark .navbar-toggler-icon, + .navbar-themed .navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") + } + .navbar-dark .navbar-text, + .navbar-themed .navbar-text { + color: rgba(255,255,255,.5) + } + .navbar-dark .navbar-text a, + .navbar-themed .navbar-text a { + color: #fff + } + .navbar-dark .navbar-text a:focus, + .navbar-dark .navbar-text a:hover, + .navbar-themed .navbar-text a:focus, + .navbar-themed .navbar-text a:hover { + color: #fff + } + .card { + background-color: #212529; + border: 1px solid rgba(255,255,255,.125) + } + .card > .list-group { + border-top: inherit; + border-bottom: inherit + } + .card > .card-header + .list-group, + .card > .list-group + .card-footer { + border-top: 0 + } + .card-header { + background-color: rgba(255,255,255,.03); + border-bottom: 1px solid rgba(255,255,255,.125) + } + .card-footer { + background-color: rgba(255,255,255,.03); + border-top: 1px solid rgba(255,255,255,.125) + } + .card-header-tabs { + border-bottom: 0 + } +} +@media (prefers-color-scheme:dark) and (min-width:576px) { + .card-group > .card + .card { + border-left: 0 + } +} +@media (prefers-color-scheme:dark) { + .accordion > .card:not(:last-of-type) { + border-bottom: 0 + } + .breadcrumb { + background-color: #343a40 + } + .breadcrumb-item + .breadcrumb-item::before { + color: #ced4da + } + .breadcrumb-item.active { + color: #ced4da + } + .page-link { + color: #adadad; + background-color: #000; + border: 1px solid #495057 + } + .page-link:hover { + color: #878787; + background-color: #343a40; + border-color: #495057 + } + .page-link:focus { + box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) + } + .page-item.active .page-link { + color: #000; + background-color: #3395ff; + border-color: #3395ff + } + .page-item.disabled .page-link { + color: #ced4da; + background-color: #000; + border-color: #495057 + } +} +@media (prefers-color-scheme:dark) { + .badge-primary { + color: #e9ecef; + background-color: #3395ff + } + a.badge-primary:focus, + a.badge-primary:hover { + color: #e9ecef; + background-color: #007bff + } + a.badge-primary.focus, + a.badge-primary:focus { + box-shadow: 0 0 0 .2rem rgba(51,149,255,.5) + } + .badge-secondary { + color: #e9ecef; + background-color: #6c757d + } + a.badge-secondary:focus, + a.badge-secondary:hover { + color: #e9ecef; + background-color: #545b62 + } + a.badge-secondary.focus, + a.badge-secondary:focus { + box-shadow: 0 0 0 .2rem rgba(108,117,125,.5) + } + .badge-success { + color: #e9ecef; + background-color: #28a745 + } + a.badge-success:focus, + a.badge-success:hover { + color: #e9ecef; + background-color: #1e7e34 + } + a.badge-success.focus, + a.badge-success:focus { + box-shadow: 0 0 0 .2rem rgba(40,167,69,.5) + } + .badge-info { + color: #e9ecef; + background-color: #17a2b8 + } + a.badge-info:focus, + a.badge-info:hover { + color: #e9ecef; + background-color: #117a8b + } + a.badge-info.focus, + a.badge-info:focus { + box-shadow: 0 0 0 .2rem rgba(23,162,184,.5) + } + .badge-warning { + color: #343a40; + background-color: #fd7e14 + } + a.badge-warning:focus, + a.badge-warning:hover { + color: #343a40; + background-color: #dc6502 + } + a.badge-warning.focus, + a.badge-warning:focus { + box-shadow: 0 0 0 .2rem rgba(253,126,20,.5) + } + .badge-danger { + color: #e9ecef; + background-color: #dc3545 + } + a.badge-danger:focus, + a.badge-danger:hover { + color: #e9ecef; + background-color: #bd2130 + } + a.badge-danger.focus, + a.badge-danger:focus { + box-shadow: 0 0 0 .2rem rgba(220,53,69,.5) + } + .badge-light { + color: #343a40; + background-color: #dee2e6 + } + a.badge-light:focus, + a.badge-light:hover { + color: #343a40; + background-color: #c1c9d0 + } + a.badge-light.focus, + a.badge-light:focus { + box-shadow: 0 0 0 .2rem rgba(222,226,230,.5) + } + .badge-dark { + color: #e9ecef; + background-color: #343a40 + } + a.badge-dark:focus, + a.badge-dark:hover { + color: #e9ecef; + background-color: #1d2124 + } + a.badge-dark.focus, + a.badge-dark:focus { + box-shadow: 0 0 0 .2rem rgba(52,58,64,.5) + } + .jumbotron { + background-color: #343a40 + } +} +@media (prefers-color-scheme:dark) { + .alert { + border: 1px solid transparent + } + .alert-heading { + color: inherit + } + .alert-dismissible .close { + color: inherit + } + .alert-primary { + color: #1b4e85; + background-color: #d6eaff; + border-color: #c6e1ff + } + .alert-primary .alert-link { + color: #12355b + } + .alert-secondary { + color: #383d41; + background-color: #e2e3e5; + border-color: #d6d8db + } + .alert-secondary .alert-link { + color: #202326 + } + .alert-success { + color: #155724; + background-color: #d4edda; + border-color: #c3e6cb + } + .alert-success .alert-link { + color: #0b2e13 + } + .alert-info { + color: #0c5460; + background-color: #d1ecf1; + border-color: #bee5eb + } + .alert-info .alert-link { + color: #062c33 + } + .alert-warning { + color: #84420a; + background-color: #ffe5d0; + border-color: #fedbbd + } + .alert-warning .alert-link { + color: #552a06 + } + .alert-danger { + color: #721c24; + background-color: #f8d7da; + border-color: #f5c6cb + } + .alert-danger .alert-link { + color: #491217 + } + .alert-light, + .alert-themed-inverted { + color: #737678; + background-color: #f8f9fa; + border-color: #f6f7f8 + } + .alert-light .alert-link, + .alert-themed-inverted .alert-link { + color: #5a5c5e + } + .alert-dark, + .alert-themed { + color: #1b1e21; + background-color: #d6d8d9; + border-color: #c6c8ca + } + .alert-dark .alert-link, + .alert-themed .alert-link { + color: #040505 + } + @-webkit-keyframes progress-bar-stripes { + from { + background-position: 1rem 0 + } + to { + background-position: 0 0 + } + } + @keyframes progress-bar-stripes { + from { + background-position: 1rem 0 + } + to { + background-position: 0 0 + } + } + .progress { + background-color: #e9ecef + } + .progress-bar { + color: #fff; + background-color: #007bff + } +} +@media (prefers-color-scheme:dark) { + .progress-bar-striped { + background-image: linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent) + } +} +@media (prefers-color-scheme:dark) { + .list-group-item-action { + color: #dee2e6 + } + .list-group-item-action:focus, + .list-group-item-action:hover { + color: #dee2e6; + background-color: #212529 + } + .list-group-item-action:active { + color: #d3d3d3; + background-color: #343a40 + } + .list-group-item { + background-color: rgba(25,29,33,.05); + border: 1px solid rgba(255,255,255,.125) + } + .list-group-item.disabled, + .list-group-item:disabled { + color: #ced4da; + background-color: rgba(25,29,33,.05) + } + .list-group-item.active { + color: #000; + background-color: #3395ff; + border-color: #3395ff + } +} +@media (prefers-color-scheme:dark) { + .list-group-item-primary { + color: #1b4e85; + background-color: #c6e1ff + } + .list-group-item-primary.list-group-item-action:focus, + .list-group-item-primary.list-group-item-action:hover { + color: #1b4e85; + background-color: #add4ff + } + .list-group-item-primary.list-group-item-action.active { + color: #fff; + background-color: #1b4e85; + border-color: #1b4e85 + } + .list-group-item-secondary { + color: #383d41; + background-color: #d6d8db + } + .list-group-item-secondary.list-group-item-action:focus, + .list-group-item-secondary.list-group-item-action:hover { + color: #383d41; + background-color: #c8cbcf + } + .list-group-item-secondary.list-group-item-action.active { + color: #fff; + background-color: #383d41; + border-color: #383d41 + } + .list-group-item-success { + color: #155724; + background-color: #c3e6cb + } + .list-group-item-success.list-group-item-action:focus, + .list-group-item-success.list-group-item-action:hover { + color: #155724; + background-color: #b1dfbb + } + .list-group-item-success.list-group-item-action.active { + color: #fff; + background-color: #155724; + border-color: #155724 + } + .list-group-item-info { + color: #0c5460; + background-color: #bee5eb + } + .list-group-item-info.list-group-item-action:focus, + .list-group-item-info.list-group-item-action:hover { + color: #0c5460; + background-color: #abdde5 + } + .list-group-item-info.list-group-item-action.active { + color: #fff; + background-color: #0c5460; + border-color: #0c5460 + } + .list-group-item-warning { + color: #84420a; + background-color: #fedbbd + } + .list-group-item-warning.list-group-item-action:focus, + .list-group-item-warning.list-group-item-action:hover { + color: #84420a; + background-color: #fecda4 + } + .list-group-item-warning.list-group-item-action.active { + color: #fff; + background-color: #84420a; + border-color: #84420a + } + .list-group-item-danger { + color: #721c24; + background-color: #f5c6cb + } + .list-group-item-danger.list-group-item-action:focus, + .list-group-item-danger.list-group-item-action:hover { + color: #721c24; + background-color: #f1b0b7 + } + .list-group-item-danger.list-group-item-action.active { + color: #fff; + background-color: #721c24; + border-color: #721c24 + } + .list-group-item-light { + color: #737678; + background-color: #f6f7f8 + } + .list-group-item-light.list-group-item-action:focus, + .list-group-item-light.list-group-item-action:hover { + color: #737678; + background-color: #e8eaed + } + .list-group-item-light.list-group-item-action.active { + color: #fff; + background-color: #737678; + border-color: #737678 + } + .list-group-item-dark { + color: #1b1e21; + background-color: #c6c8ca + } + .list-group-item-dark.list-group-item-action:focus, + .list-group-item-dark.list-group-item-action:hover { + color: #1b1e21; + background-color: #b9bbbe + } + .list-group-item-dark.list-group-item-action.active { + color: #fff; + background-color: #1b1e21; + border-color: #1b1e21 + } + .close { + color: #fff; + text-shadow: 0 1px 0 #000 + } + .close:hover { + color: #fff + } + button.close { + background-color: transparent; + border: 0 + } + .toast { + background-color: rgba(0,0,0,.85); + border: 1px solid rgba(255,255,255,.1); + box-shadow: 0 .25rem .75rem rgba(255,255,255,.1) + } + .toast-header { + color: #ced4da; + background-color: rgba(0,0,0,.85); + border-bottom: 1px solid rgba(255,255,255,.05) + } +} +@media (prefers-color-scheme:dark) { + .modal-content { + background-color: #191d21; + border: 1px solid rgba(255,255,255,.2) + } + .modal-backdrop { + background-color: #000 + } + .modal-header { + border-bottom: 1px solid #343a40 + } + .modal-footer { + border-top: 1px solid #343a40 + } +} +@media (prefers-color-scheme:dark) { + .tooltip { + text-shadow: none + } + .tooltip .arrow::before { + border-color: transparent + } + .tooltip-inner { + color: #fff; + background-color: #000 + } + .popover { + text-shadow: none; + background-color: #fff; + border: 1px solid rgba(0,0,0,.2) + } + .popover .arrow::after, + .popover .arrow::before { + border-color: transparent + } + .bs-popover-auto[x-placement^=bottom] .popover-header::before, + .bs-popover-bottom .popover-header::before { + border-bottom: 1px solid #f7f7f7 + } + .popover-header { + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb + } + .popover-body { + color: #212529 + } +} +@media (prefers-color-scheme:dark) { + .carousel-control-next, + .carousel-control-prev { + color: #fff; + background: 0 0; + border: 0 + } +} +@media (prefers-color-scheme:dark) { + .carousel-control-next:focus, + .carousel-control-next:hover, + .carousel-control-prev:focus, + .carousel-control-prev:hover { + color: #fff + } + .carousel-control-next-icon, + .carousel-control-prev-icon { + background: 50%/100% 100% no-repeat + } + .carousel-control-prev-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e") + } + .carousel-control-next-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e") + } + .carousel-indicators li { + background-color: #fff; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent + } +} +@media (prefers-color-scheme:dark) { + .carousel-caption { + color: #fff + } + @-webkit-keyframes spinner-border { + to { + transform: rotate(360deg) + } + } + @keyframes spinner-border { + to { + transform: rotate(360deg) + } + } + .spinner-border { + border: .25em solid currentcolor + } + @-webkit-keyframes spinner-grow { + 0% { + transform: scale(0) + } + 50% { + opacity: 1; + transform: none + } + } + @keyframes spinner-grow { + 0% { + transform: scale(0) + } + 50% { + opacity: 1; + transform: none + } + } + .spinner-grow { + background-color: currentcolor + } +} +@media (prefers-color-scheme:dark) { + .bg-primary { + background-color: #3395ff!important + } + a.bg-primary:focus, + a.bg-primary:hover, + button.bg-primary:focus, + button.bg-primary:hover { + background-color: #007bff!important + } + .bg-secondary { + background-color: #6c757d!important + } + a.bg-secondary:focus, + a.bg-secondary:hover, + button.bg-secondary:focus, + button.bg-secondary:hover { + background-color: #545b62!important + } + .bg-success { + background-color: #28a745!important + } + a.bg-success:focus, + a.bg-success:hover, + button.bg-success:focus, + button.bg-success:hover { + background-color: #1e7e34!important + } + .bg-info { + background-color: #17a2b8!important + } + a.bg-info:focus, + a.bg-info:hover, + button.bg-info:focus, + button.bg-info:hover { + background-color: #117a8b!important + } + .bg-warning { + background-color: #fd7e14!important + } + a.bg-warning:focus, + a.bg-warning:hover, + button.bg-warning:focus, + button.bg-warning:hover { + background-color: #dc6502!important + } + .bg-danger { + background-color: #dc3545!important + } + a.bg-danger:focus, + a.bg-danger:hover, + button.bg-danger:focus, + button.bg-danger:hover { + background-color: #bd2130!important + } + .bg-light, + .bg-themed-inverted { + background-color: #dee2e6!important + } + a.bg-light:focus, + a.bg-light:hover, + a.bg-themed-inverted:focus, + a.bg-themed-inverted:hover, + button.bg-light:focus, + button.bg-light:hover, + button.bg-themed-inverted:focus, + button.bg-themed-inverted:hover { + background-color: #c1c9d0!important + } + .bg-dark, + .bg-themed, + .navbar-themed { + background-color: #343a40!important + } + a.bg-dark:focus, + a.bg-dark:hover, + a.bg-themed:focus, + a.bg-themed:hover, + a.navbar-themed:focus, + a.navbar-themed:hover, + button.bg-dark:focus, + button.bg-dark:hover, + button.bg-themed:focus, + button.bg-themed:hover, + button.navbar-themed:focus, + button.navbar-themed:hover { + background-color: #1d2124!important + } + .bg-white { + background-color: #fff!important + } + .bg-transparent { + background-color: transparent!important + } + .border { + border: 1px solid #343a40!important + } + .border-top { + border-top: 1px solid #343a40!important + } + .border-right { + border-right: 1px solid #343a40!important + } + .border-bottom { + border-bottom: 1px solid #343a40!important + } + .border-left { + border-left: 1px solid #343a40!important + } + .border-0 { + border: 0!important + } + .border-top-0 { + border-top: 0!important + } + .border-right-0 { + border-right: 0!important + } + .border-bottom-0 { + border-bottom: 0!important + } + .border-left-0 { + border-left: 0!important + } + .border-primary { + border-color: #3395ff!important + } + .border-secondary { + border-color: #6c757d!important + } + .border-success { + border-color: #28a745!important + } + .border-info { + border-color: #17a2b8!important + } + .border-warning { + border-color: #fd7e14!important + } + .border-danger { + border-color: #dc3545!important + } + .border-light { + border-color: #dee2e6!important + } + .border-dark { + border-color: #343a40!important + } + .border-white { + border-color: #fff!important + } +} +@media (prefers-color-scheme:dark) { + .embed-responsive .embed-responsive-item, + .embed-responsive embed, + .embed-responsive iframe, + .embed-responsive object, + .embed-responsive video { + border: 0 + } +} +@media (prefers-color-scheme:dark) { + @supports ((position:-webkit-sticky) or (position:sticky)) { + .sticky-top { + position: -webkit-sticky; + position: sticky; + top: 0; + z-index: 1020 + } + } + .sr-only { + border: 0 + } + .shadow-sm { + box-shadow: 0 .125rem .25rem rgba(0,0,0,.075)!important + } + .shadow { + box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important + } + .shadow-lg { + box-shadow: 0 1rem 3rem rgba(0,0,0,.175)!important + } + .shadow-none { + box-shadow: none!important + } +} +@media (prefers-color-scheme:dark) { + .stretched-link::after { + background-color: rgba(0,0,0,0) + } +} +@media (prefers-color-scheme:dark) { + .text-white { + color: #fff!important + } + .text-primary { + color: #3395ff!important + } + a.text-primary:focus, + a.text-primary:hover { + color: #006fe6!important + } + .text-secondary { + color: #6c757d!important + } + a.text-secondary:focus, + a.text-secondary:hover { + color: #494f54!important + } + .text-success { + color: #28a745!important + } + a.text-success:focus, + a.text-success:hover { + color: #19692c!important + } + .text-info { + color: #17a2b8!important + } + a.text-info:focus, + a.text-info:hover { + color: #0f6674!important + } + .text-warning { + color: #fd7e14!important + } + a.text-warning:focus, + a.text-warning:hover { + color: #c35a02!important + } + .text-danger { + color: #dc3545!important + } + a.text-danger:focus, + a.text-danger:hover { + color: #a71d2a!important + } + .text-light, + .text-themed { + color: #dee2e6!important + } + a.text-light:focus, + a.text-light:hover, + a.text-themed:focus, + a.text-themed:hover { + color: #b2bcc5!important + } + .text-dark, + .text-themed-inverted { + color: #343a40!important + } + a.text-dark:focus, + a.text-dark:hover, + a.text-themed-inverted:focus, + a.text-themed-inverted:hover { + color: #121416!important + } + .text-body { + color: #d3d3d3!important + } + .text-muted { + color: #6c757d!important + } + .text-black-50 { + color: rgba(0,0,0,.5)!important + } + .text-white-50 { + color: rgba(255,255,255,.5)!important + } + .text-hide { + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0 + } + .text-reset { + color: inherit!important + } +} \ No newline at end of file diff --git a/uffd/static/bootstrap/bootstrap-prefers-dark-color-only.min.css b/uffd/static/bootstrap/bootstrap-prefers-dark-color-only.min.css new file mode 100644 index 0000000000000000000000000000000000000000..a3f825b649832301db3dfd306b0290b54e149558 --- /dev/null +++ b/uffd/static/bootstrap/bootstrap-prefers-dark-color-only.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap-Dark v4.0.0 (https://github.com/ForEvolve/bootstrap-dark) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +@media (prefers-color-scheme:dark) {body {color: #d3d3d3;background-color: #191d21 }abbr[data-original-title], abbr[title] {border-bottom: 0 }a {color: #adadad;background-color: transparent }a:hover {color: #878787 }a:not([href]):not([class]) {color: inherit }a:not([href]):not([class]):hover {color: inherit }caption {color: #6c757d }fieldset {border: 0 }legend {color: inherit }hr {border: 0;border-top: 1px solid rgba(255,255,255,.1) }.mark, mark {background-color: #fcf8e3 }.blockquote-footer {color: #6c757d }.img-thumbnail {background-color: #fff;border: 1px solid #dee2e6 }.figure-caption {color: #6c757d }code {color: #e83e8c }a > code {color: inherit }kbd {color: #fff;background-color: #212529 }pre {color: #f8f9fa }pre code {color: inherit }}@media (prefers-color-scheme:dark) {.table {color: #d3d3d3 }.table td, .table th {border-top: 1px solid #343a40 }.table thead th {border-bottom: 2px solid #343a40 }.table tbody + tbody {border-top: 2px solid #343a40 }.table-bordered {border: 1px solid #343a40 }.table-bordered td, .table-bordered th {border: 1px solid #343a40 }.table-borderless tbody + tbody, .table-borderless td, .table-borderless th, .table-borderless thead th {border: 0 }.table-striped tbody tr:nth-of-type(odd) {background-color: rgba(0,0,0,.05) }.table-hover tbody tr:hover {color: #d3d3d3;background-color: rgba(0,0,0,.075) }.table-primary, .table-primary > td, .table-primary > th {background-color: #c6e1ff }.table-primary tbody + tbody, .table-primary td, .table-primary th, .table-primary thead th {border-color: #95c8ff }.table-hover .table-primary:hover {background-color: #add4ff }.table-hover .table-primary:hover > td, .table-hover .table-primary:hover > th {background-color: #add4ff }.table-secondary, .table-secondary > td, .table-secondary > th {background-color: #d6d8db }.table-secondary tbody + tbody, .table-secondary td, .table-secondary th, .table-secondary thead th {border-color: #b3b7bb }.table-hover .table-secondary:hover {background-color: #c8cbcf }.table-hover .table-secondary:hover > td, .table-hover .table-secondary:hover > th {background-color: #c8cbcf }.table-success, .table-success > td, .table-success > th {background-color: #c3e6cb }.table-success tbody + tbody, .table-success td, .table-success th, .table-success thead th {border-color: #8fd19e }.table-hover .table-success:hover {background-color: #b1dfbb }.table-hover .table-success:hover > td, .table-hover .table-success:hover > th {background-color: #b1dfbb }.table-info, .table-info > td, .table-info > th {background-color: #bee5eb }.table-info tbody + tbody, .table-info td, .table-info th, .table-info thead th {border-color: #86cfda }.table-hover .table-info:hover {background-color: #abdde5 }.table-hover .table-info:hover > td, .table-hover .table-info:hover > th {background-color: #abdde5 }.table-warning, .table-warning > td, .table-warning > th {background-color: #fedbbd }.table-warning tbody + tbody, .table-warning td, .table-warning th, .table-warning thead th {border-color: #febc85 }.table-hover .table-warning:hover {background-color: #fecda4 }.table-hover .table-warning:hover > td, .table-hover .table-warning:hover > th {background-color: #fecda4 }.table-danger, .table-danger > td, .table-danger > th {background-color: #f5c6cb }.table-danger tbody + tbody, .table-danger td, .table-danger th, .table-danger thead th {border-color: #ed969e }.table-hover .table-danger:hover {background-color: #f1b0b7 }.table-hover .table-danger:hover > td, .table-hover .table-danger:hover > th {background-color: #f1b0b7 }.table-light, .table-light > td, .table-light > th {background-color: #f6f7f8 }.table-light tbody + tbody, .table-light td, .table-light th, .table-light thead th {border-color: #eef0f2 }.table-hover .table-light:hover {background-color: #e8eaed }.table-hover .table-light:hover > td, .table-hover .table-light:hover > th {background-color: #e8eaed }.table-dark, .table-dark > td, .table-dark > th {background-color: #c6c8ca }.table-dark tbody + tbody, .table-dark td, .table-dark th, .table-dark thead th {border-color: #95999c }.table-hover .table-dark:hover {background-color: #b9bbbe }.table-hover .table-dark:hover > td, .table-hover .table-dark:hover > th {background-color: #b9bbbe }.table-active, .table-active > td, .table-active > th {background-color: rgba(0,0,0,.075) }.table-hover .table-active:hover {background-color: rgba(0,0,0,.075) }.table-hover .table-active:hover > td, .table-hover .table-active:hover > th {background-color: rgba(0,0,0,.075) }.table .thead-dark th {color: #dee2e6;background-color: #343a40;border-color: #454d55 }.table .thead-light th {color: #495057;background-color: #e9ecef;border-color: #343a40 }.table-dark {color: #dee2e6;background-color: #343a40 }.table-dark td, .table-dark th, .table-dark thead th {border-color: #454d55 }.table-dark.table-bordered {border: 0 }.table-dark.table-striped tbody tr:nth-of-type(odd) {background-color: rgba(255,255,255,.05) }.table-dark.table-hover tbody tr:hover {color: #fff;background-color: rgba(255,255,255,.075) }}@media (prefers-color-scheme:dark) and (max-width:575.98px) {.table-responsive-sm > .table-bordered {border: 0 }}@media (prefers-color-scheme:dark) and (max-width:767.98px) {.table-responsive-md > .table-bordered {border: 0 }}@media (prefers-color-scheme:dark) and (max-width:991.98px) {.table-responsive-lg > .table-bordered {border: 0 }}@media (prefers-color-scheme:dark) and (max-width:1199.98px) {.table-responsive-xl > .table-bordered {border: 0 }}@media (prefers-color-scheme:dark) {.table-responsive > .table-bordered {border: 0 }.table-primary, .table-primary > td, .table-primary > th {color: #343a40 }.table-hover .table-primary:hover {color: #343a40 }.table-hover .table-primary:hover > td, .table-hover .table-primary:hover > th {color: #343a40 }.table-secondary, .table-secondary > td, .table-secondary > th {color: #343a40 }.table-hover .table-secondary:hover {color: #343a40 }.table-hover .table-secondary:hover > td, .table-hover .table-secondary:hover > th {color: #343a40 }.table-success, .table-success > td, .table-success > th {color: #343a40 }.table-hover .table-success:hover {color: #343a40 }.table-hover .table-success:hover > td, .table-hover .table-success:hover > th {color: #343a40 }.table-info, .table-info > td, .table-info > th {color: #343a40 }.table-hover .table-info:hover {color: #343a40 }.table-hover .table-info:hover > td, .table-hover .table-info:hover > th {color: #343a40 }.table-warning, .table-warning > td, .table-warning > th {color: #343a40 }.table-hover .table-warning:hover {color: #343a40 }.table-hover .table-warning:hover > td, .table-hover .table-warning:hover > th {color: #343a40 }.table-danger, .table-danger > td, .table-danger > th {color: #343a40 }.table-hover .table-danger:hover {color: #343a40 }.table-hover .table-danger:hover > td, .table-hover .table-danger:hover > th {color: #343a40 }.table-light, .table-light > td, .table-light > th {color: #343a40 }.table-hover .table-light:hover {color: #343a40 }.table-hover .table-light:hover > td, .table-hover .table-light:hover > th {color: #343a40 }.table-dark, .table-dark > td, .table-dark > th {color: #343a40 }.table-hover .table-dark:hover {color: #343a40 }.table-hover .table-dark:hover > td, .table-hover .table-dark:hover > th {color: #343a40 }.table-active, .table-active > td, .table-active > th {color: #e9ecef }.table-hover .table-active:hover {color: #e9ecef }.table-hover .table-active:hover > td, .table-hover .table-active:hover > th {color: #e9ecef }.table-dark {color: #dee2e6 }.form-control {color: #dee2e6;background-color: #000;border: 1px solid #6c757d }}@media (prefers-color-scheme:dark) {.form-control::-ms-expand {background-color: transparent;border: 0 }.form-control:focus {color: #dee2e6;background-color: #191d21;border-color: #b3d7ff;box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) }.form-control::-webkit-input-placeholder {color: #6c757d }.form-control::-moz-placeholder {color: #6c757d }.form-control::-ms-input-placeholder {color: #6c757d }.form-control::placeholder {color: #6c757d }.form-control:disabled, .form-control[readonly] {background-color: #343a40 }select.form-control:-moz-focusring {color: transparent;text-shadow: 0 0 0 #dee2e6 }select.form-control:focus::-ms-value {color: #dee2e6;background-color: #000 }.form-control-plaintext {color: #212529;background-color: transparent;border: solid transparent }.form-check-input:disabled ~ .form-check-label, .form-check-input[disabled] ~ .form-check-label {color: #6c757d }.valid-feedback {color: #28a745 }.valid-tooltip {color: #e9ecef;background-color: rgba(40,167,69,.9) }.form-control.is-valid, .was-validated .form-control:valid {border-color: #28a745;background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") }.form-control.is-valid:focus, .was-validated .form-control:valid:focus {border-color: #28a745;box-shadow: 0 0 0 .2rem rgba(40,167,69,.25) }.custom-select.is-valid, .was-validated .custom-select:valid {border-color: #28a745;background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat,#000 url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat }.custom-select.is-valid:focus, .was-validated .custom-select:valid:focus {border-color: #28a745;box-shadow: 0 0 0 .2rem rgba(40,167,69,.25) }.form-check-input.is-valid ~ .form-check-label, .was-validated .form-check-input:valid ~ .form-check-label {color: #28a745 }.custom-control-input.is-valid ~ .custom-control-label, .was-validated .custom-control-input:valid ~ .custom-control-label {color: #28a745 }.custom-control-input.is-valid ~ .custom-control-label::before, .was-validated .custom-control-input:valid ~ .custom-control-label::before {border-color: #28a745 }.custom-control-input.is-valid:checked ~ .custom-control-label::before, .was-validated .custom-control-input:valid:checked ~ .custom-control-label::before {border-color: #34ce57;background-color: #34ce57 }.custom-control-input.is-valid:focus ~ .custom-control-label::before, .was-validated .custom-control-input:valid:focus ~ .custom-control-label::before {box-shadow: 0 0 0 .2rem rgba(40,167,69,.25) }.custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before, .was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before {border-color: #28a745 }.custom-file-input.is-valid ~ .custom-file-label, .was-validated .custom-file-input:valid ~ .custom-file-label {border-color: #28a745 }.custom-file-input.is-valid:focus ~ .custom-file-label, .was-validated .custom-file-input:valid:focus ~ .custom-file-label {border-color: #28a745;box-shadow: 0 0 0 .2rem rgba(40,167,69,.25) }.invalid-feedback {color: #dc3545 }.invalid-tooltip {color: #e9ecef;background-color: rgba(220,53,69,.9) }.form-control.is-invalid, .was-validated .form-control:invalid {border-color: #dc3545;background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") }.form-control.is-invalid:focus, .was-validated .form-control:invalid:focus {border-color: #dc3545;box-shadow: 0 0 0 .2rem rgba(220,53,69,.25) }.custom-select.is-invalid, .was-validated .custom-select:invalid {border-color: #dc3545;background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat,#000 url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat }.custom-select.is-invalid:focus, .was-validated .custom-select:invalid:focus {border-color: #dc3545;box-shadow: 0 0 0 .2rem rgba(220,53,69,.25) }.form-check-input.is-invalid ~ .form-check-label, .was-validated .form-check-input:invalid ~ .form-check-label {color: #dc3545 }.custom-control-input.is-invalid ~ .custom-control-label, .was-validated .custom-control-input:invalid ~ .custom-control-label {color: #dc3545 }.custom-control-input.is-invalid ~ .custom-control-label::before, .was-validated .custom-control-input:invalid ~ .custom-control-label::before {border-color: #dc3545 }.custom-control-input.is-invalid:checked ~ .custom-control-label::before, .was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before {border-color: #e4606d;background-color: #e4606d }.custom-control-input.is-invalid:focus ~ .custom-control-label::before, .was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before {box-shadow: 0 0 0 .2rem rgba(220,53,69,.25) }.custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before, .was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before {border-color: #dc3545 }.custom-file-input.is-invalid ~ .custom-file-label, .was-validated .custom-file-input:invalid ~ .custom-file-label {border-color: #dc3545 }.custom-file-input.is-invalid:focus ~ .custom-file-label, .was-validated .custom-file-input:invalid:focus ~ .custom-file-label {border-color: #dc3545;box-shadow: 0 0 0 .2rem rgba(220,53,69,.25) }}@media (prefers-color-scheme:dark) {.btn {color: #d3d3d3;background-color: transparent;border: 1px solid transparent }}@media (prefers-color-scheme:dark) {.btn:hover {color: #d3d3d3 }.btn.focus, .btn:focus {box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) }.btn-primary {color: #e9ecef;background-color: #3395ff;border-color: #3395ff }.btn-primary:hover {color: #e9ecef;background-color: #0d82ff;border-color: #007bff }.btn-primary.focus, .btn-primary:focus {color: #e9ecef;background-color: #0d82ff;border-color: #007bff;box-shadow: 0 0 0 .2rem rgba(78,162,253,.5) }.btn-primary.disabled, .btn-primary:disabled {color: #e9ecef;background-color: #3395ff;border-color: #3395ff }.btn-primary:not(:disabled):not(.disabled).active, .btn-primary:not(:disabled):not(.disabled):active, .show > .btn-primary.dropdown-toggle {color: #e9ecef;background-color: #007bff;border-color: #0075f2 }.btn-primary:not(:disabled):not(.disabled).active:focus, .btn-primary:not(:disabled):not(.disabled):active:focus, .show > .btn-primary.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(78,162,253,.5) }.btn-secondary {color: #e9ecef;background-color: #6c757d;border-color: #6c757d }.btn-secondary:hover {color: #e9ecef;background-color: #5a6268;border-color: #545b62 }.btn-secondary.focus, .btn-secondary:focus {color: #e9ecef;background-color: #5a6268;border-color: #545b62;box-shadow: 0 0 0 .2rem rgba(127,135,142,.5) }.btn-secondary.disabled, .btn-secondary:disabled {color: #e9ecef;background-color: #6c757d;border-color: #6c757d }.btn-secondary:not(:disabled):not(.disabled).active, .btn-secondary:not(:disabled):not(.disabled):active, .show > .btn-secondary.dropdown-toggle {color: #e9ecef;background-color: #545b62;border-color: #4e555b }.btn-secondary:not(:disabled):not(.disabled).active:focus, .btn-secondary:not(:disabled):not(.disabled):active:focus, .show > .btn-secondary.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(127,135,142,.5) }.btn-success {color: #e9ecef;background-color: #28a745;border-color: #28a745 }.btn-success:hover {color: #e9ecef;background-color: #218838;border-color: #1e7e34 }.btn-success.focus, .btn-success:focus {color: #e9ecef;background-color: #218838;border-color: #1e7e34;box-shadow: 0 0 0 .2rem rgba(69,177,95,.5) }.btn-success.disabled, .btn-success:disabled {color: #e9ecef;background-color: #28a745;border-color: #28a745 }.btn-success:not(:disabled):not(.disabled).active, .btn-success:not(:disabled):not(.disabled):active, .show > .btn-success.dropdown-toggle {color: #e9ecef;background-color: #1e7e34;border-color: #1c7430 }.btn-success:not(:disabled):not(.disabled).active:focus, .btn-success:not(:disabled):not(.disabled):active:focus, .show > .btn-success.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(69,177,95,.5) }.btn-info {color: #e9ecef;background-color: #17a2b8;border-color: #17a2b8 }.btn-info:hover {color: #e9ecef;background-color: #138496;border-color: #117a8b }.btn-info.focus, .btn-info:focus {color: #e9ecef;background-color: #138496;border-color: #117a8b;box-shadow: 0 0 0 .2rem rgba(55,173,192,.5) }.btn-info.disabled, .btn-info:disabled {color: #e9ecef;background-color: #17a2b8;border-color: #17a2b8 }.btn-info:not(:disabled):not(.disabled).active, .btn-info:not(:disabled):not(.disabled):active, .show > .btn-info.dropdown-toggle {color: #e9ecef;background-color: #117a8b;border-color: #10707f }.btn-info:not(:disabled):not(.disabled).active:focus, .btn-info:not(:disabled):not(.disabled):active:focus, .show > .btn-info.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(55,173,192,.5) }.btn-warning {color: #343a40;background-color: #fd7e14;border-color: #fd7e14 }.btn-warning:hover {color: #e9ecef;background-color: #e96b02;border-color: #dc6502 }.btn-warning.focus, .btn-warning:focus {color: #e9ecef;background-color: #e96b02;border-color: #dc6502;box-shadow: 0 0 0 .2rem rgba(223,116,27,.5) }.btn-warning.disabled, .btn-warning:disabled {color: #343a40;background-color: #fd7e14;border-color: #fd7e14 }.btn-warning:not(:disabled):not(.disabled).active, .btn-warning:not(:disabled):not(.disabled):active, .show > .btn-warning.dropdown-toggle {color: #e9ecef;background-color: #dc6502;border-color: #cf5f02 }.btn-warning:not(:disabled):not(.disabled).active:focus, .btn-warning:not(:disabled):not(.disabled):active:focus, .show > .btn-warning.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(223,116,27,.5) }.btn-danger {color: #e9ecef;background-color: #dc3545;border-color: #dc3545 }.btn-danger:hover {color: #e9ecef;background-color: #c82333;border-color: #bd2130 }.btn-danger.focus, .btn-danger:focus {color: #e9ecef;background-color: #c82333;border-color: #bd2130;box-shadow: 0 0 0 .2rem rgba(222,80,95,.5) }.btn-danger.disabled, .btn-danger:disabled {color: #e9ecef;background-color: #dc3545;border-color: #dc3545 }.btn-danger:not(:disabled):not(.disabled).active, .btn-danger:not(:disabled):not(.disabled):active, .show > .btn-danger.dropdown-toggle {color: #e9ecef;background-color: #bd2130;border-color: #b21f2d }.btn-danger:not(:disabled):not(.disabled).active:focus, .btn-danger:not(:disabled):not(.disabled):active:focus, .show > .btn-danger.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(222,80,95,.5) }.btn-light {color: #343a40;background-color: #dee2e6;border-color: #dee2e6 }.btn-light:hover {color: #343a40;background-color: #c8cfd6;border-color: #c1c9d0 }.btn-light.focus, .btn-light:focus {color: #343a40;background-color: #c8cfd6;border-color: #c1c9d0;box-shadow: 0 0 0 .2rem rgba(197,201,205,.5) }.btn-light.disabled, .btn-light:disabled {color: #343a40;background-color: #dee2e6;border-color: #dee2e6 }.btn-light:not(:disabled):not(.disabled).active, .btn-light:not(:disabled):not(.disabled):active, .show > .btn-light.dropdown-toggle {color: #343a40;background-color: #c1c9d0;border-color: #bac2cb }.btn-light:not(:disabled):not(.disabled).active:focus, .btn-light:not(:disabled):not(.disabled):active:focus, .show > .btn-light.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(197,201,205,.5) }.btn-dark {color: #e9ecef;background-color: #343a40;border-color: #343a40 }.btn-dark:hover {color: #e9ecef;background-color: #23272b;border-color: #1d2124 }.btn-dark.focus, .btn-dark:focus {color: #e9ecef;background-color: #23272b;border-color: #1d2124;box-shadow: 0 0 0 .2rem rgba(79,85,90,.5) }.btn-dark.disabled, .btn-dark:disabled {color: #e9ecef;background-color: #343a40;border-color: #343a40 }.btn-dark:not(:disabled):not(.disabled).active, .btn-dark:not(:disabled):not(.disabled):active, .show > .btn-dark.dropdown-toggle {color: #e9ecef;background-color: #1d2124;border-color: #171a1d }.btn-dark:not(:disabled):not(.disabled).active:focus, .btn-dark:not(:disabled):not(.disabled):active:focus, .show > .btn-dark.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(79,85,90,.5) }.btn-outline-primary {color: #3395ff;border-color: #3395ff }.btn-outline-primary:hover {color: #e9ecef;background-color: #3395ff;border-color: #3395ff }.btn-outline-primary.focus, .btn-outline-primary:focus {box-shadow: 0 0 0 .2rem rgba(51,149,255,.5) }.btn-outline-primary.disabled, .btn-outline-primary:disabled {color: #3395ff;background-color: transparent }.btn-outline-primary:not(:disabled):not(.disabled).active, .btn-outline-primary:not(:disabled):not(.disabled):active, .show > .btn-outline-primary.dropdown-toggle {color: #e9ecef;background-color: #3395ff;border-color: #3395ff }.btn-outline-primary:not(:disabled):not(.disabled).active:focus, .btn-outline-primary:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-primary.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(51,149,255,.5) }.btn-outline-secondary {color: #6c757d;border-color: #6c757d }.btn-outline-secondary:hover {color: #e9ecef;background-color: #6c757d;border-color: #6c757d }.btn-outline-secondary.focus, .btn-outline-secondary:focus {box-shadow: 0 0 0 .2rem rgba(108,117,125,.5) }.btn-outline-secondary.disabled, .btn-outline-secondary:disabled {color: #6c757d;background-color: transparent }.btn-outline-secondary:not(:disabled):not(.disabled).active, .btn-outline-secondary:not(:disabled):not(.disabled):active, .show > .btn-outline-secondary.dropdown-toggle {color: #e9ecef;background-color: #6c757d;border-color: #6c757d }.btn-outline-secondary:not(:disabled):not(.disabled).active:focus, .btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-secondary.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(108,117,125,.5) }.btn-outline-success {color: #28a745;border-color: #28a745 }.btn-outline-success:hover {color: #e9ecef;background-color: #28a745;border-color: #28a745 }.btn-outline-success.focus, .btn-outline-success:focus {box-shadow: 0 0 0 .2rem rgba(40,167,69,.5) }.btn-outline-success.disabled, .btn-outline-success:disabled {color: #28a745;background-color: transparent }.btn-outline-success:not(:disabled):not(.disabled).active, .btn-outline-success:not(:disabled):not(.disabled):active, .show > .btn-outline-success.dropdown-toggle {color: #e9ecef;background-color: #28a745;border-color: #28a745 }.btn-outline-success:not(:disabled):not(.disabled).active:focus, .btn-outline-success:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-success.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(40,167,69,.5) }.btn-outline-info {color: #17a2b8;border-color: #17a2b8 }.btn-outline-info:hover {color: #e9ecef;background-color: #17a2b8;border-color: #17a2b8 }.btn-outline-info.focus, .btn-outline-info:focus {box-shadow: 0 0 0 .2rem rgba(23,162,184,.5) }.btn-outline-info.disabled, .btn-outline-info:disabled {color: #17a2b8;background-color: transparent }.btn-outline-info:not(:disabled):not(.disabled).active, .btn-outline-info:not(:disabled):not(.disabled):active, .show > .btn-outline-info.dropdown-toggle {color: #e9ecef;background-color: #17a2b8;border-color: #17a2b8 }.btn-outline-info:not(:disabled):not(.disabled).active:focus, .btn-outline-info:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-info.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(23,162,184,.5) }.btn-outline-warning {color: #fd7e14;border-color: #fd7e14 }.btn-outline-warning:hover {color: #343a40;background-color: #fd7e14;border-color: #fd7e14 }.btn-outline-warning.focus, .btn-outline-warning:focus {box-shadow: 0 0 0 .2rem rgba(253,126,20,.5) }.btn-outline-warning.disabled, .btn-outline-warning:disabled {color: #fd7e14;background-color: transparent }.btn-outline-warning:not(:disabled):not(.disabled).active, .btn-outline-warning:not(:disabled):not(.disabled):active, .show > .btn-outline-warning.dropdown-toggle {color: #343a40;background-color: #fd7e14;border-color: #fd7e14 }.btn-outline-warning:not(:disabled):not(.disabled).active:focus, .btn-outline-warning:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-warning.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(253,126,20,.5) }.btn-outline-danger {color: #dc3545;border-color: #dc3545 }.btn-outline-danger:hover {color: #e9ecef;background-color: #dc3545;border-color: #dc3545 }.btn-outline-danger.focus, .btn-outline-danger:focus {box-shadow: 0 0 0 .2rem rgba(220,53,69,.5) }.btn-outline-danger.disabled, .btn-outline-danger:disabled {color: #dc3545;background-color: transparent }.btn-outline-danger:not(:disabled):not(.disabled).active, .btn-outline-danger:not(:disabled):not(.disabled):active, .show > .btn-outline-danger.dropdown-toggle {color: #e9ecef;background-color: #dc3545;border-color: #dc3545 }.btn-outline-danger:not(:disabled):not(.disabled).active:focus, .btn-outline-danger:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-danger.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(220,53,69,.5) }.btn-outline-light {color: #dee2e6;border-color: #dee2e6 }.btn-outline-light:hover {color: #343a40;background-color: #dee2e6;border-color: #dee2e6 }.btn-outline-light.focus, .btn-outline-light:focus {box-shadow: 0 0 0 .2rem rgba(222,226,230,.5) }.btn-outline-light.disabled, .btn-outline-light:disabled {color: #dee2e6;background-color: transparent }.btn-outline-light:not(:disabled):not(.disabled).active, .btn-outline-light:not(:disabled):not(.disabled):active, .show > .btn-outline-light.dropdown-toggle {color: #343a40;background-color: #dee2e6;border-color: #dee2e6 }.btn-outline-light:not(:disabled):not(.disabled).active:focus, .btn-outline-light:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-light.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(222,226,230,.5) }.btn-outline-dark {color: #343a40;border-color: #343a40 }.btn-outline-dark:hover {color: #e9ecef;background-color: #343a40;border-color: #343a40 }.btn-outline-dark.focus, .btn-outline-dark:focus {box-shadow: 0 0 0 .2rem rgba(52,58,64,.5) }.btn-outline-dark.disabled, .btn-outline-dark:disabled {color: #343a40;background-color: transparent }.btn-outline-dark:not(:disabled):not(.disabled).active, .btn-outline-dark:not(:disabled):not(.disabled):active, .show > .btn-outline-dark.dropdown-toggle {color: #e9ecef;background-color: #343a40;border-color: #343a40 }.btn-outline-dark:not(:disabled):not(.disabled).active:focus, .btn-outline-dark:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-dark.dropdown-toggle:focus {box-shadow: 0 0 0 .2rem rgba(52,58,64,.5) }.btn-link {color: #adadad }.btn-link:hover {color: #878787 }.btn-link.disabled, .btn-link:disabled {color: #6c757d }}@media (prefers-color-scheme:dark) {.dropdown-toggle::after {border-top: .3em solid;border-right: .3em solid transparent;border-bottom: 0;border-left: .3em solid transparent }.dropdown-menu {color: #d3d3d3;background-color: #000;border: 1px solid rgba(255,255,255,.15) }}@media (prefers-color-scheme:dark) {.dropup .dropdown-toggle::after {border-top: 0;border-right: .3em solid transparent;border-bottom: .3em solid;border-left: .3em solid transparent }.dropright .dropdown-toggle::after {border-top: .3em solid transparent;border-right: 0;border-bottom: .3em solid transparent;border-left: .3em solid }.dropleft .dropdown-toggle::before {border-top: .3em solid transparent;border-right: .3em solid;border-bottom: .3em solid transparent }.dropdown-divider {border-top: 1px solid #343a40 }.dropdown-item {color: #f8f9fa;background-color: transparent;border: 0 }.dropdown-item:focus, .dropdown-item:hover {color: #fff;background-color: #212529 }.dropdown-item.active, .dropdown-item:active {color: #000;background-color: #3395ff }.dropdown-item.disabled, .dropdown-item:disabled {color: #ced4da;background-color: transparent }.dropdown-header {color: #ced4da }.dropdown-item-text {color: #f8f9fa }.input-group-text {color: #dee2e6;background-color: #343a40;border: 1px solid #6c757d }.custom-control-input:checked ~ .custom-control-label::before {color: #fff;border-color: #007bff;background-color: #007bff }.custom-control-input:focus ~ .custom-control-label::before {box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) }.custom-control-input:focus:not(:checked) ~ .custom-control-label::before {border-color: #80bdff }.custom-control-input:not(:disabled):active ~ .custom-control-label::before {color: #fff;background-color: #b3d7ff;border-color: #b3d7ff }.custom-control-input:disabled ~ .custom-control-label, .custom-control-input[disabled] ~ .custom-control-label {color: #6c757d }.custom-control-input:disabled ~ .custom-control-label::before, .custom-control-input[disabled] ~ .custom-control-label::before {background-color: #e9ecef }.custom-control-label::before {background-color: #fff;border: 1px solid #adb5bd }.custom-control-label::after {background: 50%/50% 50% no-repeat }.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after {background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e") }.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before {border-color: #007bff;background-color: #007bff }.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after {background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e") }.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before {background-color: rgba(0,123,255,.5) }.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before {background-color: rgba(0,123,255,.5) }.custom-radio .custom-control-input:checked ~ .custom-control-label::after {background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e") }.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before {background-color: rgba(0,123,255,.5) }.custom-switch .custom-control-label::after {background-color: #adb5bd }}@media (prefers-color-scheme:dark) {.custom-switch .custom-control-input:checked ~ .custom-control-label::after {background-color: #fff }.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before {background-color: rgba(0,123,255,.5) }.custom-select {color: #dee2e6;background: #000 url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat;border: 1px solid #6c757d }.custom-select:focus {border-color: #80bdff;box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) }.custom-select:focus::-ms-value {color: #dee2e6;background-color: #000 }.custom-select[multiple], .custom-select[size]:not([size="1"]) {background-image: none }.custom-select:disabled {color: #ced4da;background-color: #343a40 }.custom-select:-moz-focusring {color: transparent;text-shadow: 0 0 0 #dee2e6 }.custom-file-input:focus ~ .custom-file-label {border-color: #80bdff;box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) }.custom-file-input:disabled ~ .custom-file-label, .custom-file-input[disabled] ~ .custom-file-label {background-color: #e9ecef }.custom-file-label {color: #495057;background-color: #fff;border: 1px solid #ced4da }.custom-file-label::after {color: #495057;background-color: #e9ecef;border-left: inherit }.custom-range {background-color: transparent }.custom-range:focus::-webkit-slider-thumb {box-shadow: 0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25) }.custom-range:focus::-moz-range-thumb {box-shadow: 0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25) }.custom-range:focus::-ms-thumb {box-shadow: 0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25) }.custom-range::-moz-focus-outer {border: 0 }.custom-range::-webkit-slider-thumb {background-color: #007bff;border: 0 }}@media (prefers-color-scheme:dark) {.custom-range::-webkit-slider-thumb:active {background-color: #b3d7ff }.custom-range::-webkit-slider-runnable-track {color: transparent;background-color: #dee2e6;border-color: transparent }.custom-range::-moz-range-thumb {background-color: #007bff;border: 0 }}@media (prefers-color-scheme:dark) {.custom-range::-moz-range-thumb:active {background-color: #b3d7ff }.custom-range::-moz-range-track {color: transparent;background-color: #dee2e6;border-color: transparent }.custom-range::-ms-thumb {background-color: #007bff;border: 0 }}@media (prefers-color-scheme:dark) {.custom-range::-ms-thumb:active {background-color: #b3d7ff }.custom-range::-ms-track {color: transparent;background-color: transparent;border-color: transparent }.custom-range::-ms-fill-lower {background-color: #dee2e6 }.custom-range::-ms-fill-upper {background-color: #dee2e6 }.custom-range:disabled::-webkit-slider-thumb {background-color: #adb5bd }.custom-range:disabled::-moz-range-thumb {background-color: #adb5bd }.custom-range:disabled::-ms-thumb {background-color: #adb5bd }}@media (prefers-color-scheme:dark) {.nav-link.disabled {color: #6c757d }.nav-tabs {border-bottom: 1px solid rgba(255,255,255,.125) }.nav-tabs .nav-link {background-color: transparent;border: 1px solid transparent }.nav-tabs .nav-link:focus, .nav-tabs .nav-link:hover {border-color: #495057 #495057 rgba(255,255,255,.125) }.nav-tabs .nav-link.disabled {color: #6c757d;background-color: transparent;border-color: transparent }.nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-link.active {color: #f8f9fa;background-color: #191d21;border-color: #495057 #495057 #191d21 }.nav-pills .nav-link {background: 0 0;border: 0 }.nav-pills .nav-link.active, .nav-pills .show > .nav-link {color: #fff;background-color: #007bff }.navbar-toggler {background-color: transparent;border: 1px solid transparent }.navbar-toggler-icon {background: 50%/100% 100% no-repeat }}@media (prefers-color-scheme:dark) {.navbar-light .navbar-brand {color: rgba(0,0,0,.9) }.navbar-light .navbar-brand:focus, .navbar-light .navbar-brand:hover {color: rgba(0,0,0,.9) }.navbar-light .navbar-nav .nav-link {color: rgba(0,0,0,.5) }.navbar-light .navbar-nav .nav-link:focus, .navbar-light .navbar-nav .nav-link:hover {color: rgba(0,0,0,.7) }.navbar-light .navbar-nav .nav-link.disabled {color: rgba(0,0,0,.3) }.navbar-light .navbar-nav .active > .nav-link, .navbar-light .navbar-nav .nav-link.active, .navbar-light .navbar-nav .nav-link.show, .navbar-light .navbar-nav .show > .nav-link {color: rgba(0,0,0,.9) }.navbar-light .navbar-toggler {color: rgba(0,0,0,.5);border-color: rgba(0,0,0,.1) }.navbar-light .navbar-toggler-icon {background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") }.navbar-light .navbar-text {color: rgba(0,0,0,.5) }.navbar-light .navbar-text a {color: rgba(0,0,0,.9) }.navbar-light .navbar-text a:focus, .navbar-light .navbar-text a:hover {color: rgba(0,0,0,.9) }.navbar-dark .navbar-brand, .navbar-themed .navbar-brand {color: #fff }.navbar-dark .navbar-brand:focus, .navbar-dark .navbar-brand:hover, .navbar-themed .navbar-brand:focus, .navbar-themed .navbar-brand:hover {color: #fff }.navbar-dark .navbar-nav .nav-link, .navbar-themed .navbar-nav .nav-link {color: rgba(255,255,255,.5) }.navbar-dark .navbar-nav .nav-link:focus, .navbar-dark .navbar-nav .nav-link:hover, .navbar-themed .navbar-nav .nav-link:focus, .navbar-themed .navbar-nav .nav-link:hover {color: rgba(255,255,255,.75) }.navbar-dark .navbar-nav .nav-link.disabled, .navbar-themed .navbar-nav .nav-link.disabled {color: rgba(255,255,255,.25) }.navbar-dark .navbar-nav .active > .nav-link, .navbar-dark .navbar-nav .nav-link.active, .navbar-dark .navbar-nav .nav-link.show, .navbar-dark .navbar-nav .show > .nav-link, .navbar-themed .navbar-nav .active > .nav-link, .navbar-themed .navbar-nav .nav-link.active, .navbar-themed .navbar-nav .nav-link.show, .navbar-themed .navbar-nav .show > .nav-link {color: #fff }.navbar-dark .navbar-toggler, .navbar-themed .navbar-toggler {color: rgba(255,255,255,.5);border-color: rgba(255,255,255,.1) }.navbar-dark .navbar-toggler-icon, .navbar-themed .navbar-toggler-icon {background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") }.navbar-dark .navbar-text, .navbar-themed .navbar-text {color: rgba(255,255,255,.5) }.navbar-dark .navbar-text a, .navbar-themed .navbar-text a {color: #fff }.navbar-dark .navbar-text a:focus, .navbar-dark .navbar-text a:hover, .navbar-themed .navbar-text a:focus, .navbar-themed .navbar-text a:hover {color: #fff }.card {background-color: #212529;border: 1px solid rgba(255,255,255,.125) }.card > .list-group {border-top: inherit;border-bottom: inherit }.card > .card-header + .list-group, .card > .list-group + .card-footer {border-top: 0 }.card-header {background-color: rgba(255,255,255,.03);border-bottom: 1px solid rgba(255,255,255,.125) }.card-footer {background-color: rgba(255,255,255,.03);border-top: 1px solid rgba(255,255,255,.125) }.card-header-tabs {border-bottom: 0 }}@media (prefers-color-scheme:dark) and (min-width:576px) {.card-group > .card + .card {border-left: 0 }}@media (prefers-color-scheme:dark) {.accordion > .card:not(:last-of-type) {border-bottom: 0 }.breadcrumb {background-color: #343a40 }.breadcrumb-item + .breadcrumb-item::before {color: #ced4da }.breadcrumb-item.active {color: #ced4da }.page-link {color: #adadad;background-color: #000;border: 1px solid #495057 }.page-link:hover {color: #878787;background-color: #343a40;border-color: #495057 }.page-link:focus {box-shadow: 0 0 0 .2rem rgba(0,123,255,.25) }.page-item.active .page-link {color: #000;background-color: #3395ff;border-color: #3395ff }.page-item.disabled .page-link {color: #ced4da;background-color: #000;border-color: #495057 }}@media (prefers-color-scheme:dark) {.badge-primary {color: #e9ecef;background-color: #3395ff }a.badge-primary:focus, a.badge-primary:hover {color: #e9ecef;background-color: #007bff }a.badge-primary.focus, a.badge-primary:focus {box-shadow: 0 0 0 .2rem rgba(51,149,255,.5) }.badge-secondary {color: #e9ecef;background-color: #6c757d }a.badge-secondary:focus, a.badge-secondary:hover {color: #e9ecef;background-color: #545b62 }a.badge-secondary.focus, a.badge-secondary:focus {box-shadow: 0 0 0 .2rem rgba(108,117,125,.5) }.badge-success {color: #e9ecef;background-color: #28a745 }a.badge-success:focus, a.badge-success:hover {color: #e9ecef;background-color: #1e7e34 }a.badge-success.focus, a.badge-success:focus {box-shadow: 0 0 0 .2rem rgba(40,167,69,.5) }.badge-info {color: #e9ecef;background-color: #17a2b8 }a.badge-info:focus, a.badge-info:hover {color: #e9ecef;background-color: #117a8b }a.badge-info.focus, a.badge-info:focus {box-shadow: 0 0 0 .2rem rgba(23,162,184,.5) }.badge-warning {color: #343a40;background-color: #fd7e14 }a.badge-warning:focus, a.badge-warning:hover {color: #343a40;background-color: #dc6502 }a.badge-warning.focus, a.badge-warning:focus {box-shadow: 0 0 0 .2rem rgba(253,126,20,.5) }.badge-danger {color: #e9ecef;background-color: #dc3545 }a.badge-danger:focus, a.badge-danger:hover {color: #e9ecef;background-color: #bd2130 }a.badge-danger.focus, a.badge-danger:focus {box-shadow: 0 0 0 .2rem rgba(220,53,69,.5) }.badge-light {color: #343a40;background-color: #dee2e6 }a.badge-light:focus, a.badge-light:hover {color: #343a40;background-color: #c1c9d0 }a.badge-light.focus, a.badge-light:focus {box-shadow: 0 0 0 .2rem rgba(222,226,230,.5) }.badge-dark {color: #e9ecef;background-color: #343a40 }a.badge-dark:focus, a.badge-dark:hover {color: #e9ecef;background-color: #1d2124 }a.badge-dark.focus, a.badge-dark:focus {box-shadow: 0 0 0 .2rem rgba(52,58,64,.5) }.jumbotron {background-color: #343a40 }}@media (prefers-color-scheme:dark) {.alert {border: 1px solid transparent }.alert-heading {color: inherit }.alert-dismissible .close {color: inherit }.alert-primary {color: #1b4e85;background-color: #d6eaff;border-color: #c6e1ff }.alert-primary .alert-link {color: #12355b }.alert-secondary {color: #383d41;background-color: #e2e3e5;border-color: #d6d8db }.alert-secondary .alert-link {color: #202326 }.alert-success {color: #155724;background-color: #d4edda;border-color: #c3e6cb }.alert-success .alert-link {color: #0b2e13 }.alert-info {color: #0c5460;background-color: #d1ecf1;border-color: #bee5eb }.alert-info .alert-link {color: #062c33 }.alert-warning {color: #84420a;background-color: #ffe5d0;border-color: #fedbbd }.alert-warning .alert-link {color: #552a06 }.alert-danger {color: #721c24;background-color: #f8d7da;border-color: #f5c6cb }.alert-danger .alert-link {color: #491217 }.alert-light, .alert-themed-inverted {color: #737678;background-color: #f8f9fa;border-color: #f6f7f8 }.alert-light .alert-link, .alert-themed-inverted .alert-link {color: #5a5c5e }.alert-dark, .alert-themed {color: #1b1e21;background-color: #d6d8d9;border-color: #c6c8ca }.alert-dark .alert-link, .alert-themed .alert-link {color: #040505 }@-webkit-keyframes progress-bar-stripes {from {background-position: 1rem 0 }to {background-position: 0 0 }}@keyframes progress-bar-stripes {from {background-position: 1rem 0 }to {background-position: 0 0 }}.progress {background-color: #e9ecef }.progress-bar {color: #fff;background-color: #007bff }}@media (prefers-color-scheme:dark) {.progress-bar-striped {background-image: linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent) }}@media (prefers-color-scheme:dark) {.list-group-item-action {color: #dee2e6 }.list-group-item-action:focus, .list-group-item-action:hover {color: #dee2e6;background-color: #212529 }.list-group-item-action:active {color: #d3d3d3;background-color: #343a40 }.list-group-item {background-color: rgba(25,29,33,.05);border: 1px solid rgba(255,255,255,.125) }.list-group-item.disabled, .list-group-item:disabled {color: #ced4da;background-color: rgba(25,29,33,.05) }.list-group-item.active {color: #000;background-color: #3395ff;border-color: #3395ff }}@media (prefers-color-scheme:dark) {.list-group-item-primary {color: #1b4e85;background-color: #c6e1ff }.list-group-item-primary.list-group-item-action:focus, .list-group-item-primary.list-group-item-action:hover {color: #1b4e85;background-color: #add4ff }.list-group-item-primary.list-group-item-action.active {color: #fff;background-color: #1b4e85;border-color: #1b4e85 }.list-group-item-secondary {color: #383d41;background-color: #d6d8db }.list-group-item-secondary.list-group-item-action:focus, .list-group-item-secondary.list-group-item-action:hover {color: #383d41;background-color: #c8cbcf }.list-group-item-secondary.list-group-item-action.active {color: #fff;background-color: #383d41;border-color: #383d41 }.list-group-item-success {color: #155724;background-color: #c3e6cb }.list-group-item-success.list-group-item-action:focus, .list-group-item-success.list-group-item-action:hover {color: #155724;background-color: #b1dfbb }.list-group-item-success.list-group-item-action.active {color: #fff;background-color: #155724;border-color: #155724 }.list-group-item-info {color: #0c5460;background-color: #bee5eb }.list-group-item-info.list-group-item-action:focus, .list-group-item-info.list-group-item-action:hover {color: #0c5460;background-color: #abdde5 }.list-group-item-info.list-group-item-action.active {color: #fff;background-color: #0c5460;border-color: #0c5460 }.list-group-item-warning {color: #84420a;background-color: #fedbbd }.list-group-item-warning.list-group-item-action:focus, .list-group-item-warning.list-group-item-action:hover {color: #84420a;background-color: #fecda4 }.list-group-item-warning.list-group-item-action.active {color: #fff;background-color: #84420a;border-color: #84420a }.list-group-item-danger {color: #721c24;background-color: #f5c6cb }.list-group-item-danger.list-group-item-action:focus, .list-group-item-danger.list-group-item-action:hover {color: #721c24;background-color: #f1b0b7 }.list-group-item-danger.list-group-item-action.active {color: #fff;background-color: #721c24;border-color: #721c24 }.list-group-item-light {color: #737678;background-color: #f6f7f8 }.list-group-item-light.list-group-item-action:focus, .list-group-item-light.list-group-item-action:hover {color: #737678;background-color: #e8eaed }.list-group-item-light.list-group-item-action.active {color: #fff;background-color: #737678;border-color: #737678 }.list-group-item-dark {color: #1b1e21;background-color: #c6c8ca }.list-group-item-dark.list-group-item-action:focus, .list-group-item-dark.list-group-item-action:hover {color: #1b1e21;background-color: #b9bbbe }.list-group-item-dark.list-group-item-action.active {color: #fff;background-color: #1b1e21;border-color: #1b1e21 }.close {color: #fff;text-shadow: 0 1px 0 #000 }.close:hover {color: #fff }button.close {background-color: transparent;border: 0 }.toast {background-color: rgba(0,0,0,.85);border: 1px solid rgba(255,255,255,.1);box-shadow: 0 .25rem .75rem rgba(255,255,255,.1) }.toast-header {color: #ced4da;background-color: rgba(0,0,0,.85);border-bottom: 1px solid rgba(255,255,255,.05) }}@media (prefers-color-scheme:dark) {.modal-content {background-color: #191d21;border: 1px solid rgba(255,255,255,.2) }.modal-backdrop {background-color: #000 }.modal-header {border-bottom: 1px solid #343a40 }.modal-footer {border-top: 1px solid #343a40 }}@media (prefers-color-scheme:dark) {.tooltip {text-shadow: none }.tooltip .arrow::before {border-color: transparent }.tooltip-inner {color: #fff;background-color: #000 }.popover {text-shadow: none;background-color: #fff;border: 1px solid rgba(0,0,0,.2) }.popover .arrow::after, .popover .arrow::before {border-color: transparent }.bs-popover-auto[x-placement^=bottom] .popover-header::before, .bs-popover-bottom .popover-header::before {border-bottom: 1px solid #f7f7f7 }.popover-header {background-color: #f7f7f7;border-bottom: 1px solid #ebebeb }.popover-body {color: #212529 }}@media (prefers-color-scheme:dark) {.carousel-control-next, .carousel-control-prev {color: #fff;background: 0 0;border: 0 }}@media (prefers-color-scheme:dark) {.carousel-control-next:focus, .carousel-control-next:hover, .carousel-control-prev:focus, .carousel-control-prev:hover {color: #fff }.carousel-control-next-icon, .carousel-control-prev-icon {background: 50%/100% 100% no-repeat }.carousel-control-prev-icon {background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e") }.carousel-control-next-icon {background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e") }.carousel-indicators li {background-color: #fff;border-top: 10px solid transparent;border-bottom: 10px solid transparent }}@media (prefers-color-scheme:dark) {.carousel-caption {color: #fff }@-webkit-keyframes spinner-border {to {transform: rotate(360deg) }}@keyframes spinner-border {to {transform: rotate(360deg) }}.spinner-border {border: .25em solid currentcolor }@-webkit-keyframes spinner-grow {0% {transform: scale(0) }50% {opacity: 1;transform: none }}@keyframes spinner-grow {0% {transform: scale(0) }50% {opacity: 1;transform: none }}.spinner-grow {background-color: currentcolor }}@media (prefers-color-scheme:dark) {.bg-primary {background-color: #3395ff!important }a.bg-primary:focus, a.bg-primary:hover, button.bg-primary:focus, button.bg-primary:hover {background-color: #007bff!important }.bg-secondary {background-color: #6c757d!important }a.bg-secondary:focus, a.bg-secondary:hover, button.bg-secondary:focus, button.bg-secondary:hover {background-color: #545b62!important }.bg-success {background-color: #28a745!important }a.bg-success:focus, a.bg-success:hover, button.bg-success:focus, button.bg-success:hover {background-color: #1e7e34!important }.bg-info {background-color: #17a2b8!important }a.bg-info:focus, a.bg-info:hover, button.bg-info:focus, button.bg-info:hover {background-color: #117a8b!important }.bg-warning {background-color: #fd7e14!important }a.bg-warning:focus, a.bg-warning:hover, button.bg-warning:focus, button.bg-warning:hover {background-color: #dc6502!important }.bg-danger {background-color: #dc3545!important }a.bg-danger:focus, a.bg-danger:hover, button.bg-danger:focus, button.bg-danger:hover {background-color: #bd2130!important }.bg-light, .bg-themed-inverted {background-color: #dee2e6!important }a.bg-light:focus, a.bg-light:hover, a.bg-themed-inverted:focus, a.bg-themed-inverted:hover, button.bg-light:focus, button.bg-light:hover, button.bg-themed-inverted:focus, button.bg-themed-inverted:hover {background-color: #c1c9d0!important }.bg-dark, .bg-themed, .navbar-themed {background-color: #343a40!important }a.bg-dark:focus, a.bg-dark:hover, a.bg-themed:focus, a.bg-themed:hover, a.navbar-themed:focus, a.navbar-themed:hover, button.bg-dark:focus, button.bg-dark:hover, button.bg-themed:focus, button.bg-themed:hover, button.navbar-themed:focus, button.navbar-themed:hover {background-color: #1d2124!important }.bg-white {background-color: #fff!important }.bg-transparent {background-color: transparent!important }.border {border: 1px solid #343a40!important }.border-top {border-top: 1px solid #343a40!important }.border-right {border-right: 1px solid #343a40!important }.border-bottom {border-bottom: 1px solid #343a40!important }.border-left {border-left: 1px solid #343a40!important }.border-0 {border: 0!important }.border-top-0 {border-top: 0!important }.border-right-0 {border-right: 0!important }.border-bottom-0 {border-bottom: 0!important }.border-left-0 {border-left: 0!important }.border-primary {border-color: #3395ff!important }.border-secondary {border-color: #6c757d!important }.border-success {border-color: #28a745!important }.border-info {border-color: #17a2b8!important }.border-warning {border-color: #fd7e14!important }.border-danger {border-color: #dc3545!important }.border-light {border-color: #dee2e6!important }.border-dark {border-color: #343a40!important }.border-white {border-color: #fff!important }}@media (prefers-color-scheme:dark) {.embed-responsive .embed-responsive-item, .embed-responsive embed, .embed-responsive iframe, .embed-responsive object, .embed-responsive video {border: 0 }}@media (prefers-color-scheme:dark) {@supports ((position:-webkit-sticky) or (position:sticky)) {.sticky-top {position: -webkit-sticky;position: sticky;top: 0;z-index: 1020 }}.sr-only {border: 0 }.shadow-sm {box-shadow: 0 .125rem .25rem rgba(0,0,0,.075)!important }.shadow {box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important }.shadow-lg {box-shadow: 0 1rem 3rem rgba(0,0,0,.175)!important }.shadow-none {box-shadow: none!important }}@media (prefers-color-scheme:dark) {.stretched-link::after {background-color: rgba(0,0,0,0) }}@media (prefers-color-scheme:dark) {.text-white {color: #fff!important }.text-primary {color: #3395ff!important }a.text-primary:focus, a.text-primary:hover {color: #006fe6!important }.text-secondary {color: #6c757d!important }a.text-secondary:focus, a.text-secondary:hover {color: #494f54!important }.text-success {color: #28a745!important }a.text-success:focus, a.text-success:hover {color: #19692c!important }.text-info {color: #17a2b8!important }a.text-info:focus, a.text-info:hover {color: #0f6674!important }.text-warning {color: #fd7e14!important }a.text-warning:focus, a.text-warning:hover {color: #c35a02!important }.text-danger {color: #dc3545!important }a.text-danger:focus, a.text-danger:hover {color: #a71d2a!important }.text-light, .text-themed {color: #dee2e6!important }a.text-light:focus, a.text-light:hover, a.text-themed:focus, a.text-themed:hover {color: #b2bcc5!important }.text-dark, .text-themed-inverted {color: #343a40!important }a.text-dark:focus, a.text-dark:hover, a.text-themed-inverted:focus, a.text-themed-inverted:hover {color: #121416!important }.text-body {color: #d3d3d3!important }.text-muted {color: #6c757d!important }.text-black-50 {color: rgba(0,0,0,.5)!important }.text-white-50 {color: rgba(255,255,255,.5)!important }.text-hide {color: transparent;text-shadow: none;background-color: transparent;border: 0 }.text-reset {color: inherit!important }} \ No newline at end of file diff --git a/uffd/static/style-1.css b/uffd/static/style-1.css new file mode 100644 index 0000000000000000000000000000000000000000..1a252a6c9ea62f11618d33b45ebd4fb6141e75b0 --- /dev/null +++ b/uffd/static/style-1.css @@ -0,0 +1,65 @@ +.footer { + position: fixed; + bottom: 0; + width: 100%; + height: 2em; + line-height: 2em; + background-color: #f5f5f5; +} + +.narrow-card { + background: #f7f7f7; + box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); + padding: 30px; +} + +.qrcode { + background: #ffffff; +} + +@media (prefers-color-scheme: dark) { + .footer { + background-color: #23282d; + } + + .narrow-card { + background: #292e33; + } + + a { + color: #3395ff; + background-color: transparent + } + a:hover { + color: #0d82ff + } + + .btn-link { + color: #3395ff; + } + .btn-link:hover { + color: #0d82ff; + } + .btn-link.disabled, + .btn-link:disabled { + color: #3395ff; + } + + /* Dark theme breaks spinners (appears as full ring without animation) */ + .spinner-border { + display: inline-block; + width: 2rem; + height: 2rem; + vertical-align: text-bottom; + border: 0.25em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + -webkit-animation: spinner-border .75s linear infinite; + animation: spinner-border .75s linear infinite; + } + .spinner-border-sm { + width: 1rem; + height: 1rem; + border-width: 0.2em; + } +} diff --git a/uffd/static/style.css b/uffd/static/style.css deleted file mode 100644 index dde08a5904cab0ab11c51d03f75566d4cb03df85..0000000000000000000000000000000000000000 --- a/uffd/static/style.css +++ /dev/null @@ -1,8 +0,0 @@ -.footer { - position: fixed; - bottom: 0; - width: 100%; - height: 2em; - line-height: 2em; - background-color: #f5f5f5; -} diff --git a/uffd/template_helper.py b/uffd/template_helper.py index ed46b8656d07aaa1ed44f6e9cb75f2424daef595..e2f42dbc45b4e58b98b20fc1ac576e8c16077e47 100644 --- a/uffd/template_helper.py +++ b/uffd/template_helper.py @@ -15,13 +15,18 @@ def register_template_helper(app): @app.template_filter() def qrcode_svg(content, **attrs): #pylint: disable=unused-variable - img = qrcode.make(content, image_factory=qrcode.image.svg.SvgPathImage, border=0) + img = qrcode.make(content, image_factory=qrcode.image.svg.SvgPathImage, border=4) svg = img.get_image() for key, value, in attrs.items(): svg.set(key, value) buf = io.BytesIO() img.save(buf) - return Markup(buf.getvalue().decode().replace('<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n', '').replace(' id="qr-path" ', ' ')) + return Markup( + buf.getvalue().decode() + .replace('<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n', '') + .replace(' id="qr-path" ', ' ') + .replace('<svg ', '<svg class="qrcode" ') + ) @app.template_filter() def datauri(data, mimetype='text/plain'): #pylint: disable=unused-variable diff --git a/uffd/templates/403.html b/uffd/templates/403.html index 5a19dc9820bc1a6edabeb85c2856b48e4b4b5dcb..b6c70b909197342522629ef25f34ca209a019af2 100644 --- a/uffd/templates/403.html +++ b/uffd/templates/403.html @@ -2,7 +2,7 @@ {% block body %} <div class="row mt-2 justify-content-center"> - <div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;"> + <div class="col-lg-6 col-md-10 narrow-card"> <div class="text-center"> <img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" > </div> diff --git a/uffd/templates/base.html b/uffd/templates/base.html index d3a8b1388cb146f506f29010b12538769ee20539..2bf30e57476d4908dfcf5e6ee699f95abec804f9 100644 --- a/uffd/templates/base.html +++ b/uffd/templates/base.html @@ -7,8 +7,9 @@ <title>{% block title %}{{ config['SITE_TITLE'] }}{% endblock %}</title> <link href="{{ url_for('static', filename="bootstrap/bootstrap.min.css") }}" rel="stylesheet"> + <link href="{{ url_for('static', filename="bootstrap/bootstrap-prefers-dark-color-only.min.css") }}" rel="stylesheet"> <link href="{{ url_for('static', filename="fa/css/all.css") }}" rel="stylesheet"> - <link href="{{ url_for('static', filename="style.css") }}" rel="stylesheet"> + <link href="{{ url_for('static', filename="style-1.css") }}" rel="stylesheet"> <script src="{{ url_for('static', filename="jquery/jquery-3.4.1.min.js") }}"></script> <script src="{{ url_for('static', filename="popper/popper-1.16.0.min.js") }}"></script> <script src="{{ url_for('static', filename="bootstrap/bootstrap.min.js") }}"></script> diff --git a/uffd/templates/base_narrow.html b/uffd/templates/base_narrow.html index e4d2389b123d7c9ae04afe158ccb8e34b70b18e3..3a3502947337d0207c98404d8e493d27a5735815 100644 --- a/uffd/templates/base_narrow.html +++ b/uffd/templates/base_narrow.html @@ -14,7 +14,7 @@ </div> </div> <div class="row justify-content-center"> - <div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;"> + <div class="col-lg-6 col-md-10 narrow-card"> <div class="text-center"> <img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" > </div> diff --git a/uffd/templates/group/show.html b/uffd/templates/group/show.html index 698d483668ece80a98e101d407fbae0cec533c6d..5f7c165897c29ddad66bb22f80fc04dbaae88255 100644 --- a/uffd/templates/group/show.html +++ b/uffd/templates/group/show.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block body %} -<form action="{{ url_for("group.update", id=group.id) }}" method="POST"> +<form action="{{ url_for("group.update", id=group.id) }}" method="POST" autocomplete="off"> <div class="align-self-center"> <div class="clearfix pb-2 col"> <div class="float-sm-right"> diff --git a/uffd/templates/invite/new.html b/uffd/templates/invite/new.html index 0e734a7599231c6700096b14c69e5ad5f6316135..547a5cf7a0514752a46998a6a0db615ced88fd66 100644 --- a/uffd/templates/invite/new.html +++ b/uffd/templates/invite/new.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block body %} -<form action="{{ url_for("invite.new_submit") }}" method="POST" class="form"> +<form action="{{ url_for("invite.new_submit") }}" method="POST" autocomplete="off" class="form"> <div class="form-group"> <label for="single-use">{{_('Link Type')}}</label> <select class="form-control" id="single-use" name="single-use"> diff --git a/uffd/templates/mail/show.html b/uffd/templates/mail/show.html index 0f11fa9cd71518520c6358fd93904715b27ab9a7..7cb7a2cc0549d44675cf70c96ac41625104d487c 100644 --- a/uffd/templates/mail/show.html +++ b/uffd/templates/mail/show.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block body %} -<form action="{{ url_for("mail.update", mail_id=mail.id) }}" method="POST"> +<form action="{{ url_for("mail.update", mail_id=mail.id) }}" method="POST" autocomplete="off"> <div class="align-self-center"> <div class="form-group col"> <label for="mail-name">{{_('Name')}}</label> diff --git a/uffd/templates/role/show.html b/uffd/templates/role/show.html index 282bbb7c186968fcdbbdaa0afddf7d45a48db961..f9b548c76e6bf2a7394fa36435c95f1f8a656a2c 100644 --- a/uffd/templates/role/show.html +++ b/uffd/templates/role/show.html @@ -7,7 +7,7 @@ </div> {% endif %} -<form action="{{ url_for("role.update", roleid=role.id) }}" method="POST"> +<form action="{{ url_for("role.update", roleid=role.id) }}" method="POST" autocomplete="off"> <div class="align-self-center"> <div class="clearfix pb-2"><div class="float-sm-right"> <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{_("Save")}}</button> diff --git a/uffd/templates/mfa/disable.html b/uffd/templates/selfservice/disable_mfa.html similarity index 81% rename from uffd/templates/mfa/disable.html rename to uffd/templates/selfservice/disable_mfa.html index d577b73b18e94cd950f168dd61146274e0262de0..504e62873ce2affaabef0a87f13195dd8b26294c 100644 --- a/uffd/templates/mfa/disable.html +++ b/uffd/templates/selfservice/disable_mfa.html @@ -7,7 +7,7 @@ You can later generate new recovery codes and setup your applications and devices again.")}} </p> -<form class="form" action="{{ url_for('mfa.disable_confirm') }}" method="POST"> +<form class="form" action="{{ url_for('selfservice.disable_mfa_confirm') }}" method="POST"> <button type="submit" class="btn btn-danger btn-block">{{_("Disable two-factor authentication")}}</button> </form> diff --git a/uffd/templates/selfservice/forgot_password.html b/uffd/templates/selfservice/forgot_password.html index 9792f15b471d058f385e3c55b6fb82c732755e86..96f8cd60a0d61963448e1610a1da3b01da0b2577 100644 --- a/uffd/templates/selfservice/forgot_password.html +++ b/uffd/templates/selfservice/forgot_password.html @@ -7,14 +7,14 @@ </div> <div class="form-group col-12"> <label for="user-loginname">{{_("Login Name")}}</label> - <input type="text" class="form-control" id="user-loginname" name="loginname" required="required" tabindex = "1"> + <input type="text" autocomplete="username" class="form-control" id="user-loginname" name="loginname" required="required" tabindex="1"> </div> <div class="form-group col-12"> <label for="user-mail">{{_("Mail Address")}}</label> - <input type="text" class="form-control" id="user-mail" name="mail" required="required" tabindex = "2"> + <input type="email" autocomplete="email" class="form-control" id="user-mail" name="mail" required="required" tabindex="2"> </div> <div class="form-group col-12"> - <button type="submit" class="btn btn-primary btn-block" tabindex = "3">{{_("Send password reset mail")}}</button> + <button type="submit" class="btn btn-primary btn-block" tabindex="3">{{_("Send password reset mail")}}</button> </div> </form> {% endblock %} diff --git a/uffd/templates/selfservice/self.html b/uffd/templates/selfservice/self.html index 6e032d5b1b67c1a38fbdebbcc82209ccac0fdc0d..1bc052c318a4fc1ac567a2e744b626c770e2d5e7 100644 --- a/uffd/templates/selfservice/self.html +++ b/uffd/templates/selfservice/self.html @@ -41,7 +41,7 @@ <form method="POST" action="{{ url_for('selfservice.add_email') }}" class="form mb-2"> <div class="row m-0"> <label class="sr-only" for="new-email-address">{{_("Email")}}</label> - <input type="text" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 20em;" id="new-email-address" name="address" placeholder="{{_("New E-Mail Address")}}" required> + <input type="email" autocomplete="email" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 20em;" id="new-email-address" name="address" placeholder="{{_("New E-Mail Address")}}" required> <button type="submit" class="btn btn-primary mb-2 col">{{_("Add address")}}</button> </div> </form> @@ -139,13 +139,13 @@ <div class="col-12 col-md-7"> <form class="form" action="{{ url_for("selfservice.change_password") }}" method="POST"> <div class="form-group"> - <input type="password" class="form-control" id="user-password1" name="password1" placeholder="{{_("New Password")}}" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required> + <input type="password" autocomplete="new-password" class="form-control" id="user-password1" name="password1" placeholder="{{_("New Password")}}" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required> <small class="form-text text-muted"> {{ User.PASSWORD_DESCRIPTION|safe }} </small> </div> <div class="form-group"> - <input type="password" class="form-control" id="user-password2" name="password2" placeholder="{{_("Repeat Password")}}" required> + <input type="password" autocomplete="new-password" class="form-control" id="user-password2" name="password2" placeholder="{{_("Repeat Password")}}" required> </div> <button type="submit" class="btn btn-primary btn-block">{{_("Change Password")}}</button> </form> @@ -167,7 +167,56 @@ {{ _("Two-factor authentication is currently <strong>disabled</strong>.")|safe }} {% endif %} </p> - <a class="btn btn-primary btn-block" href="{{ url_for('mfa.setup') }}">{{_("Manage two-factor authentication")}}</a> + <a class="btn btn-primary btn-block" href="{{ url_for('selfservice.setup_mfa') }}">{{_("Manage two-factor authentication")}}</a> + </div> +</div> + +<hr> + +<div class="row mt-3"> + <div class="col-12 col-md-5"> + <h5>{{_("Active Sessions")}}</h5> + <p>{{_("Your active login sessions on this device and other devices.")}}</p> + <p>{{_("Revoke a session to log yourself out on another device. Note that this is limited to the Single-Sign-On session and <b>does not affect login sessions on services.</b>")}}</p> + </div> + <div class="col-12 col-md-7"> + <table class="table"> + <thead> + <tr> + <th scope="col">{{_("Last used")}}</th> + <th scope="col">{{_("Device")}}</th> + <th scope="col"></th> + </tr> + </thead> + <tbody> + <tr> + <td>{{_("Just now")}}</td> + <td>{{ request.session.user_agent_browser }} on {{ request.session.user_agent_platform }} ({{ request.session.ip_address }})</td> + <td></td> + </tr> + {% for session in user.sessions|sort(attribute='last_used', reverse=True) if not session.expired and session != request.session %} + <tr> + <td> + {% set last_used_rel = session.last_used - datetime.utcnow() %} + {% if -last_used_rel.total_seconds() <= 60 %} + {{_("Just now")}} + {% else %} + {{ last_used_rel|timedeltaformat(add_direction=True, granularity='minute') }} + {% endif %} + </td> + <td>{{ session.user_agent_browser }} on {{ session.user_agent_platform }} ({{ session.ip_address }})</td> + <td> + {% if session != request.session %} + <form action="{{ url_for("selfservice.revoke_session", session_id=session.id) }}" method="POST" onsubmit='return confirm({{_("Are you sure?")|tojson|e}});'> + <button type="submit" class="btn btn-sm btn-danger float-right">{{_("Revoke")}}</button> + </form> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> </div> diff --git a/uffd/templates/selfservice/set_password.html b/uffd/templates/selfservice/set_password.html index 39e511074f0269dc44e1eb22e870d23a539020a9..bab7ce914e4214c1abdfd65b11d060191051c8a1 100644 --- a/uffd/templates/selfservice/set_password.html +++ b/uffd/templates/selfservice/set_password.html @@ -7,17 +7,17 @@ </div> <div class="form-group col-12"> <label for="user-password1">{{_("New Password")}}</label> - <input type="password" class="form-control" id="user-password1" name="password1" tabindex="2" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required> + <input type="password" autocomplete="new-password" class="form-control" id="user-password1" name="password1" tabindex="2" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required> <small class="form-text text-muted"> {{ User.PASSWORD_DESCRIPTION|safe }} </small> </div> <div class="form-group col-12"> <label for="user-password2">{{_("Repeat Password")}}</label> - <input type="password" class="form-control" id="user-password2" name="password2" tabindex="3" required> + <input type="password" autocomplete="new-password" class="form-control" id="user-password2" name="password2" tabindex="3" required> </div> <div class="form-group col-12"> - <button type="submit" class="btn btn-primary btn-block" tabindex = "3">{{_("Set password")}}</button> + <button type="submit" class="btn btn-primary btn-block" tabindex="3">{{_("Set password")}}</button> </div> </form> {% endblock %} diff --git a/uffd/templates/mfa/setup.html b/uffd/templates/selfservice/setup_mfa.html similarity index 91% rename from uffd/templates/mfa/setup.html rename to uffd/templates/selfservice/setup_mfa.html index 2922aac2862f5a6ebee75735d7cb8d97c040daf2..baa769b3036ea5b7ee8d8eea8399ece045f39b7d 100644 --- a/uffd/templates/mfa/setup.html +++ b/uffd/templates/selfservice/setup_mfa.html @@ -28,11 +28,11 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe {% if mfa_setup or mfa_enabled %} <div class="clearfix"> {% if mfa_enabled %} - <form class="form float-right" action="{{ url_for('mfa.disable') }}"> + <form class="form float-right" action="{{ url_for('selfservice.disable_mfa') }}"> <button type="submit" class="btn btn-danger mb-2">{{_("Disable two-factor authentication")}}</button> </form> {% else %} - <form class="form float-right" action="{{ url_for('mfa.disable_confirm') }}" method="POST"> + <form class="form float-right" action="{{ url_for('selfservice.disable_mfa_confirm') }}" method="POST"> <button type="submit" class="btn btn-light mb-2">{{_("Reset two-factor configuration")}}</button> </form> {% endif %} @@ -56,7 +56,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe </div> <div class="col-12 col-md-7"> - <form class="form" action="{{ url_for('mfa.setup_recovery') }}" method="POST"> + <form class="form" action="{{ url_for('selfservice.setup_mfa_recovery') }}" method="POST"> {% if mfa_init %} <button type="submit" class="btn btn-primary mb-2 col"> {{_("Generate recovery codes to enable two-factor authentication")}} @@ -93,7 +93,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe </div> <div class="col-12 col-md-7"> - <form class="form mb-2" action="{{ url_for('mfa.setup_totp') }}"> + <form class="form mb-2" action="{{ url_for('selfservice.setup_mfa_totp') }}" autocomplete="off"> <div class="row m-0"> <label class="sr-only" for="totp-name">{{_("Name")}}</label> <input type="text" name="name" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 15em;" id="totp-name" placeholder="{{_("Name")}}" required {{ 'disabled' if mfa_init }}> @@ -114,7 +114,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe <tr> <td>{{ method.name }}</td> <td>{{ method.created|dateformat }}</td> - <td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('mfa.delete_totp', id=method.id) }}">{{_("Delete")}}</a></td> + <td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('selfservice.delete_mfa_totp', id=method.id) }}">{{_("Delete")}}</a></td> </tr> {% endfor %} {% if not request.user.mfa_totp_methods %} @@ -152,7 +152,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe </div> </noscript> <div id="webauthn-alert" class="alert alert-warning d-none" role="alert"></div> - <form id="webauthn-form" class="form mb-2"> + <form id="webauthn-form" autocomplete="off" class="form mb-2"> <div class="row m-0"> <label class="sr-only" for="webauthn-name">{{_("Name")}}</label> <input type="text" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 15em;" id="webauthn-name" placeholder="{{_("Name")}}" required disabled> @@ -176,7 +176,7 @@ mfa_enabled: The user has setup at least one two-factor method. Two-factor authe <tr> <td>{{ method.name }}</td> <td>{{ method.created|dateformat }}</td> - <td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('mfa.delete_webauthn', id=method.id) }}">{{_("Delete")}}</a></td> + <td><a class="btn btn-sm btn-danger float-right" href="{{ url_for('selfservice.delete_mfa_webauthn', id=method.id) }}">{{_("Delete")}}</a></td> </tr> {% endfor %} {% if not request.user.mfa_webauthn_methods %} @@ -198,7 +198,7 @@ $('#webauthn-form').on('submit', function(e) { $('#webauthn-spinner').removeClass('d-none'); $('#webauthn-btn-text').text({{ _('Contacting server')|tojson }}); $('#webauthn-btn').prop('disabled', true); - fetch({{ url_for('mfa.setup_webauthn_begin')|tojson }}, { + fetch({{ url_for('selfservice.setup_mfa_webauthn_begin')|tojson }}, { method: 'POST', }).then(function(response) { if (response.ok) @@ -210,7 +210,7 @@ $('#webauthn-form').on('submit', function(e) { $('#webauthn-btn-text').text({{ _('Waiting for device')|tojson }}); return navigator.credentials.create(options); }).then(function(attestation) { - return fetch({{ url_for('mfa.setup_webauthn_complete')|tojson }}, { + return fetch({{ url_for('selfservice.setup_mfa_webauthn_complete')|tojson }}, { method: 'POST', headers: {'Content-Type': 'application/cbor'}, body: CBOR.encode({ @@ -223,7 +223,7 @@ $('#webauthn-form').on('submit', function(e) { if (response.ok) { $('#webauthn-spinner').addClass('d-none'); $('#webauthn-btn-text').text({{ _('Success')|tojson }}); - window.location = {{ url_for('mfa.setup')|tojson }}; + window.location = {{ url_for('selfservice.setup_mfa')|tojson }}; } else { throw new Error({{ _('Invalid response from device')|tojson }}); } diff --git a/uffd/templates/mfa/setup_recovery.html b/uffd/templates/selfservice/setup_mfa_recovery.html similarity index 94% rename from uffd/templates/mfa/setup_recovery.html rename to uffd/templates/selfservice/setup_mfa_recovery.html index 2f70cbbd4e6b4936433009e38d8d4425dd01f217..16c087b1d824104f7906e7c99244f3d416844ba4 100644 --- a/uffd/templates/mfa/setup_recovery.html +++ b/uffd/templates/selfservice/setup_mfa_recovery.html @@ -23,7 +23,7 @@ </p> <div class="btn-toolbar"> - <a class="ml-auto mb-2 btn btn-primary d-print-none" href="{{ url_for('mfa.setup') }}">{{_("Continue")}}</a> + <a class="ml-auto mb-2 btn btn-primary d-print-none" href="{{ url_for('selfservice.setup_mfa') }}">{{_("Continue")}}</a> <a class="ml-2 mb-2 btn btn-light d-print-none" href="{{ methods|map(attribute='code')|join('\n')|datauri }}" download="uffd-recovery-codes"> {{_("Download codes")}} </a> diff --git a/uffd/templates/mfa/setup_totp.html b/uffd/templates/selfservice/setup_mfa_totp.html similarity index 91% rename from uffd/templates/mfa/setup_totp.html rename to uffd/templates/selfservice/setup_mfa_totp.html index da2e331745d5ba2650f38f889e46134346ead7da..e8f7405af4e4a4fe448d761672cbbe2d3133c4eb 100644 --- a/uffd/templates/mfa/setup_totp.html +++ b/uffd/templates/selfservice/setup_mfa_totp.html @@ -32,7 +32,7 @@ </div> </div> -<form action="{{ url_for('mfa.setup_totp_finish', name=name) }}" method="POST" class="form"> +<form action="{{ url_for('selfservice.setup_mfa_totp_finish', name=name) }}" method="POST" autocomplete="off" class="form"> <div class="row m-0"> <input type="text" name="code" class="form-control mb-2 mr-2 col-auto col-md" id="code" placeholder="{{_('Code')}}" required autofocus> <button type="submit" class="btn btn-primary mb-2 col col-md-auto">{{_("Verify and complete setup")}}</button> diff --git a/uffd/templates/service/api.html b/uffd/templates/service/api.html index abe7ad6861b3edd6a321dba15aeeea00453c9bac..23b47de25bd4863a39c546ecf0f938eef5e9913a 100644 --- a/uffd/templates/service/api.html +++ b/uffd/templates/service/api.html @@ -2,7 +2,7 @@ {% block body %} <div class="row"> - <form action="{{ url_for('service.api_submit', service_id=service.id, id=client.id) }}" method="POST" class="form col-12 px-0"> + <form action="{{ url_for('service.api_submit', service_id=service.id, id=client.id) }}" method="POST" autocomplete="off" class="form col-12 px-0"> <div class="form-group col"> <p class="text-right"> @@ -24,9 +24,9 @@ <div class="form-group col"> <label for="client-auth-password">{{ _('Authentication Password') }}</label> {% if client.id %} - <input type="password" class="form-control" id="client-auth-password" name="auth_password" placeholder="●●●●●●●●"> + <input type="password" autocomplete="new-password" class="form-control" id="client-auth-password" name="auth_password" placeholder="●●●●●●●●"> {% else %} - <input type="password" class="form-control" id="client-auth-password" name="auth_password" required> + <input type="password" autocomplete="new-password" class="form-control" id="client-auth-password" name="auth_password" required> {% endif %} </div> diff --git a/uffd/templates/service/oauth2.html b/uffd/templates/service/oauth2.html index 3337f3a840e0bd97a3a7c8648805fea1d90c4feb..3ad87f5df7d6f4327fb5e6815dd4aacc925ccb38 100644 --- a/uffd/templates/service/oauth2.html +++ b/uffd/templates/service/oauth2.html @@ -2,7 +2,7 @@ {% block body %} <div class="row"> - <form action="{{ url_for('service.oauth2_submit', service_id=service.id, db_id=client.db_id) }}" method="POST" class="form col-12 px-0"> + <form action="{{ url_for('service.oauth2_submit', service_id=service.id, db_id=client.db_id) }}" method="POST" autocomplete="off" class="form col-12 px-0"> <div class="form-group col"> <p class="text-right"> @@ -24,9 +24,9 @@ <div class="form-group col"> <label for="client-client-secret">{{ _('Client Secret') }}</label> {% if client.db_id %} - <input type="password" class="form-control" id="client-client-secret" name="client_secret" placeholder="●●●●●●●●"> + <input type="password" autocomplete="new-password" class="form-control" id="client-client-secret" name="client_secret" placeholder="●●●●●●●●"> {% else %} - <input type="password" class="form-control" id="client-client-secret" name="client_secret" required> + <input type="password" autocomplete="new-password" class="form-control" id="client-client-secret" name="client_secret" required> {% endif %} </div> diff --git a/uffd/templates/service/show.html b/uffd/templates/service/show.html index 08e0629febbba15399c509cf64c895b8660911b6..b258f8cad3a5f55fc4ff59c3c9688a19ecbd1df6 100644 --- a/uffd/templates/service/show.html +++ b/uffd/templates/service/show.html @@ -4,7 +4,7 @@ <div class="row"> - <form action="{{ url_for('service.edit_submit', id=service.id) }}" method="POST" class="form col-12 px-0"> + <form action="{{ url_for('service.edit_submit', id=service.id) }}" method="POST" autocomplete="off" class="form col-12 px-0"> <div class="form-group col"> <p class="text-right"> <a href="{{ url_for('service.index') }}" class="btn btn-secondary">{{ _('Cancel') }}</a> diff --git a/uffd/templates/session/deviceauth.html b/uffd/templates/session/deviceauth.html index c0827501355d9003328d68a0d32a47ee67e36b12..b05b508d44dbeecaaac90b5d38317d284165f307 100644 --- a/uffd/templates/session/deviceauth.html +++ b/uffd/templates/session/deviceauth.html @@ -2,11 +2,11 @@ {% block body %} {% if not initiation %} -<form action="{{ url_for("session.deviceauth") }}"> +<form action="{{ url_for("session.deviceauth") }}" autocomplete="off"> {% elif not confirmation %} -<form action="{{ url_for("session.deviceauth_submit") }}" method="POST"> +<form action="{{ url_for("session.deviceauth_submit") }}" method="POST" autocomplete="off"> {% else %} -<form action="{{ url_for("session.deviceauth_finish") }}" method="POST"> +<form action="{{ url_for("session.deviceauth_finish") }}" method="POST" autocomplete="off"> {% endif %} <div class="col-12"> <h2 class="text-center">{{_('Authorize Device Login')}}</h2> diff --git a/uffd/templates/session/devicelogin.html b/uffd/templates/session/devicelogin.html index b9874ecdc8f2cff8e4b61a1a212a960a9b0e88c0..369580fe90d833ed004c189931cf0d6c8f2bb594 100644 --- a/uffd/templates/session/devicelogin.html +++ b/uffd/templates/session/devicelogin.html @@ -1,7 +1,7 @@ {% extends 'base_narrow.html' %} {% block body %} -<form action="{{ url_for("session.devicelogin_submit", ref=ref) }}" method="POST"> +<form action="{{ url_for("session.devicelogin_submit", ref=ref) }}" method="POST" autocomplete="off"> <div class="col-12"> <h2 class="text-center">{{_('Device Login')}}</h2> </div> diff --git a/uffd/templates/session/login.html b/uffd/templates/session/login.html index 8b963c2945b99799ae40b1b2a10a65fcde6758a1..dccedd82f51c6cd99750a20f77d394f55968963d 100644 --- a/uffd/templates/session/login.html +++ b/uffd/templates/session/login.html @@ -10,14 +10,14 @@ {% endif %} <div class="form-group col-12"> <label for="user-loginname">{{_("Login Name")}}</label> - <input type="text" class="form-control" id="user-loginname" name="loginname" required="required" tabindex = "1" autofocus> + <input type="text" autocomplete="username" class="form-control" id="user-loginname" name="loginname" required="required" tabindex="1" autofocus> </div> <div class="form-group col-12"> <label for="user-password1">{{_("Password")}}</label> - <input type="password" class="form-control" id="user-password1" name="password" required="required" tabindex = "2"> + <input type="password" autocomplete="current-password" class="form-control" id="user-password1" name="password" required="required" tabindex="2"> </div> <div class="form-group col-12"> - <button type="submit" class="btn btn-primary btn-block" tabindex = "3">{{_("Login")}}</button> + <button type="submit" class="btn btn-primary btn-block" tabindex="3">{{_("Login")}}</button> </div> {% if request.values.get('devicelogin') %} <div class="text-center text-muted mb-3">{{_("- or -")}}</div> diff --git a/uffd/templates/mfa/auth.html b/uffd/templates/session/mfa_auth.html similarity index 94% rename from uffd/templates/mfa/auth.html rename to uffd/templates/session/mfa_auth.html index 8b64a597c8eb9190a6ee6d13524758ab6430a9fd..cd9b306071f4aa5f7e6b702f3a0dabee2da64f3f 100644 --- a/uffd/templates/mfa/auth.html +++ b/uffd/templates/session/mfa_auth.html @@ -1,7 +1,7 @@ {% extends 'base_narrow.html' %} {% block body %} -<form action="{{ url_for("mfa.auth_finish", ref=ref) }}" method="POST"> +<form action="{{ url_for("session.mfa_auth_finish", ref=ref) }}" method="POST" autocomplete="off"> <div class="col-12 mb-3"> <h2 class="text-center">{{_("Two-Factor Authentication")}}</h2> </div> @@ -24,7 +24,7 @@ <div class="text-center text-muted d-none webauthn-group mb-3">- {{_("or")}} -</div> {% endif %} <div class="form-group col-12 mb-2"> - <input type="text" class="form-control" id="mfa-code" name="code" required="required" placeholder="{{_("Code from your authenticator app or recovery code")}}" autocomplete="off" autofocus> + <input type="text" class="form-control" id="mfa-code" name="code" required="required" placeholder="{{_("Code from your authenticator app or recovery code")}}" autofocus> </div> <div class="form-group col-12"> <button type="submit" class="btn btn-primary btn-block">{{_("Verify")}}</button> @@ -42,7 +42,7 @@ function begin_webauthn() { $('#webauthn-spinner').removeClass('d-none'); $('#webauthn-btn-text').text({{ _('Contacting server')|tojson }}); $('#webauthn-btn').prop('disabled', true); - fetch({{ url_for('mfa.auth_webauthn_begin')|tojson }}, { + fetch({{ url_for('session.mfa_auth_webauthn_begin')|tojson }}, { method: 'POST', }).then(function(response) { if (response.ok) { @@ -60,7 +60,7 @@ function begin_webauthn() { return navigator.credentials.get(options); }).then(function(assertion) { $('#webauthn-btn-text').text({{ _('Verifing response')|tojson }}); - return fetch({{ url_for('mfa.auth_webauthn_complete')|tojson }}, { + return fetch({{ url_for('session.mfa_auth_webauthn_complete')|tojson }}, { method: 'POST', headers: {'Content-Type': 'application/cbor'}, body: CBOR.encode({ diff --git a/uffd/templates/signup/confirm.html b/uffd/templates/signup/confirm.html index 087ef8b08892a046c51c5b5ec1e2da29dce02d42..3fcfb47f9337ea46b44ef4d989bfaf341a42543a 100644 --- a/uffd/templates/signup/confirm.html +++ b/uffd/templates/signup/confirm.html @@ -7,7 +7,7 @@ </div> <div class="form-group col-12"> <label for="user-password1">{{_('Please enter your password to complete the account registration')}}</label> - <input type="password" class="form-control" id="user-password1" name="password" required="required"> + <input type="password" autocomplete="current-password" class="form-control" id="user-password1" name="password" required="required"> </div> <div class="form-group col-12"> <button type="submit" class="btn btn-primary btn-block">{{_('Complete Account Registration')}}</button> diff --git a/uffd/templates/signup/start.html b/uffd/templates/signup/start.html index 1c72f469dde0d2d0e9fe0b1783a208e02e3fd6d8..09e0ad36aed4b61e44cc43ae10ef5b172ac50888 100644 --- a/uffd/templates/signup/start.html +++ b/uffd/templates/signup/start.html @@ -8,7 +8,7 @@ <div class="form-group col-12"> <label for="user-loginname">{{_('Login Name')}}</label> <div class="js-only-input-group"> - <input type="text" class="form-control" id="user-loginname" name="loginname" aria-describedby="loginname-feedback" value="{{ request.form.loginname }}" minlength=1 maxlength=32 pattern="[a-z0-9_-]*" required> + <input type="text" autocomplete="username" class="form-control" id="user-loginname" name="loginname" aria-describedby="loginname-feedback" value="{{ request.form.loginname }}" minlength=1 maxlength=32 pattern="[a-z0-9_-]*" required> <div class="js-only-input-group-append d-none"> <button class="btn btn-outline-secondary rounded-right" type="button" id="check-loginname">{{_('Check')}}</button> </div> @@ -20,28 +20,28 @@ </div> <div class="form-group col-12"> <label for="user-displayname">{{_('Display Name')}}</label> - <input type="text" class="form-control" id="user-displayname" name="displayname" value="{{ request.form.displayname }}" minlength=1 maxlength=128 required> + <input type="text" autocomplete="nickname" class="form-control" id="user-displayname" name="displayname" value="{{ request.form.displayname }}" minlength=1 maxlength=128 required> <small class="form-text text-muted"> {{_('At least one and at most 128 characters, no other special requirements.')}} </small> </div> <div class="form-group col-12"> <label for="user-mail">{{_('E-Mail Address')}}</label> - <input type="email" class="form-control" id="user-mail" name="mail" value="{{ request.form.mail }}" required> + <input type="email" autocomplete="email" class="form-control" id="user-mail" name="mail" value="{{ request.form.mail }}" required> <small class="form-text text-muted"> {{_('We will send a confirmation mail to this address that you need to complete the registration.')}} </small> </div> <div class="form-group col-12"> <label for="user-password1">{{_('Password')}}</label> - <input type="password" class="form-control" id="user-password1" name="password1" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required> + <input type="password" autocomplete="new-password" class="form-control" id="user-password1" name="password1" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}" required> <small class="form-text text-muted"> {{ User.PASSWORD_DESCRIPTION|safe }} </small> </div> <div class="form-group col-12"> <label for="user-password2">{{_('Repeat Password')}}</label> - <input type="password" class="form-control" id="user-password2" name="password2" required> + <input type="password" autocomplete="new-password" class="form-control" id="user-password2" name="password2" required> </div> <div class="form-group col-12"> <button type="submit" class="btn btn-primary btn-block">{{_('Create Account')}}</button> diff --git a/uffd/templates/user/show.html b/uffd/templates/user/show.html index 04b2cf359c000a995d3d8687ecd3940aed0e86a2..ca7dfb54eec74fb7b0df428cc8462f553ffcaa07 100644 --- a/uffd/templates/user/show.html +++ b/uffd/templates/user/show.html @@ -17,9 +17,9 @@ {% block body %} {% if user.id %} -<form action="{{ url_for("user.update", id=user.id) }}" method="POST"> +<form action="{{ url_for("user.update", id=user.id) }}" method="POST" autocomplete="off"> {% else %} -<form action="{{ url_for("user.create") }}" method="POST"> +<form action="{{ url_for("user.create") }}" method="POST" autocomplete="off"> {% endif %} <div class="align-self-center"> {% if user.id and user.is_deactivated %} @@ -174,9 +174,9 @@ <div class="form-group col"> <label for="user-loginname">{{_("Password")}}</label> {% if user.id %} - <input type="password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}"> + <input type="password" autocomplete="new-password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}"> {% else %} - <input type="password" class="form-control" id="user-password" name="password" placeholder="{{_("E-Mail to set it will be sent")}}" readonly> + <input type="text" class="form-control" id="user-password" name="password" placeholder="{{_("E-Mail to set it will be sent")}}" readonly> {% endif %} <small class="form-text text-muted"> {{ User.PASSWORD_DESCRIPTION|safe }} @@ -190,8 +190,15 @@ {{ _("Status:") }} {{ _("Enabled") if user.mfa_enabled else _("Disabled") }}<br> {{ user.mfa_recovery_codes|length }} {{ _("Recovery Codes") }}, {{ user.mfa_totp_methods|length }} {{ _("Authenticator Apps (TOTP)") }}, {{ user.mfa_webauthn_methods|length }} {{ _("U2F and FIDO2 Devices") }} </p> - <a href="{{ url_for("mfa.admin_disable", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-secondary">{{_("Reset 2FA")}}</a> + <a href="{{ url_for("user.disable_mfa", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-secondary">{{_("Reset 2FA")}}</a> </div> + + <div class="form-group col"> + <div class="mb-1">{{_("Sessions")}}</div> + <p>{{ _("%(session_count)d active sessions", session_count=user.sessions|rejectattr('expired')|list|length) }}</p> + <a href="{{ url_for("user.revoke_sessions", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-secondary">{{_("Revoke all sessions")}}</a> + </div> + {% endif %} </div> diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index 68f53c06570d8c2f651b2e70a4884e8674ca69ba..239b32b84951bc3fb8c285fb4ac17427fbf5f79e 100644 Binary files a/uffd/translations/de/LC_MESSAGES/messages.mo and b/uffd/translations/de/LC_MESSAGES/messages.mo differ diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 4f4d621c0e7c7c28629c69dc0a1e8584c4bb0278..59f10cdcb2d209cc918b9c4da48713dd18c4d33c 100644 --- a/uffd/translations/de/LC_MESSAGES/messages.po +++ b/uffd/translations/de/LC_MESSAGES/messages.po @@ -7,30 +7,30 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-11-13 02:05+0100\n" +"POT-Creation-Date: 2024-03-24 18:37+0100\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" "Language-Team: de <LL@li.org>\n" -"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.8.0\n" +"Generated-By: Babel 2.10.3\n" -#: uffd/models/invite.py:82 uffd/models/invite.py:105 uffd/models/invite.py:110 +#: uffd/models/invite.py:84 uffd/models/invite.py:107 uffd/models/invite.py:112 msgid "Invite link is invalid" msgstr "Einladungslink ist ungültig" -#: uffd/models/invite.py:84 +#: uffd/models/invite.py:86 msgid "Invite link does not grant any roles" msgstr "Einladungslink weist keine Rollen zu" -#: uffd/models/invite.py:86 +#: uffd/models/invite.py:88 msgid "Invite link does not grant any new roles" msgstr "Einladungslink weist keine neuen Rollen zu" -#: uffd/models/invite.py:91 uffd/models/signup.py:122 +#: uffd/models/invite.py:93 uffd/models/signup.py:122 #: uffd/templates/mfa/setup.html:225 msgid "Success" msgstr "Erfolgreich" @@ -61,6 +61,11 @@ msgstr "eine Stunde" msgid "%(hours)d hours" msgstr "%(hours)d Stunden" +#: uffd/models/session.py:62 uffd/models/session.py:74 +#: uffd/models/session.py:80 uffd/models/session.py:90 +msgid "Unknown" +msgstr "Unbekannt" + #: uffd/models/signup.py:78 uffd/models/signup.py:103 msgid "Invalid signup request" msgstr "Ungültiger Account-Registrierungs-Link" @@ -117,28 +122,28 @@ msgstr "Zugriff verweigert" msgid "You don't have the permission to access this page." msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen." -#: uffd/templates/base.html:84 +#: uffd/templates/base.html:85 msgid "Change" msgstr "Ändern" -#: uffd/templates/base.html:92 uffd/templates/session/deviceauth.html:12 +#: uffd/templates/base.html:93 uffd/templates/session/deviceauth.html:12 msgid "Authorize Device Login" msgstr "Gerätelogin erlauben" -#: uffd/templates/base.html:93 uffd/templates/session/devicelogin.html:6 +#: uffd/templates/base.html:94 uffd/templates/session/devicelogin.html:6 msgid "Device Login" msgstr "Gerätelogin" -#: uffd/templates/base.html:99 uffd/templates/oauth2/logout.html:5 +#: uffd/templates/base.html:100 uffd/templates/oauth2/logout.html:5 msgid "Logout" msgstr "Abmelden" -#: uffd/templates/base.html:106 uffd/templates/service/overview.html:15 +#: uffd/templates/base.html:107 uffd/templates/service/overview.html:15 #: uffd/templates/session/login.html:6 uffd/templates/session/login.html:20 msgid "Login" msgstr "Anmelden" -#: uffd/templates/base.html:142 +#: uffd/templates/base.html:143 msgid "About uffd" msgstr "Über uffd" @@ -160,18 +165,18 @@ msgstr "GID" #: uffd/templates/mfa/setup.html:157 uffd/templates/mfa/setup.html:158 #: uffd/templates/mfa/setup.html:169 uffd/templates/role/list.html:14 #: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44 -#: uffd/templates/selfservice/self.html:190 +#: uffd/templates/selfservice/self.html:239 #: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20 -#: uffd/templates/service/show.html:140 uffd/templates/user/show.html:205 -#: uffd/templates/user/show.html:237 +#: uffd/templates/service/show.html:140 uffd/templates/user/show.html:212 +#: uffd/templates/user/show.html:244 msgid "Name" msgstr "Name" #: uffd/templates/group/list.html:16 uffd/templates/group/show.html:33 #: uffd/templates/invite/new.html:36 uffd/templates/role/list.html:15 #: uffd/templates/role/show.html:48 uffd/templates/rolemod/list.html:10 -#: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:191 -#: uffd/templates/user/show.html:206 uffd/templates/user/show.html:238 +#: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:240 +#: uffd/templates/user/show.html:213 uffd/templates/user/show.html:245 msgid "Description" msgstr "Beschreibung" @@ -195,9 +200,11 @@ msgstr "Abbrechen" #: uffd/templates/group/show.html:11 uffd/templates/role/show.html:19 #: uffd/templates/role/show.html:21 uffd/templates/selfservice/self.html:61 -#: uffd/templates/selfservice/self.html:205 uffd/templates/service/api.html:11 +#: uffd/templates/selfservice/self.html:210 +#: uffd/templates/selfservice/self.html:254 uffd/templates/service/api.html:11 #: uffd/templates/service/oauth2.html:11 uffd/templates/service/show.html:12 #: uffd/templates/user/show.html:41 uffd/templates/user/show.html:193 +#: uffd/templates/user/show.html:199 msgid "Are you sure?" msgstr "Wirklich fortfahren?" @@ -1154,13 +1161,48 @@ msgstr "" msgid "Manage two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten" -#: uffd/templates/selfservice/self.html:178 uffd/templates/user/list.html:20 -#: uffd/templates/user/show.html:51 uffd/templates/user/show.html:200 +#: uffd/templates/selfservice/self.html:178 +msgid "Active Sessions" +msgstr "Aktive Sitzungen" + +#: uffd/templates/selfservice/self.html:179 +msgid "Your active login sessions on this device and other devices." +msgstr "Deine aktiven Sitzungen auf diesem und anderen Geräten." + +#: uffd/templates/selfservice/self.html:180 +msgid "" +"Revoke a session to log yourself out on another device. Note that this is" +" limited to the Single-Sign-On session and <b>does not affect login " +"sessions on services.</b>" +msgstr "" +"Widerrufe eine Sitzung, um dich auf einem anderen Gerät abzumelden. " +"Beachte dass dies auf deine Sitzung im Single-Sign-On beschränkt ist und " +"sich <b>nicht auf Sitzungen an Diensten auswirkt.</b>" + +#: uffd/templates/selfservice/self.html:186 +msgid "Last used" +msgstr "Zuletzt verwendet" + +#: uffd/templates/selfservice/self.html:187 +msgid "Device" +msgstr "Gerät" + +#: uffd/templates/selfservice/self.html:193 +#: uffd/templates/selfservice/self.html:202 +msgid "Just now" +msgstr "Gerade eben" + +#: uffd/templates/selfservice/self.html:211 +msgid "Revoke" +msgstr "Widerrufen" + +#: uffd/templates/selfservice/self.html:227 uffd/templates/user/list.html:20 +#: uffd/templates/user/show.html:51 uffd/templates/user/show.html:207 #: uffd/views/role.py:21 msgid "Roles" msgstr "Rollen" -#: uffd/templates/selfservice/self.html:179 +#: uffd/templates/selfservice/self.html:228 msgid "" "Aside from a set of base permissions, your roles determine the " "permissions of your account." @@ -1168,7 +1210,7 @@ msgstr "" "Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, " "von deinen Rollen bestimmt" -#: uffd/templates/selfservice/self.html:181 +#: uffd/templates/selfservice/self.html:230 #, python-format msgid "" "See <a href=\"%(services_url)s\">Services</a> for an overview of your " @@ -1177,13 +1219,13 @@ msgstr "" "Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick " "über deine aktuellen Berechtigungen." -#: uffd/templates/selfservice/self.html:185 +#: uffd/templates/selfservice/self.html:234 msgid "Administrators and role moderators can invite you to new roles." msgstr "" "Accounts mit Adminrechten oder Rollen-Moderationsrechten können dich zu " "Rollen einladen." -#: uffd/templates/selfservice/self.html:200 +#: uffd/templates/selfservice/self.html:249 msgid "" "Some permissions in this role require you to setup two-factor " "authentication" @@ -1191,11 +1233,11 @@ msgstr "" "Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-" "Faktor-Authentifikation" -#: uffd/templates/selfservice/self.html:206 +#: uffd/templates/selfservice/self.html:255 msgid "Leave" msgstr "Verlassen" -#: uffd/templates/selfservice/self.html:213 +#: uffd/templates/selfservice/self.html:262 msgid "You currently don't have any roles" msgstr "Du hast derzeit keine Rollen" @@ -1670,7 +1712,20 @@ msgstr "Aktiv" msgid "Reset 2FA" msgstr "2FA zurücksetzen" -#: uffd/templates/user/show.html:233 +#: uffd/templates/user/show.html:197 +msgid "Sessions" +msgstr "Sitzungen" + +#: uffd/templates/user/show.html:198 +#, python-format +msgid "%(session_count)d active sessions" +msgstr "%(session_count)d aktive Sitzungen" + +#: uffd/templates/user/show.html:199 +msgid "Revoke all sessions" +msgstr "Alle Sitzungen widerrufen" + +#: uffd/templates/user/show.html:240 msgid "Resulting groups (only updated after save)" msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)" @@ -1791,16 +1846,16 @@ msgstr "" "2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen " "werden konnte (%s)" -#: uffd/views/mfa.py:214 +#: uffd/views/mfa.py:216 #, python-format msgid "We received too many invalid attempts! Please wait at least %s." msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s." -#: uffd/views/mfa.py:228 +#: uffd/views/mfa.py:231 msgid "You have exhausted your recovery codes. Please generate new ones now!" msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!" -#: uffd/views/mfa.py:231 +#: uffd/views/mfa.py:234 msgid "" "You only have a few recovery codes remaining. Make sure to generate new " "ones before they run out." @@ -1808,12 +1863,12 @@ msgstr "" "Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere " "diese erneut bevor keine mehr übrig sind." -#: uffd/views/mfa.py:235 +#: uffd/views/mfa.py:238 msgid "Two-factor authentication failed" msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen" -#: uffd/views/oauth2.py:172 uffd/views/selfservice.py:66 -#: uffd/views/session.py:67 +#: uffd/views/oauth2.py:267 uffd/views/selfservice.py:66 +#: uffd/views/session.py:86 #, python-format msgid "" "We received too many requests from your ip address/network! Please wait " @@ -1822,19 +1877,19 @@ msgstr "" "Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem " "Netzwerk empfangen! Bitte warte mindestens %(delay)s." -#: uffd/views/oauth2.py:180 +#: uffd/views/oauth2.py:278 msgid "Device login is currently not available. Try again later!" msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!" -#: uffd/views/oauth2.py:193 +#: uffd/views/oauth2.py:296 msgid "Device login failed" msgstr "Gerätelogin fehlgeschlagen" -#: uffd/views/oauth2.py:199 +#: uffd/views/oauth2.py:304 msgid "You need to login to access this service" msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können" -#: uffd/views/oauth2.py:206 +#: uffd/views/oauth2.py:335 #, python-format msgid "" "You don't have the permission to access the service " @@ -1918,7 +1973,7 @@ msgid "E-Mail address already exists" msgstr "E-Mail-Adresse existiert bereits" #: uffd/views/selfservice.py:124 uffd/views/selfservice.py:162 -#: uffd/views/selfservice.py:227 +#: uffd/views/selfservice.py:237 #, python-format msgid "E-Mail to \"%(mail_address)s\" could not be sent!" msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!" @@ -1951,7 +2006,11 @@ msgstr "E-Mail-Adresse gelöscht" msgid "E-Mail preferences updated" msgstr "E-Mail-Einstellungen geändert" -#: uffd/views/selfservice.py:209 +#: uffd/views/selfservice.py:208 +msgid "Session revoked" +msgstr "Sitzung widerrufen" + +#: uffd/views/selfservice.py:219 #, python-format msgid "You left role %(role_name)s" msgstr "Rolle %(role_name)s verlassen" @@ -1960,7 +2019,7 @@ msgstr "Rolle %(role_name)s verlassen" msgid "Services" msgstr "Dienste" -#: uffd/views/session.py:65 +#: uffd/views/session.py:84 #, python-format msgid "" "We received too many invalid login attempts for this user! Please wait at" @@ -1969,34 +2028,34 @@ msgstr "" "Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account " "erhalten! Bitte warte mindestens %(delay)s." -#: uffd/views/session.py:74 +#: uffd/views/session.py:93 msgid "Login name or password is wrong" msgstr "Der Anmeldename oder das Passwort ist falsch" -#: uffd/views/session.py:77 +#: uffd/views/session.py:96 #, python-format msgid "Your account is deactivated. Contact %(contact_email)s for details." msgstr "" "Dein Account ist deaktiviert. Kontaktiere %(contact_email)s für weitere " "Informationen." -#: uffd/views/session.py:83 +#: uffd/views/session.py:102 msgid "You do not have access to this service" msgstr "Du hast keinen Zugriff auf diesen Service" -#: uffd/views/session.py:95 uffd/views/session.py:106 +#: uffd/views/session.py:114 uffd/views/session.py:125 msgid "You need to login first" msgstr "Du musst dich erst anmelden" -#: uffd/views/session.py:127 uffd/views/session.py:137 +#: uffd/views/session.py:146 uffd/views/session.py:156 msgid "Initiation code is no longer valid" msgstr "Startcode ist nicht mehr gültig" -#: uffd/views/session.py:141 +#: uffd/views/session.py:160 msgid "Invalid confirmation code" msgstr "Ungültiger Bestätigungscode" -#: uffd/views/session.py:153 uffd/views/session.py:164 +#: uffd/views/session.py:172 uffd/views/session.py:183 msgid "Invalid initiation code" msgstr "Ungültiger Startcode" @@ -2061,7 +2120,11 @@ msgstr "Account deaktiviert" msgid "User activated" msgstr "Account aktiviert" -#: uffd/views/user.py:174 +#: uffd/views/user.py:173 +msgid "Sessions revoked" +msgstr "Sitzungen widerrufen" + +#: uffd/views/user.py:183 msgid "Deleted user" msgstr "Account gelöscht" diff --git a/uffd/views/__init__.py b/uffd/views/__init__.py index 1ee14190f7396badd7c833e361f4481bba8e7350..b025086da6da021401f4602d144bd0d5a4046d03 100644 --- a/uffd/views/__init__.py +++ b/uffd/views/__init__.py @@ -3,7 +3,7 @@ from werkzeug.exceptions import Forbidden from uffd.secure_redirect import secure_local_redirect -from . import session, selfservice, signup, mfa, oauth2, user, group, service, role, invite, api, mail, rolemod +from . import session, selfservice, signup, oauth2, user, group, service, role, invite, api, mail, rolemod def init_app(app): @app.errorhandler(403) @@ -26,7 +26,6 @@ def init_app(app): app.register_blueprint(session.bp) app.register_blueprint(selfservice.bp) app.register_blueprint(signup.bp) - app.register_blueprint(mfa.bp) app.register_blueprint(oauth2.bp) app.register_blueprint(user.bp) app.register_blueprint(group.bp) diff --git a/uffd/views/api.py b/uffd/views/api.py index d0be61d4c731842edd6167cbde475fe3bd25dc2e..815a4d4fa3905f57aa00bbef8aec9ccd4b1c24d7 100644 --- a/uffd/views/api.py +++ b/uffd/views/api.py @@ -180,7 +180,7 @@ def prometheus_metrics(): from prometheus_client import PLATFORM_COLLECTOR, generate_latest, CONTENT_TYPE_LATEST #pylint: disable=import-outside-toplevel class UffdCollector(): - def collect(self): #pylint: disable=no-self-use + def collect(self): try: uffd_version = str(pkg_resources.get_distribution('uffd').version) except pkg_resources.DistributionNotFound: diff --git a/uffd/views/mfa.py b/uffd/views/mfa.py deleted file mode 100644 index 7471bef285048d31add7d0bf4bfb8a1883292af3..0000000000000000000000000000000000000000 --- a/uffd/views/mfa.py +++ /dev/null @@ -1,236 +0,0 @@ -from warnings import warn -import urllib.parse - -from flask import Blueprint, render_template, session, request, redirect, url_for, flash, current_app, abort -from flask_babel import gettext as _ - -from uffd.csrf import csrf_protect -from uffd.secure_redirect import secure_local_redirect -from uffd.database import db -from uffd.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod, User, Ratelimit, format_delay -from .session import login_required, login_required_pre_mfa, set_request_user - -bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/') - -mfa_ratelimit = Ratelimit('mfa', 1*60, 3) - -@bp.route('/', methods=['GET']) -@login_required() -def setup(): - return render_template('mfa/setup.html') - -@bp.route('/setup/disable', methods=['GET']) -@login_required() -def disable(): - return render_template('mfa/disable.html') - -@bp.route('/setup/disable', methods=['POST']) -@login_required() -@csrf_protect(blueprint=bp) -def disable_confirm(): - MFAMethod.query.filter_by(user=request.user).delete() - db.session.commit() - request.user.update_groups() - db.session.commit() - return redirect(url_for('mfa.setup')) - -@bp.route('/admin/<int:id>/disable') -@login_required() -@csrf_protect(blueprint=bp) -def admin_disable(id): - # Group cannot be checked with login_required kwarg, because the config - # variable is not available when the decorator is processed - if not request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): - abort(403) - user = User.query.get(id) - MFAMethod.query.filter_by(user=user).delete() - user.update_groups() - db.session.commit() - flash(_('Two-factor authentication was reset')) - return redirect(url_for('user.show', id=id)) - -@bp.route('/setup/recovery', methods=['POST']) -@login_required() -@csrf_protect(blueprint=bp) -def setup_recovery(): - for method in RecoveryCodeMethod.query.filter_by(user=request.user).all(): - db.session.delete(method) - methods = [] - for _ in range(10): - method = RecoveryCodeMethod(request.user) - methods.append(method) - db.session.add(method) - db.session.commit() - return render_template('mfa/setup_recovery.html', methods=methods) - -@bp.route('/setup/totp', methods=['GET']) -@login_required() -def setup_totp(): - method = TOTPMethod(request.user) - session['mfa_totp_key'] = method.key - return render_template('mfa/setup_totp.html', method=method, name=request.values['name']) - -@bp.route('/setup/totp', methods=['POST']) -@login_required() -@csrf_protect(blueprint=bp) -def setup_totp_finish(): - if not RecoveryCodeMethod.query.filter_by(user=request.user).all(): - flash(_('Generate recovery codes first!')) - return redirect(url_for('mfa.setup')) - method = TOTPMethod(request.user, name=request.values['name'], key=session.pop('mfa_totp_key')) - if method.verify(request.form['code']): - db.session.add(method) - request.user.update_groups() - db.session.commit() - return redirect(url_for('mfa.setup')) - flash(_('Code is invalid')) - return redirect(url_for('mfa.setup_totp', name=request.values['name'])) - -@bp.route('/setup/totp/<int:id>/delete') -@login_required() -@csrf_protect(blueprint=bp) -def delete_totp(id): #pylint: disable=redefined-builtin - method = TOTPMethod.query.filter_by(user=request.user, id=id).first_or_404() - db.session.delete(method) - request.user.update_groups() - db.session.commit() - return redirect(url_for('mfa.setup')) - -# WebAuthn support is optional because fido2 has a pretty unstable -# interface and might be difficult to install with the correct version -try: - from uffd.fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import - WEBAUTHN_SUPPORTED = True -except ImportError as err: - warn(_('2FA WebAuthn support disabled because import of the fido2 module failed (%s)')%err) - WEBAUTHN_SUPPORTED = False - -bp.add_app_template_global(WEBAUTHN_SUPPORTED, name='webauthn_supported') - -if WEBAUTHN_SUPPORTED: - def get_webauthn_server(): - hostname = urllib.parse.urlsplit(request.url).hostname - return Fido2Server(PublicKeyCredentialRpEntity(id=current_app.config.get('MFA_RP_ID', hostname), - name=current_app.config['MFA_RP_NAME'])) - - @bp.route('/setup/webauthn/begin', methods=['POST']) - @login_required() - @csrf_protect(blueprint=bp) - def setup_webauthn_begin(): - if not RecoveryCodeMethod.query.filter_by(user=request.user).all(): - abort(403) - methods = WebauthnMethod.query.filter_by(user=request.user).all() - creds = [method.cred for method in methods] - server = get_webauthn_server() - registration_data, state = server.register_begin( - { - "id": str(request.user.id).encode(), - "name": request.user.loginname, - "displayName": request.user.displayname, - }, - creds, - user_verification='discouraged', - ) - session["webauthn-state"] = state - return cbor.encode(registration_data) - - @bp.route('/setup/webauthn/complete', methods=['POST']) - @login_required() - @csrf_protect(blueprint=bp) - def setup_webauthn_complete(): - server = get_webauthn_server() - data = cbor.decode(request.get_data()) - client_data = ClientData(data["clientDataJSON"]) - att_obj = AttestationObject(data["attestationObject"]) - auth_data = server.register_complete(session["webauthn-state"], client_data, att_obj) - method = WebauthnMethod(request.user, auth_data.credential_data, name=data['name']) - db.session.add(method) - request.user.update_groups() - db.session.commit() - return cbor.encode({"status": "OK"}) - - @bp.route("/auth/webauthn/begin", methods=["POST"]) - @login_required_pre_mfa(no_redirect=True) - def auth_webauthn_begin(): - server = get_webauthn_server() - creds = [method.cred for method in request.user_pre_mfa.mfa_webauthn_methods] - if not creds: - abort(404) - auth_data, state = server.authenticate_begin(creds, user_verification='discouraged') - session["webauthn-state"] = state - return cbor.encode(auth_data) - - @bp.route("/auth/webauthn/complete", methods=["POST"]) - @login_required_pre_mfa(no_redirect=True) - def auth_webauthn_complete(): - server = get_webauthn_server() - creds = [method.cred for method in request.user_pre_mfa.mfa_webauthn_methods] - if not creds: - abort(404) - data = cbor.decode(request.get_data()) - credential_id = data["credentialId"] - client_data = ClientData(data["clientDataJSON"]) - auth_data = AuthenticatorData(data["authenticatorData"]) - signature = data["signature"] - # authenticate_complete() (as of python-fido2 v0.5.0, the version in Debian Buster) - # does not check signCount, although the spec recommends it - server.authenticate_complete( - session.pop("webauthn-state"), - creds, - credential_id, - client_data, - auth_data, - signature, - ) - session['user_mfa'] = True - set_request_user() - return cbor.encode({"status": "OK"}) - -@bp.route('/setup/webauthn/<int:id>/delete') -@login_required() -@csrf_protect(blueprint=bp) -def delete_webauthn(id): #pylint: disable=redefined-builtin - method = WebauthnMethod.query.filter_by(user=request.user, id=id).first_or_404() - db.session.delete(method) - request.user.update_groups() - db.session.commit() - return redirect(url_for('mfa.setup')) - -@bp.route('/auth', methods=['GET']) -@login_required_pre_mfa() -def auth(): - if not request.user_pre_mfa.mfa_enabled: - session['user_mfa'] = True - set_request_user() - if session.get('user_mfa'): - return secure_local_redirect(request.values.get('ref', url_for('index'))) - return render_template('mfa/auth.html', ref=request.values.get('ref')) - -@bp.route('/auth', methods=['POST']) -@login_required_pre_mfa() -def auth_finish(): - delay = mfa_ratelimit.get_delay(request.user_pre_mfa.id) - if delay: - flash(_('We received too many invalid attempts! Please wait at least %s.')%format_delay(delay)) - return redirect(url_for('mfa.auth', ref=request.values.get('ref'))) - for method in request.user_pre_mfa.mfa_totp_methods: - if method.verify(request.form['code']): - session['user_mfa'] = True - set_request_user() - return secure_local_redirect(request.values.get('ref', url_for('index'))) - for method in request.user_pre_mfa.mfa_recovery_codes: - if method.verify(request.form['code']): - db.session.delete(method) - db.session.commit() - session['user_mfa'] = True - set_request_user() - if len(request.user_pre_mfa.mfa_recovery_codes) <= 1: - flash(_('You have exhausted your recovery codes. Please generate new ones now!')) - return redirect(url_for('mfa.setup')) - if len(request.user_pre_mfa.mfa_recovery_codes) <= 5: - flash(_('You only have a few recovery codes remaining. Make sure to generate new ones before they run out.')) - return redirect(url_for('mfa.setup')) - return secure_local_redirect(request.values.get('ref', url_for('index'))) - mfa_ratelimit.log(request.user_pre_mfa.id) - flash(_('Two-factor authentication failed')) - return redirect(url_for('mfa.auth', ref=request.values.get('ref'))) diff --git a/uffd/views/oauth2.py b/uffd/views/oauth2.py index 43d65fa61e5b6b1dd8d253b3a6e53c1bda065c11..8da8a68d593463ef36f0f351a2ea92e02f2ee0ae 100644 --- a/uffd/views/oauth2.py +++ b/uffd/views/oauth2.py @@ -1,253 +1,497 @@ -import functools -import secrets import urllib.parse +import time +import json from flask import Blueprint, request, jsonify, render_template, session, redirect, url_for, flash, abort -import oauthlib.oauth2 from flask_babel import gettext as _ from sqlalchemy.exc import IntegrityError +import jwt from uffd.secure_redirect import secure_local_redirect from uffd.database import db -from uffd.models import DeviceLoginConfirmation, OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, host_ratelimit, format_delay, ServiceUser - -class UffdRequestValidator(oauthlib.oauth2.RequestValidator): - # Argument "oauthreq" is named "request" in superclass but this clashes with flask's "request" object - # Arguments "token_value" and "token_data" are named "token" in superclass but this clashs with "token" endpoint - # pylint: disable=arguments-differ,arguments-renamed,unused-argument,too-many-public-methods,abstract-method - - # In all cases (aside from validate_bearer_token), either validate_client_id or authenticate_client is called - # before anything else. authenticate_client_id would be called instead of authenticate_client for non-confidential - # clients. However, we don't support those. - def validate_client_id(self, client_id, oauthreq, *args, **kwargs): - oauthreq.client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none() - return oauthreq.client is not None - - def authenticate_client(self, oauthreq, *args, **kwargs): - authorization = oauthreq.extra_credentials.get('authorization') - if authorization: - # From RFC6749 2.3.1: - # Clients in possession of a client password MAY use the HTTP Basic authentication - # scheme as defined in [RFC2617] to authenticate with the authorization server. - # The client identifier is encoded using the "application/x-www-form-urlencoded" - # encoding algorithm per Appendix B, and the encoded value is used as the username - # the client password is encoded using the same algorithm and used as the password. - oauthreq.client_id = urllib.parse.unquote(authorization.username) - oauthreq.client_secret = urllib.parse.unquote(authorization.password) - if oauthreq.client_secret is None: - return False - oauthreq.client = OAuth2Client.query.filter_by(client_id=oauthreq.client_id).one_or_none() - if oauthreq.client is None: - return False - if not oauthreq.client.client_secret.verify(oauthreq.client_secret): - return False - if oauthreq.client.client_secret.needs_rehash: - oauthreq.client.client_secret = oauthreq.client_secret - db.session.commit() - return True +from uffd.models import ( + DeviceLoginConfirmation, OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, + host_ratelimit, format_delay, OAuth2Key, +) - def get_default_redirect_uri(self, client_id, oauthreq, *args, **kwargs): - return oauthreq.client.default_redirect_uri +def get_issuer(): + return request.host_url.rstrip('/') - def validate_redirect_uri(self, client_id, redirect_uri, oauthreq, *args, **kwargs): - return redirect_uri in oauthreq.client.redirect_uris +OIDC_SCOPES = { + # From https://openid.net/specs/openid-connect-core-1_0.html + 'openid': { + # "The sub (subject) Claim MUST always be returned in the UserInfo Response." + 'sub': None, + }, + 'profile': { + 'name': None, + 'family_name': None, + 'given_name': None, + 'middle_name': None, + 'nickname': None, + 'preferred_username': None, + 'profile': None, + 'picture': None, + 'website': None, + 'gender': None, + 'birthdate': None, + 'zoneinfo': None, + 'locale': None, + 'updated_at': None, + }, + 'email': { + 'email': None, + 'email_verified': None, + }, + # Custom scopes + 'groups': { + 'groups': None, + }, +} - def validate_response_type(self, client_id, response_type, client, oauthreq, *args, **kwargs): - return response_type == 'code' +OIDC_CLAIMS = { + 'sub': lambda service_user: str(service_user.user.unix_uid), + 'name': lambda service_user: service_user.user.displayname, + 'preferred_username': lambda service_user: service_user.user.loginname, + 'email': lambda service_user: service_user.email, + 'email_verified': lambda service_user: service_user.email_verified, + # RFC 9068 registers the "groups" claim with a syntax taken from SCIM (RFC 7643) + # that is different from what we use here. The plain list of names syntax we use + # is far more common in the context of id_token/userinfo claims. + 'groups': lambda service_user: [group.name for group in service_user.user.groups] +} - def get_default_scopes(self, client_id, oauthreq, *args, **kwargs): - return oauthreq.client.default_scopes +def render_claims(scopes, claims, service_user): + claims = dict(claims) + for scope in scopes: + claims.update(OIDC_SCOPES.get(scope, {})) + # This would be a good place to enforce permissions on available claims + res = {} + for claim, func in OIDC_CLAIMS.items(): + if claim in claims: + res[claim] = func(service_user=service_user) + return res - def validate_scopes(self, client_id, scopes, client, oauthreq, *args, **kwargs): - if scopes == ['']: - oauthreq.scopes = scopes = self.get_default_scopes(client_id, oauthreq) - return set(scopes).issubset({'profile'}) +bp = Blueprint('oauth2', __name__, template_folder='templates') - def save_authorization_code(self, client_id, code, oauthreq, *args, **kwargs): - grant = OAuth2Grant(user=oauthreq.user, client=oauthreq.client, code=code['code'], - redirect_uri=oauthreq.redirect_uri, scopes=oauthreq.scopes) - db.session.add(grant) - db.session.commit() - # Oauthlib does not really provide a way to customize grant code generation. - # Actually `code` is created just before `save_authorization_code` is called - # and the same dict is later used to generate the OAuth2 response. So by - # modifing the `code` dict we can actually influence the grant code. - code['code'] = f"{grant.id}-{code['code']}" - - def validate_code(self, client_id, code, client, oauthreq, *args, **kwargs): - if '-' not in code: - return False - grant_id, grant_code = code.split('-', 2) - oauthreq.grant = OAuth2Grant.query.get(grant_id) - if not oauthreq.grant or oauthreq.grant.client != client: - return False - if not secrets.compare_digest(oauthreq.grant.code, grant_code): - return False - if oauthreq.grant.expired: - return False - if oauthreq.grant.user.is_deactivated: - return False - oauthreq.user = oauthreq.grant.user - oauthreq.scopes = oauthreq.grant.scopes - return True - - def invalidate_authorization_code(self, client_id, code, oauthreq, *args, **kwargs): - OAuth2Grant.query.filter_by(client=oauthreq.client, code=code).delete() - db.session.commit() +@bp.route('/.well-known/openid-configuration') +def discover(): + return jsonify({ + 'issuer': get_issuer(), + 'authorization_endpoint': url_for('oauth2.authorize', _external=True), + 'token_endpoint': url_for('oauth2.token', _external=True), + 'userinfo_endpoint': url_for('oauth2.userinfo', _external=True), + 'jwks_uri': url_for('oauth2.keys', _external=True), + 'scopes_supported': sorted(OIDC_SCOPES.keys()), + 'response_types_supported': ['code'], + 'grant_types_supported': ['authorization_code'], + 'id_token_signing_alg_values_supported': OAuth2Key.get_available_algorithms(), + 'subject_types_supported': ['public'], + 'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'], + 'claims_supported': sorted(['iat', 'exp', 'aud', 'iss'] + list(OIDC_CLAIMS.keys())), + 'claims_parameter_supported': True, + 'request_uri_parameter_supported': False, # default is True + }) - def save_bearer_token(self, token_data, oauthreq, *args, **kwargs): - tok = OAuth2Token( - user=oauthreq.user, - client=oauthreq.client, - token_type=token_data['token_type'], - access_token=token_data['access_token'], - refresh_token=token_data['refresh_token'], - expires_in_seconds=token_data['expires_in'], - scopes=oauthreq.scopes - ) - db.session.add(tok) - db.session.commit() - # Oauthlib does not really provide a way to customize access/refresh token - # generation. Actually `token_data` is created just before - # `save_bearer_token` is called and the same dict is later used to generate - # the OAuth2 response. So by modifing the `token_data` dict we can actually - # influence the tokens. - token_data['access_token'] = f"{tok.id}-{token_data['access_token']}" - token_data['refresh_token'] = f"{tok.id}-{token_data['refresh_token']}" - return oauthreq.client.default_redirect_uri - - def validate_grant_type(self, client_id, grant_type, client, oauthreq, *args, **kwargs): - return grant_type == 'authorization_code' - - def confirm_redirect_uri(self, client_id, code, redirect_uri, client, oauthreq, *args, **kwargs): - return redirect_uri == oauthreq.grant.redirect_uri - - def validate_bearer_token(self, token_value, scopes, oauthreq): - if '-' not in token_value: - return False - tok_id, tok_secret = token_value.split('-', 2) - tok = OAuth2Token.query.get(tok_id) - if not tok or not secrets.compare_digest(tok.access_token, tok_secret): - return False - if tok.expired: - oauthreq.error_message = 'Token expired' - return False - if tok.user.is_deactivated: - oauthreq.error_message = 'User deactivated' - return False - if not set(scopes).issubset(tok.scopes): - oauthreq.error_message = 'Scopes invalid' - return False - oauthreq.access_token = tok - oauthreq.user = tok.user - oauthreq.scopes = scopes - oauthreq.client = tok.client - oauthreq.client_id = oauthreq.client.client_id - return True - - # get_original_scopes/validate_refresh_token are only used for refreshing tokens. We don't implement the refresh endpoint. - # revoke_token is only used for revoking access tokens. We don't implement the revoke endpoint. - # get_id_token/validate_silent_authorization/validate_silent_login are OpenID Connect specfic. - # validate_user/validate_user_match are not required for Authorization Code Grant flow. - -validator = UffdRequestValidator() -server = oauthlib.oauth2.WebApplicationServer(validator) -bp = Blueprint('oauth2', __name__, url_prefix='/oauth2/', template_folder='templates') - -@bp.errorhandler(oauthlib.oauth2.rfc6749.errors.OAuth2Error) -def handle_oauth2error(error): - return render_template('oauth2/error.html', error=type(error).__name__, error_description=error.description), 400 - -@bp.route('/authorize', methods=['GET', 'POST']) -def authorize(): - scopes, credentials = server.validate_authorization_request(request.url, request.method, request.form, request.headers) - client = OAuth2Client.query.filter_by(client_id=credentials['client_id']).one() +@bp.route('/oauth2/keys') +def keys(): + return jsonify({ + 'keys': [key.public_key_jwks_dict for key in OAuth2Key.query.filter_by(active=True)], + }), 200, {'Cache-Control': ['max-age=86400, public, must-revalidate, no-transform=true']} + +def oauth2_redirect(**extra_args): + urlparts = urllib.parse.urlparse(request.oauth2_redirect_uri) + args = urllib.parse.parse_qs(urlparts.query) + if 'state' in request.args: + args['state'] = request.args['state'] + for key, value in extra_args.items(): + if value is not None: + args[key] = [value] + return redirect(urlparts._replace(query=urllib.parse.urlencode(args, doseq=True)).geturl()) + +class OAuth2Error(Exception): + ERROR: str + + def __init__(self, error_description=None): + self.error_description = error_description + + @property + def params(self): + res = {'error': self.ERROR} + if self.error_description: + res['error_description'] = self.error_description + return res + +# RFC 6749: OAuth 2.0 +class InvalidRequestError(OAuth2Error): + ERROR = 'invalid_request' + +class UnsupportedResponseTypeError(OAuth2Error): + ERROR = 'unsupported_response_type' + +class InvalidScopeError(OAuth2Error): + ERROR = 'invalid_scope' + +class InvalidClientError(OAuth2Error): + ERROR = 'invalid_client' + +class UnsupportedGrantTypeError(OAuth2Error): + ERROR = 'unsupported_grant_type' + +class InvalidGrantError(OAuth2Error): + ERROR = 'invalid_grant' + +class AccessDeniedError(OAuth2Error): + ERROR = 'access_denied' + + def __init__(self, flash_message=None, **kwargs): + self.flash_message = flash_message + super().__init__(**kwargs) + +# RFC 6750: OAuth 2.0 Bearer Token Usage +class InvalidTokenError(OAuth2Error): + ERROR = 'invalid_token' + +# OpenID Connect Core 1.0 +class LoginRequiredError(OAuth2Error): + ERROR = 'login_required' + + def __init__(self, response=None, flash_message=None, **kwargs): + self.response = response + self.flash_message = flash_message + super().__init__(**kwargs) + +class RequestNotSupportedError(OAuth2Error): + ERROR = 'request_not_supported' + +class RequestURINotSupportedError(OAuth2Error): + ERROR = 'request_uri_not_supported' + +def authorize_validate_request(): + request.oauth2_redirect_uri = None + for param in request.args: + if len(request.args.getlist(param)) > 1: + raise InvalidRequestError(error_description=f'Duplicate parameter {param}') + + if 'client_id' not in request.args: + raise InvalidRequestError(error_description='Required parameter client_id missing') + client_id = request.args['client_id'] + client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none() + if not client: + raise InvalidRequestError(error_description=f'Unknown client {client_id}') + + redirect_uri = request.args.get('redirect_uri') + if redirect_uri and redirect_uri not in client.redirect_uris: + raise InvalidRequestError(error_description='Invalid redirect_uri') + request.oauth2_redirect_uri = redirect_uri or client.default_redirect_uri + if not request.oauth2_redirect_uri: + raise InvalidRequestError(error_description='Parameter redirect_uri required') + + if 'response_type' not in request.args: + raise InvalidRequestError(error_description='Required parameter response_type missing') + response_type = request.args['response_type'] + if response_type != 'code': + raise UnsupportedResponseTypeError(error_description='Unsupported response type') + + scopes = {scope for scope in request.args.get('scope', '').split(' ') if scope} or {'profile'} + if scopes == {'profile'}: + pass # valid plain OAuth2 scopes + elif 'openid' in scopes: + # OIDC core spec: "Scope values used that are not understood by an implementation SHOULD be ignored." + # Since we don't support some of the optional scope values defined by the + # spec (phone, address, offline_access), it's probably best to ignore all + # unknown scopes. + pass # valid OIDC scopes + else: + raise InvalidScopeError(error_description='Unknown scope') + + return OAuth2Grant( + client=client, + # redirect_uri is None if not present in request! This affects token request validation. + redirect_uri=redirect_uri, + scopes=scopes, + ) + +def authorize_validate_request_oidc(grant): + nonce = request.args.get('nonce') + claims = json.loads(request.args['claims']) if 'claims' in request.args else None - if request.user: - credentials['user'] = request.user - elif 'devicelogin_started' in session: + if 'request' in request.args: + raise RequestNotSupportedError() + if 'request_uri' in request.args: + raise RequestURINotSupportedError() + + prompt_values = {value for value in request.args.get('prompt', '').split(' ') if value} + if 'none' in prompt_values and prompt_values != {'none'}: + raise InvalidRequestError(error_description='Invalid usage of none prompt parameter value') + + sub_value = None + if claims and claims.get('id_token', {}).get('sub', {}).get('value') is not None: + sub_value = claims['id_token']['sub']['value'] + if 'id_token_hint' in request.args: + try: + id_token = OAuth2Key.decode_jwt( + request.args['id_token_hint'], + issuer=get_issuer(), + options={'verify_exp': False, 'verify_aud': False} + ) + except (jwt.exceptions.InvalidTokenError, jwt.exceptions.InvalidKeyError) as err: + raise InvalidRequestError(error_description='Invalid id_token_hint value') from err + if sub_value is not None and id_token['sub'] != sub_value: + raise InvalidRequestError(error_description='Ambiguous sub values in claims and id_token_hint') + sub_value = id_token['sub'] + + # We "MUST only send a positive response if the End-User identified by that + # sub value has an active session with the Authorization Server or has been + # Authenticated as a result of the request". However, we currently cannot + # display the login page if there is already a valid session. So we can only + # support sub_value in combination with prompt=none for now. + if sub_value is not None and 'none' not in prompt_values: + raise InvalidRequestError(error_description='id_token_hint or sub claim value not supported without prompt=none') + + grant.nonce = nonce + grant.claims = claims + return grant, sub_value, prompt_values + +def authorize_user(client): + if request.session: + return request.session + + if 'devicelogin_started' in session: del session['devicelogin_started'] host_delay = host_ratelimit.get_delay() if host_delay: - flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay))) - return redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) + raise LoginRequiredError( + flash_message=_( + 'We received too many requests from your ip address/network! Please wait at least %(delay)s.', + delay=format_delay(host_delay) + ), + response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) + ) host_ratelimit.log() initiation = OAuth2DeviceLoginInitiation(client=client) db.session.add(initiation) try: db.session.commit() - except IntegrityError: - flash(_('Device login is currently not available. Try again later!')) - return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True)) + except IntegrityError as err: + raise LoginRequiredError( + flash_message=_('Device login is currently not available. Try again later!'), + response=redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True)) + ) from err session['devicelogin_id'] = initiation.id session['devicelogin_secret'] = initiation.secret - return redirect(url_for('session.devicelogin', ref=request.full_path)) - elif 'devicelogin_id' in session and 'devicelogin_secret' in session and 'devicelogin_confirmation' in session: - initiation = OAuth2DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret'], - client=client).one_or_none() + raise LoginRequiredError(response=redirect(url_for('session.devicelogin', ref=request.full_path))) + if 'devicelogin_id' in session and 'devicelogin_secret' in session and 'devicelogin_confirmation' in session: + initiation = OAuth2DeviceLoginInitiation.query.filter_by( + id=session['devicelogin_id'], + secret=session['devicelogin_secret'], + client=client + ).one_or_none() confirmation = DeviceLoginConfirmation.query.get(session['devicelogin_confirmation']) del session['devicelogin_id'] del session['devicelogin_secret'] del session['devicelogin_confirmation'] - if not initiation or initiation.expired or not confirmation or confirmation.user.is_deactivated: - flash(_('Device login failed')) - return redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) - credentials['user'] = confirmation.user + if not initiation or initiation.expired or not confirmation or confirmation.session.user.is_deactivated: + raise LoginRequiredError( + flash_message=_('Device login failed'), + response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) + ) db.session.delete(initiation) db.session.commit() + return confirmation.session + + raise LoginRequiredError( + flash_message=_('You need to login to access this service'), + response=redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) + ) + +@bp.route('/oauth2/authorize') +def authorize(): + is_oidc = 'openid' in request.args.get('scope', '').split(' ') + + try: + grant = authorize_validate_request() + sub_value, prompt_values = None, [] + if is_oidc: + grant, sub_value, prompt_values = authorize_validate_request_oidc(grant) + except OAuth2Error as err: + # Correct OAuth2/OIDC error handling would be to redirect back to the + # client with an error paramter, unless client_id or redirect_uri is + # invalid. However, uffd never did that before adding OIDC support and + # many applications fail to correctly handle this case. As a compromise + # we report errors correctly in OIDC mode and don't in plain OAuth2 mode. + if is_oidc and request.oauth2_redirect_uri: + return oauth2_redirect(**err.params) + return render_template('oauth2/error.html', **err.params), 400 + + try: + _session = authorize_user(grant.client) + if sub_value is not None and str(_session.user.unix_uid) != sub_value: + # We only reach this point in OIDC requests with prompt=none, see + # authorize_validate_request_oidc. So this LoginRequiredError is + # always returned as a redirect back to the client. + raise LoginRequiredError() + if not grant.client.access_allowed(_session.user): + raise AccessDeniedError(flash_message=_( + "You don't have the permission to access the service <b>%(service_name)s</b>.", + service_name=grant.client.service.name + )) + grant.session = _session + except LoginRequiredError as err: + # We abuse LoginRequiredError to signal a redirect to the login page + if is_oidc and 'none' in prompt_values: + err.error_description = 'Login required but prompt value set to none' + return oauth2_redirect(**err.params) + if err.flash_message: + flash(err.flash_message) + return err.response + except AccessDeniedError as err: + if is_oidc and request.oauth2_redirect_uri: + return oauth2_redirect(**err.params) + abort(403, description=err.flash_message) + + db.session.add(grant) + db.session.commit() + return oauth2_redirect(code=grant.code) + +def token_authenticate_client(): + for param in ('client_id', 'client_secret'): + if len(request.form.getlist(param)) > 1: + raise InvalidRequestError(error_description=f'Duplicate parameter {param}') + if request.authorization: + client_id = urllib.parse.unquote(request.authorization.username) + client_secret = urllib.parse.unquote(request.authorization.password) + if request.form.get('client_id', client_id) != client_id: + raise InvalidRequestError(error_description='Ambiguous parameter client_id') + if 'client_secret' in request.form: + raise InvalidRequestError(error_description='Ambiguous parameter client_secret') + elif 'client_id' in request.form and 'client_secret' in request.form: + client_id = request.form['client_id'] + client_secret = request.form['client_secret'] else: - flash(_('You need to login to access this service')) - return redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) - - # Here we would normally ask the user, if he wants to give the requesting - # service access to his data. Since we only have trusted services (the - # clients defined in the server config), we don't ask for consent. - if not client.access_allowed(credentials['user']): - abort(403, description=_("You don't have the permission to access the service <b>%(service_name)s</b>.", service_name=client.service.name)) - session['oauth2-clients'] = session.get('oauth2-clients', []) - if client.client_id not in session['oauth2-clients']: - session['oauth2-clients'].append(client.client_id) - - headers, body, status = server.create_authorization_response(request.url, request.method, request.form, request.headers, scopes, credentials) - return body or '', status, headers - -@bp.route('/token', methods=['GET', 'POST']) + raise InvalidClientError() + + client = OAuth2Client.query.filter_by(client_id=client_id).one_or_none() + if client is None or not client.client_secret.verify(client_secret): + raise InvalidClientError() + if client.client_secret.needs_rehash: + client.client_secret = client_secret + db.session.commit() + return client + +def token_validate_request(client): + for param in ('grant_type', 'code', 'redirect_uri'): + if len(request.form.getlist(param)) > 1: + raise InvalidRequestError(error_description=f'Duplicate parameter {param}') + if 'grant_type' not in request.form: + raise InvalidRequestError(error_description='Parameter grant_type missing') + grant_type = request.form['grant_type'] + if grant_type != 'authorization_code': + raise UnsupportedGrantTypeError() + if 'code' not in request.form: + raise InvalidRequestError(error_description='Parameter code missing') + code = request.form['code'] + + grant = OAuth2Grant.get_by_authorization_code(code) + if not grant or grant.client != client: + raise InvalidGrantError() + if grant.redirect_uri and grant.redirect_uri != request.form.get('redirect_uri'): + raise InvalidRequestError(error_description='Parameter redirect_uri missing or invalid') + return grant + +@bp.route('/oauth2/token', methods=['POST']) def token(): - headers, body, status = server.create_token_response(request.url, request.method, request.form, - request.headers, {'authorization': request.authorization}) - return body, status, headers - -def oauth_required(*scopes): - def wrapper(func): - @functools.wraps(func) - def decorator(*args, **kwargs): - valid, oauthreq = server.verify_request(request.url, request.method, request.form, request.headers, scopes) - if not valid: - abort(401) - request.oauth = oauthreq - return func(*args, **kwargs) - return decorator - return wrapper - -@bp.route('/userinfo') -@oauth_required('profile') + try: + client = token_authenticate_client() + grant = token_validate_request(client) + except InvalidClientError as err: + return jsonify(err.params), 401, {'WWW-Authenticate': ['Basic realm="oauth2"']} + except OAuth2Error as err: + return jsonify(err.params), 400 + + tok = grant.make_token() + db.session.add(tok) + db.session.delete(grant) + db.session.commit() + + resp = { + 'token_type': 'Bearer', + 'access_token': tok.access_token, + 'expires_in': tok.EXPIRES_IN, + 'scope': ' '.join(tok.scopes), + } + if 'openid' in tok.scopes: + key = OAuth2Key.get_preferred_key() + id_token = render_claims(['openid'], (grant.claims or {}).get('id_token', {}), tok.service_user) + id_token['iss'] = get_issuer() + id_token['aud'] = tok.client.client_id + id_token['iat'] = int(time.time()) + id_token['at_hash'] = key.oidc_hash(tok.access_token.encode('ascii')) + id_token['exp'] = id_token['iat'] + tok.EXPIRES_IN + if grant.nonce: + id_token['nonce'] = grant.nonce + resp['id_token'] = OAuth2Key.get_preferred_key().encode_jwt(id_token) + else: + # We don't support the refresh_token grant type. Due to limitations of + # oauthlib we always returned (disfunctional) refresh tokens in the past. + # We still do that for non-OIDC clients to not change behavour for + # existing clients. + resp['refresh_token'] = tok.refresh_token + + return jsonify(resp), 200, {'Cache-Control': ['no-store']} + +def validate_access_token(): + if len(request.headers.getlist('Authorization')) == 1 and 'access_token' not in request.values: + auth_type, auth_value = (request.headers['Authorization'].split(' ', 1) + [''])[:2] + if auth_type.lower() != 'bearer': + raise InvalidRequestError() + access_token = auth_value + elif len(request.values.getlist('access_token')) == 1 and 'Authorization' not in request.headers: + access_token = request.values['access_token'] + else: + raise InvalidClientError() + tok = OAuth2Token.get_by_access_token(access_token) + if not tok: + raise InvalidTokenError() + return tok + +@bp.route('/oauth2/userinfo', methods=['GET', 'POST']) def userinfo(): - service_user = ServiceUser.query.get((request.oauth.client.service_id, request.oauth.user.id)) - return jsonify( - id=service_user.user.unix_uid, - name=service_user.user.displayname, - nickname=service_user.user.loginname, - email=service_user.email, - groups=[group.name for group in service_user.user.groups] - ) + try: + tok = validate_access_token() + except OAuth2Error as err: + # RFC 6750: + # If the request lacks any authentication information (e.g., the client + # was unaware that authentication is necessary or attempted using an + # unsupported authentication method), the resource server SHOULD NOT + # include an error code or other error information. + header = 'Bearer' + if request.headers.get('Authorization', '').lower().startswith('bearer') or 'access_token' in request.values: + header += f' error="{err.ERROR}"' + return '', 401, {'WWW-Authenticate': [header]} + + service_user = tok.service_user + if 'openid' in tok.scopes: + resp = render_claims(tok.scopes, (tok.claims or {}).get('userinfo', {}), service_user) + else: + resp = { + 'id': service_user.user.unix_uid, + 'name': service_user.user.displayname, + 'nickname': service_user.user.loginname, + 'email': service_user.email, + 'groups': [group.name for group in service_user.user.groups], + } + return jsonify(resp), 200, {'Cache-Control': ['private']} @bp.app_url_defaults def inject_logout_params(endpoint, values): - if endpoint != 'oauth2.logout' or not session.get('oauth2-clients'): + if endpoint != 'oauth2.logout' or not request.session: return - values['client_ids'] = ','.join(session['oauth2-clients']) + client_ids = set(token.client.client_id for token in request.session.oauth2_tokens) + if client_ids: + values['client_ids'] = ','.join(client_ids) -@bp.route('/logout') +@bp.route('/oauth2/logout') def logout(): if not request.values.get('client_ids'): return secure_local_redirect(request.values.get('ref', '/')) diff --git a/uffd/views/selfservice.py b/uffd/views/selfservice.py index bbf062bfaf3bce1529d1433102a6041d573cb6b3..25d0b00ff207724cdf6b287a51c9374c4c7fc903 100644 --- a/uffd/views/selfservice.py +++ b/uffd/views/selfservice.py @@ -1,6 +1,6 @@ import secrets -from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort +from flask import Blueprint, render_template, session, request, url_for, redirect, flash, current_app, abort from flask_babel import gettext as _, lazy_gettext from sqlalchemy.exc import IntegrityError @@ -8,7 +8,12 @@ from uffd.navbar import register_navbar from uffd.csrf import csrf_protect from uffd.sendmail import sendmail from uffd.database import db -from uffd.models import User, UserEmail, PasswordToken, Role, host_ratelimit, Ratelimit, format_delay +from uffd.models import ( + User, UserEmail, PasswordToken, Role, host_ratelimit, Ratelimit, format_delay, + Session, MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod, +) +from uffd.fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import + from .session import login_required bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix='/self/') @@ -198,6 +203,16 @@ def update_email_preferences(): flash(_('E-Mail preferences updated')) return redirect(url_for('selfservice.index')) +@bp.route("/session/<int:session_id>/revoke", methods=(['POST'])) +@csrf_protect(blueprint=bp) +@login_required(selfservice_acl_check) +def revoke_session(session_id): + _session = Session.query.filter_by(id=session_id, user=request.user).first_or_404() + db.session.delete(_session) + db.session.commit() + flash(_('Session revoked')) + return redirect(url_for('selfservice.index')) + @bp.route("/leaverole/<int:roleid>", methods=(['POST'])) @csrf_protect(blueprint=bp) @login_required(selfservice_acl_check) @@ -225,3 +240,119 @@ def send_passwordreset(user, new=False): email = user.recovery_email or user.primary_email if not sendmail(email.address, subject, template, user=user, token=token): flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address)) + +@bp.route('/mfa/', methods=['GET']) +@login_required(selfservice_acl_check) +def setup_mfa(): + return render_template('selfservice/setup_mfa.html') + +@bp.route('/mfa/setup/disable', methods=['GET']) +@login_required(selfservice_acl_check) +def disable_mfa(): + return render_template('selfservice/disable_mfa.html') + +@bp.route('/mfa/setup/disable', methods=['POST']) +@login_required(selfservice_acl_check) +@csrf_protect(blueprint=bp) +def disable_mfa_confirm(): + MFAMethod.query.filter_by(user=request.user).delete() + db.session.commit() + request.user.update_groups() + db.session.commit() + return redirect(url_for('selfservice.setup_mfa')) + +@bp.route('/mfa/setup/recovery', methods=['POST']) +@login_required(selfservice_acl_check) +@csrf_protect(blueprint=bp) +def setup_mfa_recovery(): + for method in RecoveryCodeMethod.query.filter_by(user=request.user).all(): + db.session.delete(method) + methods = [] + for _ in range(10): + method = RecoveryCodeMethod(request.user) + methods.append(method) + db.session.add(method) + db.session.commit() + return render_template('selfservice/setup_mfa_recovery.html', methods=methods) + +@bp.route('/mfa/setup/totp', methods=['GET']) +@login_required(selfservice_acl_check) +def setup_mfa_totp(): + method = TOTPMethod(request.user) + session['mfa_totp_key'] = method.key + return render_template('selfservice/setup_mfa_totp.html', method=method, name=request.values['name']) + +@bp.route('/mfa/setup/totp', methods=['POST']) +@login_required(selfservice_acl_check) +@csrf_protect(blueprint=bp) +def setup_mfa_totp_finish(): + if not RecoveryCodeMethod.query.filter_by(user=request.user).all(): + flash(_('Generate recovery codes first!')) + return redirect(url_for('selfservice.setup_mfa')) + method = TOTPMethod(request.user, name=request.values['name'], key=session.pop('mfa_totp_key')) + if method.verify(request.form['code']): + db.session.add(method) + request.user.update_groups() + db.session.commit() + return redirect(url_for('selfservice.setup_mfa')) + flash(_('Code is invalid')) + return redirect(url_for('selfservice.setup_mfa_totp', name=request.values['name'])) + +@bp.route('/mfa/setup/totp/<int:id>/delete') +@login_required(selfservice_acl_check) +@csrf_protect(blueprint=bp) +def delete_mfa_totp(id): #pylint: disable=redefined-builtin + method = TOTPMethod.query.filter_by(user=request.user, id=id).first_or_404() + db.session.delete(method) + request.user.update_groups() + db.session.commit() + return redirect(url_for('selfservice.setup_mfa')) + +bp.add_app_template_global(WEBAUTHN_SUPPORTED, name='webauthn_supported') + +if WEBAUTHN_SUPPORTED: + @bp.route('/mfa/setup/webauthn/begin', methods=['POST']) + @login_required(selfservice_acl_check) + @csrf_protect(blueprint=bp) + def setup_mfa_webauthn_begin(): + if not RecoveryCodeMethod.query.filter_by(user=request.user).all(): + abort(403) + methods = WebauthnMethod.query.filter_by(user=request.user).all() + creds = [method.cred for method in methods] + server = get_webauthn_server() + registration_data, state = server.register_begin( + { + "id": str(request.user.id).encode(), + "name": request.user.loginname, + "displayName": request.user.displayname, + }, + creds, + user_verification='discouraged', + ) + session["webauthn-state"] = state + return cbor.encode(registration_data) + + @bp.route('/mfa/setup/webauthn/complete', methods=['POST']) + @login_required(selfservice_acl_check) + @csrf_protect(blueprint=bp) + def setup_mfa_webauthn_complete(): + server = get_webauthn_server() + data = cbor.decode(request.get_data()) + client_data = ClientData(data["clientDataJSON"]) + att_obj = AttestationObject(data["attestationObject"]) + auth_data = server.register_complete(session["webauthn-state"], client_data, att_obj) + method = WebauthnMethod(request.user, auth_data.credential_data, name=data['name']) + db.session.add(method) + request.user.update_groups() + db.session.commit() + return cbor.encode({"status": "OK"}) + +@bp.route('/mfa/setup/webauthn/<int:id>/delete') +@login_required(selfservice_acl_check) +@csrf_protect(blueprint=bp) +def delete_mfa_webauthn(id): #pylint: disable=redefined-builtin + method = WebauthnMethod.query.filter_by(user=request.user, id=id).first_or_404() + db.session.delete(method) + request.user.update_groups() + db.session.commit() + return redirect(url_for('selfservice.setup_mfa')) diff --git a/uffd/views/service.py b/uffd/views/service.py index 374180cae6732000954ae61e6c8c1751b4747c07..f08217a3e291d14dfd61431ac4417cfe42b5818f 100644 --- a/uffd/views/service.py +++ b/uffd/views/service.py @@ -28,7 +28,7 @@ def overview_login_maybe_required(func): return decorator def overview_navbar_visible(): - return get_services(request.user) != [] or admin_acl() + return get_services(request.user) or admin_acl() @bp.route('/services/') @register_navbar(lazy_gettext('Services'), icon='sitemap', blueprint=bp, visible=overview_navbar_visible) diff --git a/uffd/views/session.py b/uffd/views/session.py index df3301af7b2982069635f5e3646eb550d44c1c86..719b6b14700c721ab476d4005c8473c63513512e 100644 --- a/uffd/views/session.py +++ b/uffd/views/session.py @@ -8,51 +8,74 @@ from flask_babel import gettext as _ from uffd.database import db from uffd.csrf import csrf_protect from uffd.secure_redirect import secure_local_redirect -from uffd.models import User, DeviceLoginInitiation, DeviceLoginConfirmation, Ratelimit, host_ratelimit, format_delay +from uffd.models import User, DeviceLoginInitiation, DeviceLoginConfirmation, Ratelimit, host_ratelimit, format_delay, Session +from uffd.fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/') login_ratelimit = Ratelimit('login', 1*60, 3) +mfa_ratelimit = Ratelimit('mfa', 1*60, 3) @bp.before_app_request def set_request_user(): request.user = None request.user_pre_mfa = None - if 'user_id' not in session: + request.session = None + request.session_pre_mfa = None + if 'id' not in session: return - if 'logintime' not in session: + if 'secret' not in session: return - if datetime.datetime.utcnow().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']: + _session = Session.query.get(session['id']) + if _session is None or not _session.secret.verify(session['secret']) or _session.expired: return - user = User.query.get(session['user_id']) - if not user or user.is_deactivated or not user.is_in_group(current_app.config['ACL_ACCESS_GROUP']): + if _session.last_used <= datetime.datetime.utcnow() - datetime.timedelta(seconds=60): + _session.last_used = datetime.datetime.utcnow() + _session.ip_address = request.remote_addr + _session.user_agent = request.user_agent.string + db.session.commit() + if _session.user.is_deactivated or not _session.user.is_in_group(current_app.config['ACL_ACCESS_GROUP']): return - request.user_pre_mfa = user - if session.get('user_mfa'): - request.user = user + request.session_pre_mfa = _session + request.user_pre_mfa = _session.user + if _session.mfa_done: + request.session = _session + request.user = _session.user @bp.route("/logout") def logout(): # The oauth2 module takes data from `session` and injects it into the url, # so we need to build the url BEFORE we clear the session! resp = redirect(url_for('oauth2.logout', ref=request.values.get('ref', url_for('.login')))) + if request.session_pre_mfa: + db.session.delete(request.session_pre_mfa) + db.session.commit() session.clear() return resp def set_session(user, skip_mfa=False): session.clear() session.permanent = True - session['user_id'] = user.id - session['logintime'] = datetime.datetime.utcnow().timestamp() - session['_csrf_token'] = secrets.token_hex(128) + secret = secrets.token_hex(128) + _session = Session( + user=user, + secret=secret, + ip_address=request.remote_addr, + user_agent=request.user_agent.string, + ) if skip_mfa: - session['user_mfa'] = True + _session.mfa_done = True + db.session.add(_session) + db.session.commit() + session['id'] = _session.id + session['secret'] = secret + session['_csrf_token'] = secrets.token_hex(128) @bp.route("/login", methods=('GET', 'POST')) def login(): # pylint: disable=too-many-return-statements if request.user_pre_mfa: - return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index')))) + return redirect(url_for('session.mfa_auth', ref=request.values.get('ref', url_for('index')))) if request.method == 'GET': return render_template('session/login.html', ref=request.values.get('ref')) @@ -83,7 +106,7 @@ def login(): flash(_('You do not have access to this service')) return render_template('session/login.html', ref=request.values.get('ref')) set_session(user) - return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index')))) + return redirect(url_for('session.mfa_auth', ref=request.values.get('ref', url_for('index')))) def login_required_pre_mfa(no_redirect=False): def wrapper(func): @@ -106,13 +129,93 @@ def login_required(permission_check=lambda: True): flash(_('You need to login first')) return redirect(url_for('session.login', ref=request.full_path)) if not request.user: - return redirect(url_for('mfa.auth', ref=request.full_path)) + return redirect(url_for('session.mfa_auth', ref=request.full_path)) if not permission_check(): abort(403) return func(*args, **kwargs) return decorator return wrapper +@bp.route('/mfa/auth', methods=['GET']) +@login_required_pre_mfa() +def mfa_auth(): + if not request.user_pre_mfa.mfa_enabled: + request.session_pre_mfa.mfa_done = True + db.session.commit() + set_request_user() + if request.session_pre_mfa.mfa_done: + return secure_local_redirect(request.values.get('ref', url_for('index'))) + return render_template('session/mfa_auth.html', ref=request.values.get('ref')) + +@bp.route('/mfa/auth', methods=['POST']) +@login_required_pre_mfa() +def mfa_auth_finish(): + delay = mfa_ratelimit.get_delay(request.user_pre_mfa.id) + if delay: + flash(_('We received too many invalid attempts! Please wait at least %s.')%format_delay(delay)) + return redirect(url_for('session.mfa_auth', ref=request.values.get('ref'))) + for method in request.user_pre_mfa.mfa_totp_methods: + if method.verify(request.form['code']): + request.session_pre_mfa.mfa_done = True + db.session.commit() + set_request_user() + return secure_local_redirect(request.values.get('ref', url_for('index'))) + for method in request.user_pre_mfa.mfa_recovery_codes: + if method.verify(request.form['code']): + db.session.delete(method) + request.session_pre_mfa.mfa_done = True + db.session.commit() + set_request_user() + if len(request.user_pre_mfa.mfa_recovery_codes) <= 1: + flash(_('You have exhausted your recovery codes. Please generate new ones now!')) + return redirect(url_for('selfservice.setup_mfa')) + if len(request.user_pre_mfa.mfa_recovery_codes) <= 5: + flash(_('You only have a few recovery codes remaining. Make sure to generate new ones before they run out.')) + return redirect(url_for('selfservice.setup_mfa')) + return secure_local_redirect(request.values.get('ref', url_for('index'))) + mfa_ratelimit.log(request.user_pre_mfa.id) + flash(_('Two-factor authentication failed')) + return redirect(url_for('session.mfa_auth', ref=request.values.get('ref'))) + +if WEBAUTHN_SUPPORTED: + @bp.route("/mfa/auth/webauthn/begin", methods=["POST"]) + @login_required_pre_mfa(no_redirect=True) + def mfa_auth_webauthn_begin(): + server = get_webauthn_server() + creds = [method.cred for method in request.user_pre_mfa.mfa_webauthn_methods] + if not creds: + abort(404) + auth_data, state = server.authenticate_begin(creds, user_verification='discouraged') + session["webauthn-state"] = state + return cbor.encode(auth_data) + + @bp.route("/mfa/auth/webauthn/complete", methods=["POST"]) + @login_required_pre_mfa(no_redirect=True) + def mfa_auth_webauthn_complete(): + server = get_webauthn_server() + creds = [method.cred for method in request.user_pre_mfa.mfa_webauthn_methods] + if not creds: + abort(404) + data = cbor.decode(request.get_data()) + credential_id = data["credentialId"] + client_data = ClientData(data["clientDataJSON"]) + auth_data = AuthenticatorData(data["authenticatorData"]) + signature = data["signature"] + # authenticate_complete() (as of python-fido2 v0.5.0, the version in Debian Buster) + # does not check signCount, although the spec recommends it + server.authenticate_complete( + session.pop("webauthn-state"), + creds, + credential_id, + client_data, + auth_data, + signature, + ) + request.session_pre_mfa.mfa_done = True + db.session.commit() + set_request_user() + return cbor.encode({"status": "OK"}) + @bp.route("/login/device/start") def devicelogin_start(): session['devicelogin_started'] = True @@ -158,12 +261,12 @@ def deviceauth(): @login_required() @csrf_protect(blueprint=bp) def deviceauth_submit(): - DeviceLoginConfirmation.query.filter_by(user=request.user).delete() + DeviceLoginConfirmation.query.filter_by(session=request.session).delete() initiation = DeviceLoginInitiation.query.filter_by(code=request.form['initiation-code']).one_or_none() if initiation is None or initiation.expired: flash(_('Invalid initiation code')) return redirect(url_for('session.deviceauth')) - confirmation = DeviceLoginConfirmation(user=request.user, initiation=initiation) + confirmation = DeviceLoginConfirmation(session=request.session, initiation=initiation) db.session.add(confirmation) db.session.commit() return render_template('session/deviceauth.html', initiation=initiation, confirmation=confirmation) @@ -171,6 +274,6 @@ def deviceauth_submit(): @bp.route("/device/finish", methods=['GET', 'POST']) @login_required() def deviceauth_finish(): - DeviceLoginConfirmation.query.filter_by(user=request.user).delete() + DeviceLoginConfirmation.query.filter_by(session=request.session).delete() db.session.commit() return redirect(url_for('index')) diff --git a/uffd/views/user.py b/uffd/views/user.py index 51d4d64e7f734f2e8f0c4acebcbc800dea50c2f4..a16895e7089586ba9fc4d1c253a49a752d9eb2a0 100644 --- a/uffd/views/user.py +++ b/uffd/views/user.py @@ -9,7 +9,7 @@ from uffd.navbar import register_navbar from uffd.csrf import csrf_protect from uffd.remailer import remailer from uffd.database import db -from uffd.models import User, UserEmail, Role +from uffd.models import User, UserEmail, Role, MFAMethod from .selfservice import send_passwordreset from .session import login_required @@ -164,6 +164,25 @@ def activate(id): flash(_('User activated')) return redirect(url_for('user.show', id=user.id)) +@bp.route('/<int:id>/mfa/disable') +@csrf_protect(blueprint=bp) +def disable_mfa(id): + user = User.query.get_or_404(id) + MFAMethod.query.filter_by(user=user).delete() + user.update_groups() + db.session.commit() + flash(_('Two-factor authentication was reset')) + return redirect(url_for('user.show', id=id)) + +@bp.route('/<int:id>/sessions/revoke') +@csrf_protect(blueprint=bp) +def revoke_sessions(id): + user = User.query.get_or_404(id) + user.sessions.clear() + db.session.commit() + flash(_('Sessions revoked')) + return redirect(url_for('user.show', id=user.id)) + @bp.route("/<int:id>/del") @csrf_protect(blueprint=bp) def delete(id):