Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • uffd/uffd
  • rixx/uffd
  • thies/uffd
  • leona/uffd
  • enbewe/uffd
  • strifel/uffd
  • thies/uffd-2
7 results
Show changes
Commits on Source (91)
  • sistason's avatar
  • Julian's avatar
    HTTP Basic auth for API with new API_CLIENTS_2 · 917f9ecd
    Julian authored
    This change is going to be backported to v1.x.x to have a good migration path.
    Bearer auth with API_CLIENTS config key is deprecated and planned to be
    removed in v2.0.0.
    917f9ecd
  • Julian's avatar
    Workaround for linter bug · d22b62ef
    Julian authored
    Pylint non-deterministicly fails to detect that a method is overwritten later
    and complains that the method does not exist. This is pretty annoying and
    remains unfixed in the most recent version.
    d22b62ef
  • Julian's avatar
    Replace CONFIG_FILENAME with CONFIG_PATH · 73c9b77e
    Julian authored
    CONFIG_FILENAME works relative to the app's instance path. While (strictly
    speaking) CONFIG_FILENAME is named correctly, it is not really obvious that
    it should be set to an instance-relative filename instead of a path. The
    current uwsgi.ini file illustrates this problem.
    
    Not having a way to specify an absolute config file path is a problem for
    the Debian package: The actual config file /etc/uffd/uffd.cfg must be
    symlinked to /usr/share/uffd/instance/config.cfg to be found. Setting
    CONFIG_PATH to "/etc/uffd/uffd.cfg" simplifies this.
    
    Since this change is part of a new major release, we can drop
    CONFIG_FILENAME in favour of CONFIG_PATH.
    73c9b77e
  • Julian's avatar
    Enforce alphabet and length constraints for group names · 49360bcb
    Julian authored
    Closes #127
    49360bcb
  • nd's avatar
    Enable multiple workers in default uwsgi config · d6f63c8d
    nd authored and Julian's avatar Julian committed
    d6f63c8d
  • Julian's avatar
  • Julian's avatar
    Remove support for deprecated invite/selfservice/signup links · dac58839
    Julian authored
    In 32e40c47, c9873e4c, 42338bd0 the old invite, password reset and mail
    verification URL schema was deprecated and replaced with a new schema
    that adds a numeric id to the links. Support for the old id-less URLs
    is now removed.
    dac58839
  • Julian's avatar
    Change default value of ACL_ACCESS_GROUP option · 446f9952
    Julian authored
    Previously ACL_ACCESS_GROUP defaulted to the value of ACL_SELFSERVICE_GROUP,
    now it defaults to "uffd_access". Note that ACL_SELFSERVICE_GROUP has the same
    default value. If you set ACL_SELFSERVICE_GROUP to a different value but not
    ACL_ACCESS_GROUP, you will need to update your config.
    446f9952
  • Julian's avatar
    Remove ENABLE_INVITE/PASSWORDRESET/ROLESELFSERVICE options · e32d037d
    Julian authored
    The options were introduced to cleanly handle LDAP user connections. Since
    LDAP support is now gone and hence user connections are gone too, these
    options are no longer necessary. While the options may be useful in other
    cases, we cannot continuously test them and so we are removing them for now.
    e32d037d
  • Julian's avatar
    Constrain mail receive addresses and fix case-folding in API · 17b99372
    Julian authored
    Previously the getmails API endpoint did not match "receive_address" values
    case-insensitivly like it did pre-v2. To solve this independent of database
    collations, all existing mail receive addresses are converted to lower-case
    and new/changed receive addresses are constraint to ASCII lower-case letters,
    digits and symbols.
    17b99372
  • Julian's avatar
    0043ecc4
  • Julian's avatar
    Unified password hashing for User and Signup · 117e257c
    Julian authored
    Previously User used salted SHA512 with OpenLDAP-style prefix syntax and
    Signup used crypt. Both models had their own hashing and verification
    code. Now both use OpenLDAP-style syntax with support for all traditional
    formats including crypt. Salted SHA512 is used for new User and Signup
    passwords.
    
    Existing Signup objects are migrated to the new format and remain functional.
    User passwords now support gradual migration to another hash algorithm when
    it is changed in the future.
    
    This code is planned to be used for database-stored API and OAuth2 client
    secrets.
    117e257c
  • Julian's avatar
    Argon2 for user password hashing · ac003909
    Julian authored
    Argon2 is a modern password hashing algorithm. It is significantly more secure
    than the previous algorithm (salted SHA512). User logins with Argon2 are
    relativly slow and cause significant spikes in CPU and memory (100MB) usage.
    
    Existing passwords are gradually migrated to Argon2 on login.
    ac003909
  • Julian's avatar
    Minor fix for last migration · 1a8960d4
    Julian authored
    Calling op.get_bind outside a callback broke "flask db history".
    1a8960d4
  • Julian's avatar
    Enable foreign key support for SQLite · e3366e35
    Julian authored
    e3366e35
  • Julian's avatar
    Cleanup CLI command to delete expired objects · e84b5068
    Julian authored
    The command replaces all existing mechanisms for deleting expired objects. It
    should run at least daily. The Debian package includes a corresponding cron
    job.
    
    Ratelimit events now use UTC timestamps instead of localtime. On upgrade all
    past ratelimit events are cleared.
    e84b5068
  • Julian's avatar
    Use numeric id in mail alias routes · 306c8502
    Julian authored
    306c8502
  • Julian's avatar
    Refactoring of OAuth2 models · 8473d4dc
    Julian authored
    8473d4dc
  • Julian's avatar
    Refactor Unix UID/GID generation · 66df931d
    Julian authored
    The generation now happens in a subquery inside the INSERT statement instead
    of separate client-managed query. This should also reduce the risk of race
    conditions.
    
    Service and non-service users may now use the same UID range.
    66df931d
  • Julian's avatar
    Migrate OAuth2 and API clients to database · fa67bde0
    Julian authored
    Also adds a shallow Service model that coexists with the config-defined
    services to group multiple OAuth2 and API clients together.
    
    Clients defined in the config with OAUTH2_CLIENTS and API_CLIENTS_2 are
    imported by the database migrations.
    
    Removes support for complex values for the OAuth2 client group_required option.
    Only simple group names are supported, not (nested) lists of groups previously
    interpreted as AND/OR conjunctions. Also removes support for the login_message
    parameter of OAuth2 clients.
    fa67bde0
  • Julian's avatar
    Fix "Migrate OAuth2 and API clients to database" · a97358ed
    Julian authored
    The migration originally failed to convert the passwords/secrets to the
    format expected by PasswordHash resulting in invalid password hashes. With
    this change, the migration works correctly.
    
    Also fixes minor template bug.
    a97358ed
  • Julian's avatar
    Fix "Migrate OAuth2 and API clients to database" (again) · 6948ddca
    Julian authored
    The original change completely broke single logout support.
    
    The migration now uses the correct hashing algorithm (unsalted SHA512 instead
    of salted SHA512) for OAuth2/API secrets/passwords.
    6948ddca
  • Julian's avatar
    Release preparations · e00ea70d
    Julian authored
    Added guard to first v2 migration in order to prevent accidental upgrades.
    Extended the upgrade instructions and moved them from the README to a
    standalone file.
    e00ea70d
  • Julian's avatar
    Fix OAuth2 logout URI form field · c07204bb
    Julian authored
    c07204bb
  • Julian's avatar
    Fix regression in service overview access behavior · 3880be9a
    Julian authored
    When the service overview was introduced, it was meant to be optional. Thus
    if the SERVICES config option was empty (the default), uffd returned 404.
    
    Commit fa67bde0 (Migrate OAuth2 and API clients to database) introduced the
    regression that accessing the service overview page when no services are
    visible based on the permissions of the current user (or guest if not logged
    in), 404 is returned.
    
    This change fixes the regression and further changes the behavior to improve
    consistency. Since fa67bde0, the page is relevant to admin users regardless of
    the SERVICES config option. Therefore uffd asks for login or reports missing
    permissions in all cases it originally returned 404.
    3880be9a
  • Julian's avatar
    8a6ca93c
  • Julian's avatar
    Support SMTP without authentication · 3678d41d
    Julian authored
    Closes #150
    3678d41d
  • Julian's avatar
    Configurable site title · bc40fea3
    Julian authored
    Closes #152
    
    Co-authored-by: davidc
    bc40fea3
  • davidc's avatar
    fix typo "serveral" · ff7de059
    davidc authored and Julian's avatar Julian committed
    ff7de059
  • Russ Garrett's avatar
    Add the allowed oauth2 scope to the README · 081c7a19
    Russ Garrett authored and Julian's avatar Julian committed
    081c7a19
  • davidc's avatar
    Config option DEFAULT_PAGE_SERVICES to allow 'services' to be the default page · 918a24a2
    davidc authored and Julian's avatar Julian committed
    918a24a2
  • davidc's avatar
    LOGIN_BANNER config to display a banner above the login form · fcb6960e
    davidc authored and Julian's avatar Julian committed
    fcb6960e
  • Julian's avatar
    Fix group/role update command clearing description · 23726002
    Julian authored
    The group and role update subcommands set the description to an empty string
    if the "--description" option was ommitted.
    
    Fixes #156
    23726002
  • sistason's avatar
  • Julian's avatar
    Fix "new invite" form resetting on error · bfd759bd
    Julian authored
    When the "new invite" page was submitted with e.g. an invalid "Valid Until"
    value, uffd displayed an error and reset the whole form. This was confusing
    to users.
    
    Now the form content is preserved on errors. Also the "Valid Until" field now
    has min/max attributes to prevent submitting the form with invalid values.
    
    Fixes #134
    bfd759bd
  • Julian's avatar
    API getusers/getgroups performance optimization · 9c9fa2b3
    Julian authored
    9c9fa2b3
  • Julian's avatar
    Downgrade popper to Bootstrap-compatible version · 34ea8c17
    Julian authored
    34ea8c17
  • Julian's avatar
    Remailer support · 10e37c17
    Julian authored
    With this feature, uffd can be configured to hide mail addresses of users
    from certain services while still allowing the services to send mails to the
    users.
    
    To these services uffd returns special remailer addresses instead of the real
    mail addresses. When a service sends an email to a remailer address the mail
    server queries uffd's API and replaces the remailer address with the real mail
    address in both envelope and headers.
    
    This feature requires additional mail server configuration (Postfix
    canonical_maps) and support in uffd-socketmapd.
    10e37c17
  • sistason's avatar
    b4e04a20
  • Julian's avatar
  • Russ Garrett's avatar
    Use permanent rather than session cookies · 77d2c30c
    Russ Garrett authored and Julian's avatar Julian committed
    77d2c30c
  • davidc's avatar
    0108ed7a
  • sistason's avatar
    1953cc8b
  • Julian's avatar
    Fix CI package tests · 75ef8bd0
    Julian authored
    75ef8bd0
  • Julian's avatar
    Compatability with python-fido2 v1.0.0 · 36862ea3
    Julian authored
    36862ea3
  • Julian's avatar
    Fix migrations for newer Alembic versions · 3f68d5ef
    Julian authored
    3f68d5ef
  • Julian's avatar
    Fix/complete mfa translations · 85157594
    Julian authored
    85157594
  • Julian's avatar
    Restructure source tree · ac731bf4
    Julian authored
    Move all models, views, cli commands and templates into corresponding
    top-level folders. Detailed changes:
    
    - uffd/<NAME>/models.py -> uffd/models/<NAME>.py
    - uffd/<NAME>/cli.py -> uffd/commands/<NAME>.py
    - uffd/<NAME>/views.py -> uffd/views/<NAME>.py
    - uffd/<NAME>/templates/* -> uffd/templates/
    - uffd/ratelimit.py -> uffd/models/ratelimit.py (it contains models)
    - gendevcert from uffd/__init__.py -> uffd/commands/gendevcert.py
    - profile from uffd/__init__.py -> uffd/commands/profile.py
    - cleanup from uffd/tasks.py -> uffd/commands/cleanup.py
    - roles-update-all from uffd/role/views.py -> uffd/commands/...
    - Views from uffd/__init__.py -> uffd/views/__init__.py
    - All models can/should be imported from uffd.models
    - flask shell auto-imports all models instead of only a few
    
    The old structure was meant to keep the code modular and related
    code/resources close to each other. However, the modules turned out to
    be heavily interdependent and not very modular. Also importing was fragile
    due to ordering issues.
    
    With the new structure the dependency tree is much simpler: Infrastructure
    code (top-level *.py files) has no internal dependencies. Models only
    depend on infrastructure and other models. Views and cli commands depend
    on infrastructure, models and other views/commands.
    
    Going forward there is still some restructuring to do, e.g.:
    
    - Move mfa setup views to selfservice views
    - Move mfa auth views to session views
    - Move utility code from views to infrastructure (e.g. login_required)
    - In most cases views should not need to import from other views
    - Reorganize infrastructure code
    ac731bf4
  • Julian's avatar
    Cleanup CI tests and LDAP remnants · 3f82ec74
    Julian authored
    Unittest jobs now fail if any test fails. Unittests on Bullseye no longer
    fail due to jinja2 import errors. Linter jobs run faster.
    3f82ec74
  • Julian's avatar
    Use UTC internally · ffcec8a4
    Julian authored
    Convert DateTime fields to UTC, use "utcnow" instead of "now" and use
    babel helper/filter when dates/times are displayed or parsed from user
    input.
    
    Uffd continues to use the system's timezone in the user interface by
    default.  However, it is now possible to overwrite this with the
    BABEL_DEFAULT_TIMEZONE config option.
    ffcec8a4
  • Julian's avatar
    Introduce ServiceUser · 6337c591
    Julian authored
    Preperation for future features that require per-service user settings
    or state, e.g. stateful sync or service-specific email settings.
    
    The additional JOIN of ServiceUser degrades getusers API performance
    by 30-50%. For API calls that return many users, this is compensated by
    an otherwise unrelated optimization (selectinload instead of joinedload).
    6337c591
  • nd's avatar
    Add prometheus metric endpoint at /metrics · 76dbf7b0
    nd authored
    Access control is done via normal api credentials.
    See README.md for details.
    Adds an optional dependency on python3-prometheus-client.
    76dbf7b0
  • Julian's avatar
    4bc7ffd0
  • Julian's avatar
    Fix flask shell command for Buster · 192b954b
    Julian authored
    192b954b
  • Julian's avatar
    Per-service email preferences · b391e176
    Julian authored
    Also fixes a minor email-related bug in the admin interface and bad
    texts/translations in the selfservice UI.
    b391e176
  • Julian's avatar
    Remailer address format v2 · 879a04c5
    Julian authored
    Deprecates old case-sensitive format. Some software out there stores email
    addresses converted to lower case, breaking v1 remailer addresses. The new
    format is case-insensitive and generally more robust.
    
    Uffd continues to use and support the v1 format for services setup before
    this change. Support for the old format is planned to be remove in uffd v3.
    It is possbile to gradually migrate services to the new format with a service
    setting in the admin interface.
    
    Also fixes compatability issue with very recent SQLAlchemy versions introduced
    by b391e176 (whens parameter of case function).
    879a04c5
  • Julian's avatar
    Restructure tests · 0bd26ee8
    Julian authored
    Restructure tests into views/models/commands subdirectories to mirror the new
    source tree structure introduced with ac731bf4 (Restructure source tree).
    0bd26ee8
  • Julian's avatar
    Fix CI regression from 0bd26ee8 (Restructure tests) · 8261b723
    Julian authored
    0bd26ee8 added __init__.py files to the tests subdirectory. This had two
    unwanted side-effects:
    
    1. setuptools.find_packages() recognised the tests as a package, so they were
       included in the pip and Debian packages.
    2. The Debian package build process with dh_python automatically runs tests
       with unittest. Unittest's test discovery (in contrast to pytest) only works
       if __init__.py files exist, so this step did not do anything in the past.
       Now, failing tests caused the whole CI pipeline to fail very early without
       the helpful information provided by later stages.
    
    This change disables running any tests during the Debian package build. It also
    explicitly sets the package list to "uffd".
    8261b723
  • Julian's avatar
    Run CI tests and build jobs simultaneously · 17b10ae9
    Julian authored
    17b10ae9
  • Julian's avatar
    Unique email addresses · 620cf9ab
    Julian authored
    Enforces uniqueness of (verified) email addresses across all users. Email
    addresses are compared case-insensitivly and Unicode-normalized. The new
    unique constraints are disabled by default and can be enabled with a CLI
    command. They are planned to become mandatory in uffd v3.
    
    A lot of software does not allow multiple users to share the same email
    address. This change prevents problems with such software.
    
    To enable this feature run the command:
    
      uffd-admin unique-email-addresses enable
    
    The commands reports any issues (e.g. existing duplicate addresses) that
    prevent enabling the feature.
    
    This change also introduces a generic mechanism to store feature flags in the
    database and improves error handling for login name constraint violations.
    620cf9ab
  • Julian's avatar
    New UID/GID allocation approach · 53c06069
    Julian authored
    Previously Unix UIDs/GIDs were allocated by using the highest used ID + 1.
    This caused ID reuse when the newest user/group was deleted. In addition, the
    implementation did not work on MariaDB (at all, it was not possible to create
    users/groups).
    
    The new approach accounts for all IDs ever used regardless of whether or not
    users/groups are deleted. It always allocates the lowest ID in the configured
    range that was never used.
    
    Aside from the different allocation algorithm, this change introduces a
    generic locking mechanism and prerequisites for testing migration scripts.
    53c06069
  • Julian's avatar
    Force charset/collation on MariaDB and enable CI tests · 91ba4a6f
    Julian authored
    Uffd now requires that MariaDB databases have utf8mb4 charset and
    utf8mb4_nopad_bin collation. The collation was chosen for consistency with
    SQLite's BINARY collation.
    91ba4a6f
  • Julian's avatar
    Cleanup CI tests · b5c27f1c
    Julian authored
    Turns check_migrations.py into a normal test case. Speeds up pipeline by
    making html5validator use the artifacts from tests:buster:sqlite instead of
    running the tests on its own.
    b5c27f1c
  • Julian's avatar
    Add per-service setting for testing remailer · 05f68ec8
    Julian authored
    This setting is more flexible than the existing REMAILER_LIMIT_TO_USERS config
    option. The config option is therefore deprecated and will be removed in the
    next major version.
    05f68ec8
  • Julian's avatar
    e083d6e1
  • Julian's avatar
    Add user deactivation · 6b2ee671
    Julian authored
    6b2ee671
  • Julian's avatar
    Optimize migration from 53c06069 (New UID/GID allocation approach) · 9545981a
    Julian authored
    Alembic runs migration scripts on SQLite and MariaDB in auto-commit mode, so
    inserting many rows with individual insert statements is extremely slow.
    9545981a
  • Julian's avatar
    Remove unused json encoder customizations · 1ed39c8e
    Julian authored
    1ed39c8e
  • thies's avatar
    Updated Key & README · c668919f
    thies authored and Julian's avatar Julian committed
    c668919f
  • Julian's avatar
    PEP 440 conformance for development builds · ee8db499
    Julian authored
    Recent setuptools releases refuse to build packages with invalid version
    strings. So instead of using the bare commit hash as the version, we now
    build proper version strings like X.Y.Z.dev-git.COMMIT for CI development
    builds and X.Y.Z for release builds (same as before).
    ee8db499
  • Julian's avatar
    c2b30f17
  • Julian's avatar
    Debian Bookworm support · 0d870ee1
    Julian authored
    - Add CI tests for Bookworm
    - Disable pylint deprecation warnings for crypt
    - Mitigate Flask changes that broke a few tests
    - Set create_constraint=True for Booleans/Enums to mitigate SQLAlchemy changes
    - Mitigate new Alembic CHECK constraint behaviour in batch mode
    0d870ee1
  • Julian's avatar
    Use Debian Bookworm for CI builds · 409d7e66
    Julian authored
    - Fix apt package build on Bookworm
    - Adapt babel.cfg to jinja 3.x.x and break compatability with older versions
    409d7e66
  • Julian's avatar
    Prevent TOTP code reuse · 7a94d7de
    Julian authored
    Time-based one-time password (TOTP) codes are only valid for a short period
    of time. In addition they are meant to be single-use to make them more
    resistant against phishing and eavesdropping (e.g. keyloggers). Prior to this
    change uffd did not keep track of used codes and thus did not prevent code
    reuse.
    7a94d7de
  • Julian's avatar
    Fix OAuth2 authorization code invalidation · 4457282d
    Julian authored
    9bfd6f81 changed the format of authorization codes, but did not adapt the
    invalidation code accordingly. Because of this, authorization codes were
    not invalidated and could have been used multiple times to request access
    tokens until expiring.
    4457282d
  • byteplow's avatar
    Dark mode · 16f5ae99
    byteplow authored and Julian's avatar Julian committed
    
    Automatically enabled based on OS/browser settings (prefers-color-scheme
    CSS media query)
    
    Co-authored-by: default avatarJulian Rother <julian@cccv.de>
    16f5ae99
  • Julian's avatar
    Fix SECRET_KEY auto-generation in debug mode · a662ceb2
    Julian authored
    Compatibility fix for Flask v2 (Debian Bookworm) and newer
    a662ceb2
  • Julian's avatar
    Fix ORM relationship conflict warnings · 4736d5a3
    Julian authored
    SQLAlchemy v1.4 (Debian Bookworm) annoyingly warns about overlapping
    user/mfa_method relationships.
    
    Fixes #146
    4736d5a3
  • Julian's avatar
    Fix autocomplete behaviour in Firefox · ccc90a8f
    Julian authored
    Firefox autofills all type="password" inputs with passwords from its built-in
    password store. This breaks usability of admin pages.
    
    This change fixes that by adding autocomplete="new-password" to these inputs.
    It also adds appropriate autocomplete attributes to other forms/inputs to
    improve autocomplete behaviour across browsers:
    
    - autocomplete="off" on all non-login/signup/selfservice forms
    - autocomplete="new-password" or autocomplete="current-password" on all
      type="password" inputs to workaround Firefox's misdetection
    - autocomplete="username"/"email"/"nickname" on login/signup/selfservice inputs
      wherever appropriate
    - Avoid type="password" where possible (e.g. on readonly fields)
    ccc90a8f
  • Julian's avatar
    Fix ORM cartesian product warnings · 94ba8b9c
    Julian authored
    SQLAlchemy v1.4 (Debian Bookworm) annoyingly warns about select statements
    that result in a cartesion product of multiple tables. We actually want
    cartesion products in all affected cases, so we change "SELECT FROM a,b" to
    the equivalent "SELECT FROM a JOIN b ON TRUE".
    
    See https://docs.sqlalchemy.org/en/14/changelog/migration_14.html
    94ba8b9c
  • Julian's avatar
    OpenID Connect Core 1.0 and Discovery 1.0 support · edd4f4ca
    Julian authored
    Limited to OpenID provider conformance profiles "Basic" and "Config":
    
    - Support for features mandatory to implement for all OpenID Providers,
      not the feature set for Dynamic OpenID Providers
    - Only Authorization Code Flow, no support for Implicit/Hybrid Flow
    - Only code response type, no support for token/id_token
    - Server metadata is served at /.well-known/openid-configuration
    
    Additional/optional features:
    
    - Support for "claims" parameter
    - Support for standard scopes "profile" and "email"
    - Support for non-standard scope/claim "groups" (in violation of RFC 9068)
    
    Compatability with existing (working) uffd client setups: Authorization
    requests without the "openid" scope behave the same as before  Prior to this
    change authorization requests with the "openid" scope were rejected by uffd.
    
    This change adds direct dependencies to pyjwt and cryptography. Prior to this
    change both were already transitive dependencies of oauthlib.
    edd4f4ca
  • Julian's avatar
    Revokable server-side sessions · bbd251f7
    Julian authored
    bbd251f7
  • Julian's avatar
    636169e5
  • Julian's avatar
    Fix 2FA selfservice permission checks · 11502833
    Julian authored
    Users with ACL_ACCESS_GROUP but without ACL_SELFSERVICE_GROUP were able to
    access the 2FA setup pages. Like all selfservice pages, these pages should
    only have been accessible to users with ACL_SELFSERVICE_GROUP.
    11502833
  • Julian's avatar
    fefac582
  • Julian's avatar
    Bind device login state to sessions instead of users · 08926d1f
    Julian authored
    Prerequisite for doing the same to OAuth2 state. This is required for
    implementing missing OIDC features later.
    08926d1f
  • Julian's avatar
    Bind OAuth2 state to sessions instead of users · 89f1ecdd
    Julian authored
    Prerequisite for implementing missing OIDC features.
    89f1ecdd
  • eNBeWe's avatar
    Fix OIDC token endpoint crash on Debian Buster/Bullseye · 23b7736a
    eNBeWe authored and Julian's avatar Julian committed
    
    The return type of jwt.encode() changed from bytes in v1.x (Buster/Bullseye)
    to str in v2.x (Bookworm). This let json.dumps crash on Buster und Bullseye
    with "TypeError: Object of type bytes is not JSON serializable".
    
    Flask v1.x (Buster/Bullseye) automatically uses simplejson.dumps instead of
    json.dumps if it is installed. simplejson.dumps auto-converts bytes to str per
    default. simplejson also happend to be installed in our CI images. This
    prevented the bug from surfacing in CI tests. We removed simplejson from our
    CI images in an external change.
    
    Co-authored-by: default avatarJulian Rother <julian@cccv.de>
    23b7736a
  • Julian's avatar
    Fix spinner style in dark mode · c0dfb38a
    Julian authored
    c0dfb38a
  • Julian's avatar
    Unified password hashing for recovery codes · 98fe5690
    Julian authored
    Closes #163
    98fe5690
Showing with 855 additions and 206 deletions
image: registry.git.cccv.de/uffd/docker-images/buster image: registry.git.cccv.de/uffd/docker-images/bookworm
variables: variables:
DEBIAN_FRONTEND: noninteractive DEBIAN_FRONTEND: noninteractive
GIT_SUBMODULE_STRATEGY: normal GIT_SUBMODULE_STRATEGY: normal
PYTHONPATH: deps/ldapalchemy
APT_API_URL: https://packages.cccv.de APT_API_URL: https://packages.cccv.de
APT_REPO: uffd APT_REPO: uffd
PYLINT_PIN: pylint~=2.10.0 PYLINT_PIN: pylint~=2.16.2
before_script: before_script:
- python3 -V - python3 -V
...@@ -14,7 +13,7 @@ before_script: ...@@ -14,7 +13,7 @@ before_script:
- uname -a - uname -a
- python3 -m pylint --version - python3 -m pylint --version
- python3 -m coverage --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: .build:
stage: build stage: build
...@@ -22,7 +21,7 @@ before_script: ...@@ -22,7 +21,7 @@ before_script:
build:pip: build:pip:
extends: .build extends: .build
script: script:
- PACKAGE_VERSION="${UFFD_PACKAGE_VERSION}" python3 -m build - python3 -m build
artifacts: artifacts:
paths: paths:
- dist/* - dist/*
...@@ -32,6 +31,7 @@ build:apt: ...@@ -32,6 +31,7 @@ build:apt:
script: script:
- ./debian/create_changelog.py uffd > debian/changelog - ./debian/create_changelog.py uffd > debian/changelog
- export PYBUILD_INSTALL_ARGS="--install-lib=/usr/share/uffd/ --install-scripts=/usr/share/uffd/" - export PYBUILD_INSTALL_ARGS="--install-lib=/usr/share/uffd/ --install-scripts=/usr/share/uffd/"
- export DEB_BUILD_OPTIONS=nocheck
- dpkg-buildpackage -us -uc - dpkg-buildpackage -us -uc
- mv ../*.deb ./ - mv ../*.deb ./
- dpkg-deb -I *.deb - dpkg-deb -I *.deb
...@@ -42,29 +42,18 @@ build:apt: ...@@ -42,29 +42,18 @@ build:apt:
db_migrations_updated: db_migrations_updated:
stage: test stage: test
needs: []
script: script:
- FLASK_APP=uffd FLASK_ENV=testing flask db upgrade - FLASK_APP=uffd FLASK_ENV=testing flask db upgrade
- FLASK_APP=uffd FLASK_ENV=testing flask db migrate 2>&1 | grep -q 'No changes in schema detected' - FLASK_APP=uffd FLASK_ENV=testing flask db migrate 2>&1 | grep -q 'No changes in schema detected'
test_db_migrations:sqlite:
stage: test
script:
- python3 check_migrations.py sqlite
test_db_migrations:mysql:
stage: test
script:
- service mysql start
- python3 check_migrations.py mysql
linter:buster: linter:buster:
image: registry.git.cccv.de/uffd/docker-images/buster image: registry.git.cccv.de/uffd/docker-images/buster
stage: test stage: test
needs: []
script: script:
- pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages! - pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages!
- python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter uffd > codeclimate.json - python3 -m pylint --output-format=pylint_gitlab.GitlabCodeClimateReporter:codeclimate.json,pylint_gitlab.GitlabPagesHtmlReporter:pylint.html,colorized uffd
- python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter uffd > pylint.html
- python3 -m pylint --rcfile .pylintrc --output-format=text uffd
artifacts: artifacts:
when: always when: always
paths: paths:
...@@ -75,11 +64,24 @@ linter:buster: ...@@ -75,11 +64,24 @@ linter:buster:
linter:bullseye: linter:bullseye:
image: registry.git.cccv.de/uffd/docker-images/bullseye image: registry.git.cccv.de/uffd/docker-images/bullseye
stage: test 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
linter:bookworm:
image: registry.git.cccv.de/uffd/docker-images/bookworm
stage: test
needs: []
script: script:
- pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages! - pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages!
- python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter uffd > codeclimate.json - python3 -m pylint --output-format=pylint_gitlab.GitlabCodeClimateReporter:codeclimate.json,pylint_gitlab.GitlabPagesHtmlReporter:pylint.html,colorized uffd
- python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter uffd > pylint.html
- python3 -m pylint --rcfile .pylintrc --output-format=text uffd
artifacts: artifacts:
when: always when: always
paths: paths:
...@@ -87,54 +89,97 @@ linter:bullseye: ...@@ -87,54 +89,97 @@ linter:bullseye:
reports: reports:
codequality: codeclimate.json codequality: codeclimate.json
unittests:buster: tests:buster:sqlite:
image: registry.git.cccv.de/uffd/docker-images/buster image: registry.git.cccv.de/uffd/docker-images/buster
stage: test stage: test
needs: []
script: script:
- service slapd start - python3 -m pytest --junitxml=report.xml
- UNITTEST_OPENLDAP=1 python3-coverage run --include 'uffd/*.py' -m pytest --junitxml=report.xml || true artifacts:
when: always
reports:
junit: report.xml
tests:buster:mysql:
image: registry.git.cccv.de/uffd/docker-images/buster
stage: test
needs: []
script:
- service mysql start
- TEST_WITH_MYSQL=1 python3 -m pytest --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xml
tests:bullseye:sqlite:
image: registry.git.cccv.de/uffd/docker-images/bullseye
stage: test
needs: []
script:
- python3 -m pytest --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xml
tests:bullseye:mysql:
image: registry.git.cccv.de/uffd/docker-images/bullseye
stage: test
needs: []
script:
- service mariadb start
- TEST_WITH_MYSQL=1 python3 -m pytest --junitxml=report.xml
artifacts:
when: always
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 report -m
- python3-coverage html - python3-coverage html
- python3-coverage xml - python3-coverage xml
- test ! -e failed
artifacts: artifacts:
when: always when: always
paths: paths:
- htmlcov/index.html - htmlcov/index.html
- htmlcov - htmlcov
- pages
expose_as: 'Coverage Report' expose_as: 'Coverage Report'
reports: reports:
cobertura: coverage.xml coverage_report:
coverage_format: cobertura
path: coverage.xml
junit: report.xml junit: report.xml
coverage: '/^TOTAL.*\s+(\d+\%)$/' coverage: '/^TOTAL.*\s+(\d+\%)$/'
unittests:bullseye: tests:bookworm:mysql:
image: registry.git.cccv.de/uffd/docker-images/bullseye image: registry.git.cccv.de/uffd/docker-images/bookworm
stage: test stage: test
needs: []
script: script:
- service slapd start - service mariadb start
- UNITTEST_OPENLDAP=1 python3-coverage run --include 'uffd/*.py' -m pytest --junitxml=report.xml || true - TEST_WITH_MYSQL=1 python3 -m pytest --junitxml=report.xml
#- python3-coverage report -m
- python3-coverage html
#- python3-coverage xml
artifacts: artifacts:
when: always when: always
paths:
- htmlcov/index.html
- htmlcov
expose_as: 'Coverage Report'
reports: reports:
#cobertura: coverage.xml
junit: report.xml junit: report.xml
#coverage: '/^TOTAL.*\s+(\d+\%)$/'
html5validator: html5validator:
stage: test stage: test
needs:
- job: tests:bookworm:sqlite
script: script:
- rm -rf pages
- mkdir -p pages
- cp -r uffd/static pages/static
- DUMP_PAGES=pages python3 -m unittest discover tests
- sed -i -e 's/href="\/static\//href=".\/static\//g' -e 's/src="\/static\//src=".\/static\//g' pages/*.html
- html5validator --root pages 2>&1 | tee html5validator.log - html5validator --root pages 2>&1 | tee html5validator.log
artifacts: artifacts:
when: on_failure when: on_failure
...@@ -144,10 +189,10 @@ html5validator: ...@@ -144,10 +189,10 @@ html5validator:
.trans: .trans:
stage: test stage: test
needs: []
script: script:
- ./update_translations.sh $TRANSLATION_LANGUAGE - ./update_translations.sh $TRANSLATION_LANGUAGE
coverage: '/^TOTAL.*\s+(\d+\%)$/' coverage: '/^TOTAL.*\s+(\d+\%)$/'
trans_de: trans_de:
extends: .trans extends: .trans
variables: variables:
...@@ -156,44 +201,74 @@ trans_de: ...@@ -156,44 +201,74 @@ trans_de:
test:package:pip:buster: test:package:pip:buster:
image: registry.git.cccv.de/uffd/docker-images/buster image: registry.git.cccv.de/uffd/docker-images/buster
stage: test stage: test
needs:
- job: build:pip
script: script:
- pip3 install dist/*.tar.gz - pip3 install dist/*.tar.gz
dependencies:
- build:pip
test:package:pip:bullseye: test:package:pip:bullseye:
image: registry.git.cccv.de/uffd/docker-images/bullseye image: registry.git.cccv.de/uffd/docker-images/bullseye
stage: test stage: test
needs:
- job: build:pip
script: script:
- pip3 install dist/*.tar.gz - pip3 install dist/*.tar.gz
dependencies:
- build:pip
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
test:package:apt:buster: test:package:apt:buster:
image: registry.git.cccv.de/uffd/docker-images/buster image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/debian:buster
stage: test stage: test
needs:
- job: build:apt
before_script: []
script: script:
- apt -y install ./*.deb - 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; ) - 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 - 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; ) - service nginx start || ( service nginx status; nginx -t; exit 1; )
- uffd-admin routes - uffd-admin routes
- curl -Lv 127.0.0.1:5000 - curl -Lv 127.0.0.1:5000
dependencies:
- build:apt
test:package:apt:bullseye: test:package:apt:bullseye:
image: registry.git.cccv.de/uffd/docker-images/bullseye image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/debian:bullseye
stage: test stage: test
needs:
- job: build:apt
before_script: []
script: script:
- apt -y install ./*.deb - 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
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; ) - 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 - 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; ) - service nginx start || ( service nginx status; nginx -t; exit 1; )
- uffd-admin routes - uffd-admin routes
- curl -Lv 127.0.0.1:5000 - curl -Lv 127.0.0.1:5000
dependencies:
- build:apt
.publish: .publish:
stage: deploy stage: deploy
...@@ -219,5 +294,6 @@ publish:apt: ...@@ -219,5 +294,6 @@ publish:apt:
- echo Update published repo for all distros - 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/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/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: dependencies:
- build:apt - build:apt
...@@ -11,7 +11,7 @@ ignore=CVS ...@@ -11,7 +11,7 @@ ignore=CVS
# Add files or directories matching the regex patterns to the blacklist. The # Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths. # regex matches against base names, not paths.
ignore-patterns=ldapalchemy ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as # Python code to execute, usually for sys.path manipulation such as
# pygtk.require(). # pygtk.require().
...@@ -68,6 +68,8 @@ disable=missing-module-docstring, ...@@ -68,6 +68,8 @@ disable=missing-module-docstring,
too-many-ancestors, too-many-ancestors,
duplicate-code, duplicate-code,
redefined-builtin, redefined-builtin,
superfluous-parens,
consider-using-f-string, # Temporary
# Enable the message, report, category or checker with the given id(s). You can # 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 # either give multiple identifier separated by comma (,) or put this option
...@@ -386,13 +388,6 @@ max-line-length=160 ...@@ -386,13 +388,6 @@ max-line-length=160
# Maximum number of lines in a module. # Maximum number of lines in a module.
max-module-lines=1000 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 # Allow the body of a class to be on the same line as the declaration if body
# contains single statement. # contains single statement.
single-line-class-stmt=no single-line-class-stmt=no
...@@ -513,5 +508,5 @@ min-public-methods=2 ...@@ -513,5 +508,5 @@ min-public-methods=2
# Exceptions that will emit a warning when being caught. Defaults to # Exceptions that will emit a warning when being caught. Defaults to
# "BaseException, Exception". # "BaseException, Exception".
overgeneral-exceptions=BaseException, overgeneral-exceptions=builtin.BaseException,
Exception builtin.Exception
...@@ -14,11 +14,16 @@ Please note that we refer to Debian packages here and **not** pip packages. ...@@ -14,11 +14,16 @@ Please note that we refer to Debian packages here and **not** pip packages.
- python3-flask-migrate - python3-flask-migrate
- python3-qrcode - python3-qrcode
- python3-fido2 (version 0.5.0 or 0.9.1, optional) - python3-fido2 (version 0.5.0 or 0.9.1, optional)
- python3-oauthlib - python3-prometheus-client (optional, needed for metrics)
- python3-jwt
- python3-cryptography
- python3-flask-babel - python3-flask-babel
- python3-mysqldb or python3-pymysql for MySQL/MariaDB support - 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`. For development, you can also use virtualenv with the supplied `requirements.txt`.
## Development ## Development
...@@ -40,13 +45,12 @@ export FLASK_APP=uffd ...@@ -40,13 +45,12 @@ export FLASK_APP=uffd
flask group create 'uffd_access' --description 'Access to Single-Sign-On and Selfservice' flask group create 'uffd_access' --description 'Access to Single-Sign-On and Selfservice'
flask group create 'uffd_admin' --description 'Admin access to uffd' flask group create 'uffd_admin' --description 'Admin access to uffd'
flask role create 'base' --default --add-group 'uffd_access' flask role create 'base' --default --add-group 'uffd_access'
flask role create 'admin' --default --add-group 'uffd_admin' flask role create 'admin' --add-group 'uffd_admin'
flask user create 'testuser' --password 'userpassword' --mail 'test@example.com' --displayname 'Test User' flask user create 'testuser' --password 'userpassword' --mail 'test@example.com' --displayname 'Test User'
flask user create 'testadmin' --password 'adminpassword' --mail 'admin@example.com' --displayname 'Test Admin' --add-role 'admin' flask user create 'testadmin' --password 'adminpassword' --mail 'admin@example.com' --displayname 'Test Admin' --add-role 'admin'
``` ```
Afterwards you can login as a normal user with "testuser" and "userpassword", or as an admin with "testad Afterwards you can login as a normal user with "testuser" and "userpassword", or as an admin with "testadmin" and "adminpassword".
min" and "adminpassword".
## Deployment ## Deployment
...@@ -55,7 +59,7 @@ The dependencies of the pip package roughly represent the versions shipped by De ...@@ -55,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! 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. 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. 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`: To install uffd on Debian Bullseye, add our package mirror to `/etc/sources.list`:
...@@ -64,33 +68,14 @@ To install uffd on Debian Bullseye, add our package mirror to `/etc/sources.list ...@@ -64,33 +68,14 @@ To install uffd on Debian Bullseye, add our package mirror to `/etc/sources.list
deb https://packages.cccv.de/uffd bullseye main deb https://packages.cccv.de/uffd bullseye main
``` ```
Then download [cccv-archive-key.gpg](cccv-archive-key.gpg) and add it to the trusted repository keys in `/etc/apt/trusted.gpg.d/`. Then download [cccv-archive-key.gpg](https://packages.cccv.de/docs/cccv-archive-key.gpg) and add it to the trusted repository keys in `/etc/apt/trusted.gpg.d/`.
Afterwards run `apt update && apt install uffd` to install the package. Afterwards run `apt update && apt install uffd` to install the package.
The Debian package uses uwsgi to run uffd and ships an `uffd-admin` to execute flask commands in the correct context. The Debian package uses uwsgi to run uffd and ships an `uffd-admin` script to execute flask commands in the correct context.
If you upgrade, make sure to run `flask db upgrade` after every update! The Debian package takes care of this by itself using uwsgi pre start hooks. If you upgrade, make sure to run `flask db upgrade` after every update! The Debian package takes care of this by itself using uwsgi pre start hooks.
For an example uwsgi config, see our [uswgi.ini](uwsgi.ini). You might find our [nginx include file](nginx.include.conf) helpful to setup a web server in front of uwsgi. For an example uwsgi config, see our [uswgi.ini](uwsgi.ini). You might find our [nginx include file](nginx.include.conf) helpful to setup a web server in front of uwsgi.
## Migration from version 1 Uffd supports SQLite and MariaDB. To use MariaDB, create the database with the options `CHARACTER SET utf8mb4 COLLATE utf8mb4_nopad_bin` and make sure to add the `?charset=utf8mb4` parameter to `SQLALCHEMY_DATABASE_URI`.
Prior to version 2 uffd stored users, groups and mail aliases in an LDAP server.
To migrate from version 1 to a later version, make sure to keep the v1 config file as it is with all LDAP settings.
Running the database migrations with `flask db upgrade` automatically imports all users, groups and mail forwardings from LDAP to the database.
Note that all LDAP attributes must be readable, including the password field.
Make sure to have a working backup of the database before running the database upgrade!
Downgrading is not supported.
After running the migrations you can remove all `LDAP_*`-prefixed settings from the config file except the following ones that are renamed:
* `LDAP_USER_GID` -> `USER_GID`
* `LDAP_USER_MIN_UID` -> `USER_MIN_UID`
* `LDAP_USER_MAX_UID` -> `USER_MAX_UID`
* `LDAP_USER_SERVICE_MIN_UID` -> `USER_SERVICE_MIN_UID`
* `LDAP_USER_SERVICE_MAX_UID` -> `USER_SERVICE_MAX_UID`
* `LDAP_GROUP_MIN_GID` -> `GROUP_MIN_GID`
* `LDAP_GROUP_MAX_GID` -> `GROUP_MAX_GID`
Upgrading will not perform any write access to the LDAP server.
## Python Coding Style Conventions ## Python Coding Style Conventions
...@@ -102,7 +87,7 @@ We ship a [pylint](https://pylint.org/) config to verify changes with. ...@@ -102,7 +87,7 @@ We ship a [pylint](https://pylint.org/) config to verify changes with.
Uffd reads its default config from `uffd/default_config.cfg`. Uffd reads its default config from `uffd/default_config.cfg`.
You can overwrite config variables by creating a config file in the `instance` folder. You can overwrite config variables by creating a config file in the `instance` folder.
The file must be named `config.cfg` (Python syntax), `config.json` or `config.yml`/`config.yaml`. The file must be named `config.cfg` (Python syntax), `config.json` or `config.yml`/`config.yaml`.
You can also set a custom file name with the environment variable `CONFIG_FILENAME`. You can also set a custom file path with the environment variable `CONFIG_PATH`.
## OAuth2 Single-Sign-On Provider ## OAuth2 Single-Sign-On Provider
...@@ -114,7 +99,9 @@ The services need to be setup to use the following URLs with the Authorization C ...@@ -114,7 +99,9 @@ The services need to be setup to use the following URLs with the Authorization C
* `/oauth2/token`: token request endpoint * `/oauth2/token`: token request endpoint
* `/oauth2/userinfo`: endpoint that provides information about the current user * `/oauth2/userinfo`: endpoint that provides information about the current user
The userinfo endpoint returns json data with the following structure: 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:
``` ```
{ {
...@@ -131,6 +118,66 @@ The userinfo endpoint returns json data with the following structure: ...@@ -131,6 +118,66 @@ The userinfo endpoint returns json data with the following structure:
`id` is the numeric (Unix) user id, `name` the display name and `nickname` the loginname of the user. `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.
Metrics can be accessed via `/metrics` and `/api/v1/metrics_prometheus`.
Those endpoints are protected via api credentials. Add prometheus in the uffd UI as a service and create an
api client with the `metrics` permission. Then you can access the metrics like that:
```
$ curl localhost:5000/api/v1/metrics_prometheus --user api-user:api-password
# HELP python_info Python platform information
# TYPE python_info gauge
python_info{implementation="CPython",major="3",minor="9",patchlevel="2",version="3.9.2"} 1.0
# HELP uffd_version_info Various version infos
# TYPE uffd_version_info gauge
uffd_version_info{version="local"} 1.0
[..]
```
## Translation ## Translation
The web frontend is initially written in English and translated in the following Languages: The web frontend is initially written in English and translated in the following Languages:
......
# Upgrading from v1 to v2
Prior to v2 uffd stored users, groups and mail aliases on an LDAP server.
OAuth2 and API client credentials were defined in the config. Starting with
v2 uffd stores all of this in its database and no longer supports LDAP.
A number of other features and configurations are no longer supported. See the
changelog for details on changed and removed features.
## Preparations
Create a backup of the database before attempting an upgrade. The database
migration scripts are quite complex and somewhat fragile. If anything fails,
you will end up with a broken database that is difficult or impossible to
recover from. Furthermore downgrading from v2 to v1 is not supported.
Make sure no service (besides uffd) directly accesses your LDAP server.
Migrate any remaining services to [uffd-ldapd][] or other solutions that
solely rely on uffds API and OAuth2 endpoints. Uffd will cease to update
any data stored in the LDAP directory.
Migrate all API clients defined with the `API_CLIENTS` config option to the
`API_CLIENTS_2` option. This includes changing authentication from a
token-based mechanism to HTTP Basic Authentication and consequently replacing
affected credentials.
The imported OAuth2 and API clients are grouped by service objects. These
service objects will be auto-created for each client with unique names derived
from the `client_id` parameter. Add the `service_name` parameter to clients to
set a custom name. This name is visible to users in place of the OAuth2
`client_id`. Use the same `service_name` for multiple clients to group them
together. This is recommended for OAuth2 and API credentials used by the same
services, as future features like service-specific email addresses will be
added as service-level options. The OAuth2 client parameter `required_group` is
imported as a service-level option. Make sure that grouped OAuth2 clients have
the same `required_group` value, otherwise nobody will be able to access the
service. Note that values other than a single group name are not supported.
Adjust the ACLs of your LDAP server so uffd can read the `userPassword`
attribute of user objects. Note that uffd will not perform any writes to the
LDAP server during or after the upgrade.
If you use user bind (config option `LDAP_SERVICE_USER_BIND`), i.e. if you
have uffd authenticate with the LDAP server using the credentials of the
currently logged in user, you will have to replace this configuration and
grant uffd full read access to all user, group and mail alias data with
config-defined credentials.
Install the new dependency `python3-argon2`. (Dist-)Upgrading the Debian
package will do that for you. Do not uninstall the removed dependency
`python3-ldap3` (i.e. do not run `apt autoremove`)! It is required to import
data from the LDAP server.
There is a safeguard in place to prevent accidental upgrades. Add the
following line to your config file to disable the safeguard:
```
UPGRADE_V1_TO_V2=True
```
## Running the Upgrade
Upgrade the Debian package to v2. This will restart the uffd UWSGI app. With
the default UWSGI configuration, the database migration scripts will run
automatically.
Otherwise run them manually:
```
uffd-admin db upgrade
```
The database migration scripts import users, groups and mail aliases from the
configured LDAP server. They also import OAuth2 and API clients defined with
the `OAUTH2_CLIENTS` and `API_CLIENTS_2` config options to the database.
Due to data being split between the LDAP server and the database, uffd v1
tended to accumulate orphaned database objects (e.g. role memberships of
deleted users). All orphaned objects are deleted during the upgrade.
As a side-effect upgrading resets all rate limits.
## Follow-up
Rename the following config options:
* `LDAP_USER_GID` -> `USER_GID`
* `LDAP_USER_MIN_UID` -> `USER_MIN_UID`
* `LDAP_USER_MAX_UID` -> `USER_MAX_UID`
* `LDAP_USER_SERVICE_MIN_UID` -> `USER_SERVICE_MIN_UID`
* `LDAP_USER_SERVICE_MAX_UID` -> `USER_SERVICE_MAX_UID`
Add the following config options:
* `GROUP_MIN_GID`
* `GROUP_MAX_GID`
Remove the following config options:
* `UPGRADE_V1_TO_V2`
* `LDAP_USER_SEARCH_BASE`
* `LDAP_USER_SEARCH_FILTER`
* `LDAP_USER_OBJECTCLASSES`
* `LDAP_USER_DN_ATTRIBUTE`
* `LDAP_USER_UID_ATTRIBUTE`
* `LDAP_USER_UID_ALIASES`
* `LDAP_USER_LOGINNAME_ATTRIBUTE`
* `LDAP_USER_LOGINNAME_ALIASES`
* `LDAP_USER_DISPLAYNAME_ATTRIBUTE`
* `LDAP_USER_DISPLAYNAME_ALIASES`
* `LDAP_USER_MAIL_ATTRIBUTE`
* `LDAP_USER_MAIL_ALIASES`
* `LDAP_USER_DEFAULT_ATTRIBUTES`
* `LDAP_GROUP_SEARCH_BASE`
* `LDAP_GROUP_SEARCH_FILTER`
* `LDAP_GROUP_GID_ATTRIBUTE`
* `LDAP_GROUP_NAME_ATTRIBUTE`
* `LDAP_GROUP_DESCRIPTION_ATTRIBUTE`
* `LDAP_GROUP_MEMBER_ATTRIBUTE`
* `LDAP_MAIL_SEARCH_BASE`
* `LDAP_MAIL_SEARCH_FILTER`
* `LDAP_MAIL_OBJECTCLASSES`
* `LDAP_MAIL_DN_ATTRIBUTE`
* `LDAP_MAIL_UID_ATTRIBUTE`
* `LDAP_MAIL_RECEIVERS_ATTRIBUTE`
* `LDAP_MAIL_DESTINATIONS_ATTRIBUTE`
* `LDAP_SERVICE_URL`
* `LDAP_SERVICE_USE_STARTTLS`
* `LDAP_SERVICE_BIND_DN`
* `LDAP_SERVICE_BIND_PASSWORD`
* `LDAP_SERVICE_USER_BIND`
* `ENABLE_INVITE`
* `ENABLE_PASSWORDRESET`
* `ENABLE_ROLESELFSERVICE`
* `OAUTH2_CLIENTS`
* `API_CLIENTS` (should not be set, see "Preperation")
* `API_CLIENTS_2`
* `LDAP_SERVICE_MOCK` (development option, should not be set)
If you set a custom config filename with the environment variable
`CONFIG_FILENAME`, replace it with `CONFIG_PATH`. The new variable must be
set to a full path instead of a filename.
If you set the config option `ACL_SELFSERVICE_GROUP`, but not
`ACL_ACCESS_GROUP`, make sure to set `ACL_ACCESS_GROUP` to the same value as
`ACL_SELFSERVICE_GROUP`.
Add a cron job that runs `uffd-admin cleanup` at least daily. Unless you
modified `/etc/cron.d/uffd`, upgrading the Debian package will do this for you.
Uninstall the previous dependency `python3-ldap3` (i.e. run `apt autoremove`).
[uffd-ldapd]: https://git.cccv.de/uffd/uffd-ldapd
No preview for this file type
#!/usr/bin/python3
import os
import sys
import logging
import datetime
import flask_migrate
from uffd import create_app, db
from uffd.user.models import User, Group
from uffd.mfa.models import RecoveryCodeMethod, TOTPMethod, WebauthnMethod
from uffd.role.models import Role, RoleGroup
from uffd.signup.models import Signup
from uffd.invite.models import Invite, InviteGrant, InviteSignup
from uffd.session.models import DeviceLoginConfirmation
from uffd.oauth2.models import OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
from uffd.selfservice.models import PasswordToken, MailToken
def run_test(dburi, revision):
config = {
'TESTING': True,
'DEBUG': True,
'SQLALCHEMY_DATABASE_URI': dburi,
'SECRET_KEY': 'DEBUGKEY',
'MAIL_SKIP_SEND': True,
'SELF_SIGNUP': True,
'ENABLE_INVITE': True,
'ENABLE_PASSWORDRESET': True,
'LDAP_SERVICE_MOCK': True
}
app = create_app(config)
with app.test_request_context():
flask_migrate.upgrade(revision='head')
# Add a few rows to all tables to make sure that the migrations work with data
user = User.query.first()
group = Group.query.first()
db.session.add(RecoveryCodeMethod(user=user))
db.session.add(TOTPMethod(user=user, name='mytotp'))
db.session.add(WebauthnMethod(user=user, name='mywebauthn', cred=b''))
role = Role(name='role', groups={group: RoleGroup(group=group)})
db.session.add(role)
role.members.append(user)
db.session.add(Role(name='base', included_roles=[role], locked=True, is_default=True, moderator_group=group, groups={group: RoleGroup(group=group)}))
db.session.add(Signup(loginname='newuser', displayname='New User', mail='newuser@example.com', password='newpassword'))
db.session.add(Signup(loginname='testuser', displayname='Testuser', mail='testuser@example.com', password='testpassword', user=user))
invite = Invite(valid_until=datetime.datetime.now(), roles=[role])
db.session.add(invite)
invite.signups.append(InviteSignup(loginname='newuser', displayname='New User', mail='newuser@example.com', password='newpassword'))
invite.grants.append(InviteGrant(user=user))
db.session.add(Invite(creator=user, valid_until=datetime.datetime.now()))
db.session.add(OAuth2Grant(user=user, client_id='testclient', code='testcode', redirect_uri='http://example.com/callback', expires=datetime.datetime.now()))
db.session.add(OAuth2Token(user=user, client_id='testclient', token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now()))
db.session.add(OAuth2DeviceLoginInitiation(oauth2_client_id='testclient', confirmations=[DeviceLoginConfirmation(user=user)]))
db.session.add(PasswordToken(user=user))
db.session.add(MailToken(user=user, newmail='test@example.com'))
db.session.commit()
flask_migrate.downgrade(revision=revision)
flask_migrate.upgrade(revision='head')
if __name__ == '__main__':
if len(sys.argv) != 2 or sys.argv[1] not in ['sqlite', 'mysql']:
print('usage: check_migrations.py {sqlite|mysql}')
exit(1)
dbtype = sys.argv[1]
revs = [s.split('_', 1)[0] for s in os.listdir('uffd/migrations/versions') if '_' in s and s.endswith('.py')] + ['base']
logging.getLogger().setLevel(logging.INFO)
failures = 0
for rev in revs:
logging.info(f'Testing "upgrade to head, add objects, downgrade to {rev}, upgrade to head"')
# Cleanup/drop database
if dbtype == 'sqlite':
try:
os.remove('/tmp/uffd_check_migrations_db.sqlite3')
except FileNotFoundError:
pass
dburi = 'sqlite:////tmp/uffd_check_migrations_db.sqlite3'
elif dbtype == 'mysql':
import MySQLdb
conn = MySQLdb.connect(user='root', unix_socket='/var/run/mysqld/mysqld.sock')
cur = conn.cursor()
try:
cur.execute('DROP DATABASE uffd')
except:
pass
cur.execute('CREATE DATABASE uffd')
conn.close()
dburi = 'mysql+mysqldb:///uffd?unix_socket=/var/run/mysqld/mysqld.sock'
try:
run_test(dburi, rev)
except Exception as ex:
failures += 1
logging.error('Test failed', exc_info=ex)
if failures:
logging.info(f'{failures} tests failed')
exit(1)
logging.info('All tests succeeded')
exit(0)
...@@ -3,12 +3,12 @@ ...@@ -3,12 +3,12 @@
set -eu set -eu
export FLASK_APP=/usr/share/uffd/uffd export FLASK_APP=/usr/share/uffd/uffd
export CONFIG_FILENAME=/etc/uffd/uffd.cfg export CONFIG_PATH=/etc/uffd/uffd.cfg
if [ "$(whoami)" = "uffd" ]; then if [ "$(whoami)" = "uffd" ]; then
flask "$@" flask "$@"
elif command -v sudo > /dev/null 2>&1; then elif command -v sudo > /dev/null 2>&1; then
exec sudo --preserve-env=FLASK_APP,CONFIG_FILENAME -u uffd flask "$@" exec sudo --preserve-env=FLASK_APP,CONFIG_PATH -u uffd flask "$@"
elif command -v runuser > /dev/null 2>&1; then elif command -v runuser > /dev/null 2>&1; then
exec runuser --preserve-environment -u uffd -- flask "$@" exec runuser --preserve-environment -u uffd -- flask "$@"
else else
......
...@@ -23,11 +23,16 @@ Depends: ...@@ -23,11 +23,16 @@ Depends:
python3-flask-migrate, python3-flask-migrate,
python3-qrcode, python3-qrcode,
python3-fido2, python3-fido2,
python3-oauthlib, python3-jwt,
python3-cryptography,
python3-flask-babel, python3-flask-babel,
python3-argon2,
python3-itsdangerous,
uwsgi, uwsgi,
uwsgi-plugin-python3, uwsgi-plugin-python3,
Recommends: Recommends:
nginx, nginx,
python3-mysqldb, python3-mysqldb,
python3-prometheus-client,
python3-ua-parser,
Description: Web-based user management and single sign-on software Description: Web-based user management and single sign-on software
# Cronjobs for uffd # Cronjobs for uffd
@daily uffd [ -f /usr/bin/uffd-admin ] && flock -n /var/run/uffd/cron.roles-update-all.lock /usr/bin/uffd-admin roles-update-all --check-only 2> /dev/null @daily uffd flock -n /var/run/uffd/cron.roles-update-all.lock /usr/bin/uffd-admin roles-update-all --check-only 2> /dev/null
@daily uffd flock -n /var/run/uffd/cron.cleanup.lock /usr/bin/uffd-admin cleanup > /dev/null
/etc/uffd /etc/uffd
/var/lib/uffd /var/lib/uffd
/usr/share/uffd/uffd/instance
...@@ -2,3 +2,5 @@ uwsgi.ini /etc/uffd/ ...@@ -2,3 +2,5 @@ uwsgi.ini /etc/uffd/
nginx.include.conf /etc/uffd/ nginx.include.conf /etc/uffd/
debian/contrib/uffd.cfg /etc/uffd/ debian/contrib/uffd.cfg /etc/uffd/
debian/contrib/uffd-admin /usr/bin/ debian/contrib/uffd-admin /usr/bin/
README.md /usr/share/doc/uffd/
UPGRADE.md /usr/share/doc/uffd/
/etc/uffd/uffd.cfg /usr/share/uffd/uffd/instance/config.cfg
/etc/uffd/uwsgi.ini /etc/uwsgi/apps-available/uffd.ini /etc/uffd/uwsgi.ini /etc/uwsgi/apps-available/uffd.ini
/etc/uwsgi/apps-available/uffd.ini /etc/uwsgi/apps-enabled/uffd.ini /etc/uwsgi/apps-available/uffd.ini /etc/uwsgi/apps-enabled/uffd.ini
...@@ -25,7 +25,7 @@ setup( ...@@ -25,7 +25,7 @@ setup(
author='CCCV', author='CCCV',
author_email='it@cccv.de', author_email='it@cccv.de',
license='AGPL3', license='AGPL3',
packages=find_packages(), packages=['uffd'],
include_package_data=True, include_package_data=True,
zip_safe=False, zip_safe=False,
python_requires='>=3.7', python_requires='>=3.7',
...@@ -36,10 +36,15 @@ setup( ...@@ -36,10 +36,15 @@ setup(
'Flask-SQLAlchemy==2.1', 'Flask-SQLAlchemy==2.1',
'qrcode==6.1', 'qrcode==6.1',
'fido2==0.5.0', 'fido2==0.5.0',
'oauthlib==2.1.0', 'cryptography==2.6.1',
'pyjwt==1.7.0',
'Flask-Migrate==2.1.1', 'Flask-Migrate==2.1.1',
'Flask-Babel==0.11.2', 'Flask-Babel==0.11.2',
'alembic==1.0.0', '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 # 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 # not very good at resolving them, so we pin the versions from Debian Buster
...@@ -49,12 +54,9 @@ setup( ...@@ -49,12 +54,9 @@ setup(
'cffi # v1.12.2 no longer works with python3.9. Newer versions seem to work fine.', 'cffi # v1.12.2 no longer works with python3.9. Newer versions seem to work fine.',
'chardet==3.0.4', 'chardet==3.0.4',
'click==7.0', 'click==7.0',
'cryptography==2.6.1',
'idna==2.6', 'idna==2.6',
'itsdangerous==0.24',
'Jinja2==2.10', 'Jinja2==2.10',
'MarkupSafe==1.1.0', 'MarkupSafe==1.1.0',
'oauthlib==2.1.0',
'pyasn1==0.4.2', 'pyasn1==0.4.2',
'pycparser==2.19', 'pycparser==2.19',
'requests==2.21.0', 'requests==2.21.0',
......
import datetime from uffd.database import db
import time from uffd.models import User, Role, RoleGroup
import unittest
from flask import url_for, session from tests.utils import UffdTestCase
# These imports are required, because otherwise we get circular imports?!
from uffd import user
from uffd.user.models import User, Group
from uffd.role.models import flatten_recursive, Role, RoleGroup
from uffd.mfa.models import TOTPMethod
from uffd import create_app, db
from utils import dump, UffdTestCase
class TestPrimitives(unittest.TestCase):
def test_flatten_recursive(self):
class Node:
def __init__(self, *neighbors):
self.neighbors = set(neighbors or set())
cycle = Node()
cycle.neighbors.add(cycle)
common = Node(cycle)
intermediate1 = Node(common)
intermediate2 = Node(common, intermediate1)
stub = Node()
backref = Node()
start1 = Node(intermediate1, intermediate2, stub, backref)
backref.neighbors.add(start1)
start2 = Node()
self.assertSetEqual(flatten_recursive({start1, start2}, 'neighbors'),
{start1, start2, backref, stub, intermediate1, intermediate2, common, cycle})
self.assertSetEqual(flatten_recursive(set(), 'neighbors'), set())
class TestUserRoleAttributes(UffdTestCase):
def test_roles_effective(self):
db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
db.session.commit()
user = self.get_user()
service_user = User.query.filter_by(loginname='service').one_or_none()
included_by_default_role = Role(name='included_by_default')
default_role = Role(name='default', is_default=True, included_roles=[included_by_default_role])
included_role = Role(name='included')
cycle_role = Role(name='cycle')
direct_role1 = Role(name='role1', members=[user, service_user], included_roles=[included_role, cycle_role])
direct_role2 = Role(name='role2', members=[user, service_user], included_roles=[included_role])
cycle_role.included_roles.append(direct_role1)
db.session.add_all([included_by_default_role, default_role, included_role, cycle_role, direct_role1, direct_role2])
self.assertSetEqual(user.roles_effective, {direct_role1, direct_role2, cycle_role, included_role, default_role, included_by_default_role})
self.assertSetEqual(service_user.roles_effective, {direct_role1, direct_role2, cycle_role, included_role})
def test_compute_groups(self):
user = self.get_user()
group1 = self.get_users_group()
group2 = self.get_access_group()
role1 = Role(name='role1', groups={group1: RoleGroup(group=group1)})
role2 = Role(name='role2', groups={group1: RoleGroup(group=group1), group2: RoleGroup(group=group2)})
db.session.add_all([role1, role2])
self.assertSetEqual(user.compute_groups(), set())
role1.members.append(user)
role2.members.append(user)
self.assertSetEqual(user.compute_groups(), {group1, group2})
role2.groups[group2].requires_mfa = True
self.assertSetEqual(user.compute_groups(), {group1})
db.session.add(TOTPMethod(user=user))
self.assertSetEqual(user.compute_groups(), {group1, group2})
def test_update_groups(self):
user = self.get_user()
group1 = self.get_users_group()
group2 = self.get_access_group()
role1 = Role(name='role1', members=[user], groups={group1: RoleGroup(group=group1)})
role2 = Role(name='role2', groups={group2: RoleGroup(group=group2)})
db.session.add_all([role1, role2])
user.groups = [group2]
groups_added, groups_removed = user.update_groups()
self.assertSetEqual(groups_added, {group1})
self.assertSetEqual(groups_removed, {group2})
self.assertSetEqual(set(user.groups), {group1})
groups_added, groups_removed = user.update_groups()
self.assertSetEqual(groups_added, set())
self.assertSetEqual(groups_removed, set())
self.assertSetEqual(set(user.groups), {group1})
class TestRoleModel(UffdTestCase):
def test_members_effective(self):
db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
db.session.commit()
user1 = self.get_user()
user2 = self.get_admin()
service = User.query.filter_by(loginname='service').one_or_none()
included_by_default_role = Role(name='included_by_default')
default_role = Role(name='default', is_default=True, included_roles=[included_by_default_role])
included_role = Role(name='included')
direct_role = Role(name='direct', members=[user1, user2, service], included_roles=[included_role])
empty_role = Role(name='empty', included_roles=[included_role])
self.assertSetEqual(included_by_default_role.members_effective, {user1, user2})
self.assertSetEqual(default_role.members_effective, {user1, user2})
self.assertSetEqual(included_role.members_effective, {user1, user2, service})
self.assertSetEqual(direct_role.members_effective, {user1, user2, service})
self.assertSetEqual(empty_role.members_effective, set())
def test_included_roles_recursive(self):
baserole = Role(name='base')
role1 = Role(name='role1', included_roles=[baserole])
role2 = Role(name='role2', included_roles=[baserole])
role3 = Role(name='role3', included_roles=[role1, role2])
self.assertSetEqual(role1.included_roles_recursive, {baserole})
self.assertSetEqual(role2.included_roles_recursive, {baserole})
self.assertSetEqual(role3.included_roles_recursive, {baserole, role1, role2})
baserole.included_roles.append(role1)
self.assertSetEqual(role3.included_roles_recursive, {baserole, role1, role2})
def test_groups_effective(self):
group1 = self.get_users_group()
group2 = self.get_access_group()
baserole = Role(name='base', groups={group1: RoleGroup(group=group1)})
role1 = Role(name='role1', groups={group2: RoleGroup(group=group2)}, included_roles=[baserole])
self.assertSetEqual(baserole.groups_effective, {group1})
self.assertSetEqual(role1.groups_effective, {group1, group2})
def test_update_member_groups(self):
user1 = self.get_user()
user1.update_groups()
user2 = self.get_admin()
user2.update_groups()
group1 = self.get_users_group()
group2 = self.get_access_group()
group3 = self.get_admin_group()
baserole = Role(name='base', members=[user1], groups={group1: RoleGroup(group=group1)})
role1 = Role(name='role1', members=[user2], groups={group2: RoleGroup(group=group2)}, included_roles=[baserole])
db.session.add_all([baserole, role1])
baserole.update_member_groups()
role1.update_member_groups()
self.assertSetEqual(set(user1.groups), {group1})
self.assertSetEqual(set(user2.groups), {group1, group2})
baserole.groups[group3] = RoleGroup()
baserole.update_member_groups()
self.assertSetEqual(set(user1.groups), {group1, group3})
self.assertSetEqual(set(user2.groups), {group1, group2, group3})
class TestRoleViews(UffdTestCase):
def setUp(self):
super().setUp()
self.login_as('admin')
def test_index(self):
db.session.add(Role(name='base', description='Base role description'))
db.session.add(Role(name='test1', description='Test1 role description'))
db.session.commit()
r = self.client.get(path=url_for('role.index'), follow_redirects=True)
dump('role_index', r)
self.assertEqual(r.status_code, 200)
def test_index_empty(self):
r = self.client.get(path=url_for('role.index'), follow_redirects=True)
dump('role_index_empty', r)
self.assertEqual(r.status_code, 200)
def test_show(self):
role = Role(name='base', description='Base role description')
db.session.add(role)
db.session.commit()
r = self.client.get(path=url_for('role.show', roleid=role.id), follow_redirects=True)
dump('role_show', r)
self.assertEqual(r.status_code, 200)
def test_new(self):
r = self.client.get(path=url_for('role.new'), follow_redirects=True)
dump('role_new', r)
self.assertEqual(r.status_code, 200)
def test_update(self):
role = Role(name='base', description='Base role description')
db.session.add(role)
db.session.commit()
role.groups[self.get_admin_group()] = RoleGroup()
db.session.commit()
self.assertEqual(role.name, 'base')
self.assertEqual(role.description, 'Base role description')
self.assertSetEqual(set(role.groups), {self.get_admin_group()})
r = self.client.post(path=url_for('role.update', roleid=role.id),
data={'name': 'base1', 'description': 'Base role description1', 'moderator-group': '', 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
follow_redirects=True)
dump('role_update', r)
self.assertEqual(r.status_code, 200)
role = Role.query.get(role.id)
self.assertEqual(role.name, 'base1')
self.assertEqual(role.description, 'Base role description1')
self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
def test_create(self):
self.assertIsNone(Role.query.filter_by(name='base').first())
r = self.client.post(path=url_for('role.update'),
data={'name': 'base', 'description': 'Base role description', 'moderator-group': '', 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
follow_redirects=True)
dump('role_create', r)
self.assertEqual(r.status_code, 200)
role = Role.query.filter_by(name='base').first()
self.assertIsNotNone(role)
self.assertEqual(role.name, 'base')
self.assertEqual(role.description, 'Base role description')
self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
def test_create_with_moderator_group(self):
self.assertIsNone(Role.query.filter_by(name='base').first())
r = self.client.post(path=url_for('role.update'),
data={'name': 'base', 'description': 'Base role description', 'moderator-group': self.get_admin_group().id, 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
follow_redirects=True)
self.assertEqual(r.status_code, 200)
role = Role.query.filter_by(name='base').first()
self.assertIsNotNone(role)
self.assertEqual(role.name, 'base')
self.assertEqual(role.description, 'Base role description')
self.assertEqual(role.moderator_group.name, 'uffd_admin')
self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
def test_delete(self):
role = Role(name='base', description='Base role description')
db.session.add(role)
db.session.commit()
role_id = role.id
self.assertIsNotNone(Role.query.get(role_id))
r = self.client.get(path=url_for('role.delete', roleid=role.id), follow_redirects=True)
dump('role_delete', r)
self.assertEqual(r.status_code, 200)
self.assertIsNone(Role.query.get(role_id))
# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
def test_set_default(self):
db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
db.session.commit()
role = Role(name='test')
db.session.add(role)
role.groups[self.get_admin_group()] = RoleGroup()
user1 = self.get_user()
user2 = self.get_admin()
service_user = User.query.filter_by(loginname='service').one_or_none()
self.assertSetEqual(set(self.get_user().roles_effective), set())
self.assertSetEqual(set(self.get_admin().roles_effective), set())
self.assertSetEqual(set(service_user.roles_effective), set())
role.members.append(self.get_user())
role.members.append(service_user)
self.assertSetEqual(set(self.get_user().roles_effective), {role})
self.assertSetEqual(set(self.get_admin().roles_effective), set())
self.assertSetEqual(set(service_user.roles_effective), {role})
db.session.commit()
role_id = role.id
self.assertSetEqual(set(role.members), {self.get_user(), service_user})
r = self.client.get(path=url_for('role.set_default', roleid=role.id), follow_redirects=True)
dump('role_set_default', r)
self.assertEqual(r.status_code, 200)
role = Role.query.get(role_id)
service_user = User.query.filter_by(loginname='service').one_or_none()
self.assertSetEqual(set(role.members), {service_user})
self.assertSetEqual(set(self.get_user().roles_effective), {role})
self.assertSetEqual(set(self.get_admin().roles_effective), {role})
def test_unset_default(self):
admin_role = Role(name='admin', is_default=True)
db.session.add(admin_role)
admin_role.groups[self.get_admin_group()] = RoleGroup()
db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
db.session.commit()
role = Role(name='test', is_default=True)
db.session.add(role)
service_user = User.query.filter_by(loginname='service').one_or_none()
role.members.append(service_user)
self.assertSetEqual(set(self.get_user().roles_effective), {role, admin_role})
self.assertSetEqual(set(self.get_admin().roles_effective), {role, admin_role})
self.assertSetEqual(set(service_user.roles_effective), {role})
db.session.commit()
role_id = role.id
admin_role_id = admin_role.id
self.assertSetEqual(set(role.members), {service_user})
r = self.client.get(path=url_for('role.unset_default', roleid=role.id), follow_redirects=True)
dump('role_unset_default', r)
self.assertEqual(r.status_code, 200)
role = Role.query.get(role_id)
admin_role = Role.query.get(admin_role_id)
service_user = User.query.filter_by(loginname='service').one_or_none()
self.assertSetEqual(set(role.members), {service_user})
self.assertSetEqual(set(self.get_user().roles_effective), {admin_role})
self.assertSetEqual(set(self.get_admin().roles_effective), {admin_role})
class TestRoleCLI(UffdTestCase): class TestRoleCLI(UffdTestCase):
def setUp(self): def setUp(self):
...@@ -398,6 +113,18 @@ class TestRoleCLI(UffdTestCase): ...@@ -398,6 +113,18 @@ class TestRoleCLI(UffdTestCase):
self.assertEqual(role.is_default, True) self.assertEqual(role.is_default, True)
self.assertEqual(set(self.get_user().groups), {self.get_access_group()}) self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
# Regression test for https://git.cccv.de/uffd/uffd/-/issues/156
def test_update_without_description(self):
with self.app.test_request_context():
role = Role.query.filter_by(name='test').first()
role.description = 'Test description'
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--clear-groups'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
role = Role.query.filter_by(name='test').first()
self.assertEqual(role.description, 'Test description')
def test_delete(self): def test_delete(self):
with self.app.test_request_context(): with self.app.test_request_context():
self.assertIsNotNone(Role.query.filter_by(name='test').first()) self.assertIsNotNone(Role.query.filter_by(name='test').first())
......
from uffd.database import db
from uffd.models import User, UserEmail, FeatureFlag
from tests.utils import UffdTestCase
class TestUniqueEmailAddressCommands(UffdTestCase):
def setUp(self):
super().setUp()
self.client.__exit__(None, None, None)
def test_enable(self):
result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'enable'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
self.assertTrue(FeatureFlag.unique_email_addresses)
def test_enable_already_enabled(self):
with self.app.test_request_context():
FeatureFlag.unique_email_addresses.enable()
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'enable'])
self.assertEqual(result.exit_code, 1)
def test_enable_user_conflict(self):
with self.app.test_request_context():
db.session.add(UserEmail(user=self.get_user(), address='foo@example.com'))
db.session.add(UserEmail(user=self.get_user(), address='FOO@example.com'))
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'enable'])
self.assertEqual(result.exit_code, 1)
def test_enable_global_conflict(self):
with self.app.test_request_context():
db.session.add(UserEmail(user=self.get_user(), address='foo@example.com', verified=True))
db.session.add(UserEmail(user=self.get_admin(), address='FOO@example.com', verified=True))
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'enable'])
self.assertEqual(result.exit_code, 1)
def test_disable(self):
with self.app.test_request_context():
FeatureFlag.unique_email_addresses.enable()
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'disable'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
self.assertFalse(FeatureFlag.unique_email_addresses)
def test_disable_already_enabled(self):
result = self.app.test_cli_runner().invoke(args=['unique-email-addresses', 'disable'])
self.assertEqual(result.exit_code, 1)
from uffd.database import db
from uffd.models import User, Group, Role, RoleGroup, FeatureFlag
from tests.utils import UffdTestCase
class TestUserCLI(UffdTestCase):
def setUp(self):
super().setUp()
role = Role(name='admin')
role.groups[self.get_admin_group()] = RoleGroup(group=self.get_admin_group())
db.session.add(role)
db.session.add(Role(name='test'))
db.session.commit()
self.client.__exit__(None, None, None)
def test_list(self):
result = self.app.test_cli_runner().invoke(args=['user', 'list'])
self.assertEqual(result.exit_code, 0)
def test_show(self):
result = self.app.test_cli_runner().invoke(args=['user', 'show', 'testuser'])
self.assertEqual(result.exit_code, 0)
result = self.app.test_cli_runner().invoke(args=['user', 'show', 'doesnotexist'])
self.assertEqual(result.exit_code, 1)
def test_create(self):
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'new user', '--mail', 'foobar@example.com']) # invalid login name
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', '']) # invalid mail
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--password', '']) # invalid password
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--displayname', '']) # invalid display name
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--add-role', 'doesnotexist']) # unknown role
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'testuser', '--mail', 'foobar@example.com']) # conflicting name
self.assertEqual(result.exit_code, 1)
with self.app.test_request_context():
FeatureFlag.unique_email_addresses.enable()
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'test@example.com']) # conflicting email address
self.assertEqual(result.exit_code, 1)
with self.app.test_request_context():
FeatureFlag.unique_email_addresses.disable()
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'newmail@example.com',
'--displayname', 'New Display Name', '--password', 'newpassword', '--add-role', 'admin'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
user = User.query.filter_by(loginname='newuser').first()
self.assertIsNotNone(user)
self.assertEqual(user.primary_email.address, 'newmail@example.com')
self.assertEqual(user.displayname, 'New Display Name')
self.assertTrue(user.password.verify('newpassword'))
self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
self.assertIn(self.get_admin_group(), user.groups)
self.assertFalse(user.is_deactivated)
result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser2', '--mail', 'newmail2@example.com', '--deactivate'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
user = User.query.filter_by(loginname='newuser2').first()
self.assertTrue(user.is_deactivated)
def test_update(self):
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'doesnotexist', '--displayname', 'foo'])
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', '']) # invalid mail
self.assertEqual(result.exit_code, 1)
with self.app.test_request_context():
FeatureFlag.unique_email_addresses.enable()
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', 'admin@example.com']) # conflicting mail
self.assertEqual(result.exit_code, 1)
with self.app.test_request_context():
FeatureFlag.unique_email_addresses.disable()
db.session.commit()
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--password', '']) # invalid password
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--displayname', '']) # invalid display name
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--remove-role', 'doesnotexist'])
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', 'newmail@example.com',
'--displayname', 'New Display Name', '--password', 'newpassword'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
user = User.query.filter_by(loginname='testuser').first()
self.assertIsNotNone(user)
self.assertEqual(user.primary_email.address, 'newmail@example.com')
self.assertEqual(user.displayname, 'New Display Name')
self.assertTrue(user.password.verify('newpassword'))
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--add-role', 'admin', '--add-role', 'test'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
user = User.query.filter_by(loginname='testuser').first()
self.assertEqual(set(user.roles), {Role.query.filter_by(name='admin').one(), Role.query.filter_by(name='test').one()})
self.assertIn(self.get_admin_group(), user.groups)
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--remove-role', 'admin'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
user = User.query.filter_by(loginname='testuser').first()
self.assertEqual(user.roles, Role.query.filter_by(name='test').all())
self.assertNotIn(self.get_admin_group(), user.groups)
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--clear-roles', '--add-role', 'admin'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
user = User.query.filter_by(loginname='testuser').first()
self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
self.assertIn(self.get_admin_group(), user.groups)
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--deactivate'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
user = User.query.filter_by(loginname='testuser').first()
self.assertTrue(user.is_deactivated)
result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--activate'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
user = User.query.filter_by(loginname='testuser').first()
self.assertFalse(user.is_deactivated)
def test_delete(self):
with self.app.test_request_context():
self.assertIsNotNone(User.query.filter_by(loginname='testuser').first())
result = self.app.test_cli_runner().invoke(args=['user', 'delete', 'testuser'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
self.assertIsNone(User.query.filter_by(loginname='testuser').first())
result = self.app.test_cli_runner().invoke(args=['user', 'delete', 'doesnotexist'])
self.assertEqual(result.exit_code, 1)
class TestGroupCLI(UffdTestCase):
def setUp(self):
super().setUp()
self.client.__exit__(None, None, None)
def test_list(self):
result = self.app.test_cli_runner().invoke(args=['group', 'list'])
self.assertEqual(result.exit_code, 0)
def test_show(self):
result = self.app.test_cli_runner().invoke(args=['group', 'show', 'users'])
self.assertEqual(result.exit_code, 0)
result = self.app.test_cli_runner().invoke(args=['group', 'show', 'doesnotexist'])
self.assertEqual(result.exit_code, 1)
def test_create(self):
result = self.app.test_cli_runner().invoke(args=['group', 'create', 'users']) # Duplicate name
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['group', 'create', 'new group'])
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['group', 'create', 'newgroup', '--description', 'A new group'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
group = Group.query.filter_by(name='newgroup').first()
self.assertIsNotNone(group)
self.assertEqual(group.description, 'A new group')
def test_update(self):
result = self.app.test_cli_runner().invoke(args=['group', 'update', 'doesnotexist', '--description', 'foo'])
self.assertEqual(result.exit_code, 1)
result = self.app.test_cli_runner().invoke(args=['group', 'update', 'users', '--description', 'New description'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
group = Group.query.filter_by(name='users').first()
self.assertEqual(group.description, 'New description')
def test_update_without_description(self):
result = self.app.test_cli_runner().invoke(args=['group', 'update', 'users']) # Should not change anything
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
group = Group.query.filter_by(name='users').first()
self.assertEqual(group.description, 'Base group for all users')
def test_delete(self):
with self.app.test_request_context():
self.assertIsNotNone(Group.query.filter_by(name='users').first())
result = self.app.test_cli_runner().invoke(args=['group', 'delete', 'users'])
self.assertEqual(result.exit_code, 0)
with self.app.test_request_context():
self.assertIsNone(Group.query.filter_by(name='users').first())
result = self.app.test_cli_runner().invoke(args=['group', 'delete', 'doesnotexist'])
self.assertEqual(result.exit_code, 1)
import os
import sys
import datetime
from uffd.database import db
from uffd.models import (
User, UserEmail, Group,
RecoveryCodeMethod, TOTPMethod, WebauthnMethod,
Role, RoleGroup,
Signup,
Invite, InviteGrant, InviteSignup,
DeviceLoginConfirmation,
Service,
OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation,
PasswordToken,
Session,
)
from tests.utils import MigrationTestCase
class TestFuzzy(MigrationTestCase):
def setUpApp(self):
self.app.config['LDAP_SERVICE_MOCK'] = True
self.app.config['OAUTH2_CLIENTS'] = {
'test': {
'service_name': 'test',
'client_secret': 'testsecret',
'redirect_uris': ['http://localhost:5004/oauthproxy/callback'],
'logout_urls': ['http://localhost:5004/oauthproxy/logout']
}
}
self.app.config['API_CLIENTS_2'] = {
'test': {
'service_name': 'test',
'client_secret': 'testsecret',
'scopes': ['checkpassword', 'getusers', 'getmails']
},
}
# Runs every upgrade/downgrade script with data. To do this we first upgrade
# to head, create data, then downgrade, upgrade, downgrade for every revision.
def test_migrations_fuzzy(self):
self.upgrade('head')
# Users and groups were created by 878b25c4fae7_ldap_to_db because we set LDAP_SERVICE_MOCK to True
user = User.query.first()
group = Group.query.first()
db.session.add(RecoveryCodeMethod(user=user))
db.session.add(TOTPMethod(user=user, name='mytotp'))
db.session.add(WebauthnMethod(user=user, name='mywebauthn', cred=b''))
role = Role(name='role', groups={group: RoleGroup(group=group)})
db.session.add(role)
role.members.append(user)
db.session.add(Role(name='base', included_roles=[role], locked=True, is_default=True, moderator_group=group, groups={group: RoleGroup(group=group)}))
db.session.add(Signup(loginname='newuser', displayname='New User', mail='newuser@example.com', password='newpassword'))
db.session.add(Signup(loginname='testuser', displayname='Testuser', mail='testuser@example.com', password='testpassword', user=user))
invite = Invite(valid_until=datetime.datetime.now(), roles=[role])
db.session.add(invite)
invite.signups.append(InviteSignup(loginname='newuser', displayname='New User', mail='newuser@example.com', password='newpassword'))
invite.grants.append(InviteGrant(user=user))
db.session.add(Invite(creator=user, valid_until=datetime.datetime.now()))
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])
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')]
for rev in revs:
self.downgrade('-1')
self.upgrade('+1')
self.downgrade('-1')