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
Select Git revision
  • master
  • jwt_encode_inconsistencies
  • incremental-sync
  • redis-rate-limits
  • typehints
  • v1.2.x
  • v1.x.x
  • v1.1.x
  • feature_invite_validuntil_minmax
  • Dockerfile
  • v1.0.x
  • roles-recursive-cte
  • v0.1.2
  • v0.1.4
  • v0.1.5
  • v0.2.0
  • v0.3.0
  • v1.0.0
  • v1.0.1
  • v1.0.2
  • v1.1.0
  • v1.1.1
  • v1.1.2
  • v1.2.0
  • v2.0.0
  • v2.0.1
  • v2.1.0
  • v2.2.0
  • v2.3.0
  • v2.3.1
30 results

Target

Select target project
No results found
Select Git revision
  • master
  • ldap_user_conn_test
2 results
Show changes

Commits on Source 418

318 additional commits have been omitted to prevent performance issues.
229 files
+ 26960
1209
Compare changes
  • Side-by-side
  • Inline

Files

+4 −1
Original line number Diff line number Diff line
@@ -52,7 +52,6 @@ coverage.xml
.pytest_cache/

# Translations
*.mo
*.pot

# Django stuff:
@@ -147,3 +146,7 @@ Sessionx.vim
tags
# Persistent undo
[._]*.un~

# Auto-generated development key/certificate
devcert.crt
devcert.key
+287 −21
Original line number Diff line number Diff line
image: debian:latest
image: registry.git.cccv.de/uffd/docker-images/bookworm

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
  DEBIAN_FRONTEND: noninteractive 
  GIT_SUBMODULE_STRATEGY: normal
  APT_API_URL: https://packages.cccv.de
  APT_REPO: uffd
  PYLINT_PIN: pylint~=2.16.2

before_script:
  - apt update
  - apt -y -qq -o Dpkg::Use-Pty=0 install python3 sqlite3 locales-all git python3-flask python3-coverage python3-flask-sqlalchemy python3-pip python-ldap3
  - python3 -V
  - lsb_release -a
  - uname -a
  - python3 -m pylint --version
  - python3 -m coverage --version
  - 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 .)"

linter:
.build:
  stage: build

build:pip:
  extends: .build
  script:
  - python3 -m build
  artifacts:
    paths:
      - dist/*

build:apt:
  extends: .build
  script:
  - ./debian/create_changelog.py uffd > debian/changelog
  - export PYBUILD_INSTALL_ARGS="--install-lib=/usr/share/uffd/ --install-scripts=/usr/share/uffd/"
  - export DEB_BUILD_OPTIONS=nocheck
  - dpkg-buildpackage -us -uc
  - mv ../*.deb ./
  - dpkg-deb -I *.deb
  - dpkg-deb -c *.deb
  artifacts:
    paths:
    - ./*.deb

db_migrations_updated:
  stage: test
  needs: []
  script:
  - 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'

linter:buster:
  image: registry.git.cccv.de/uffd/docker-images/buster
  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:bullseye:
  image: registry.git.cccv.de/uffd/docker-images/bullseye
  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:
  - pip3 install pylint
  - python3 -m pylint --rcfile .pylintrc --output-format=text uffd | tee pylint.txt
  - 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.txt

#unittest:
#  stage: test
#  script:
#  - python3 -m coverage run runTests.py
#  - python3 -m coverage report --include "./*"
#  - python3 -m coverage report -m  --include "./*" > report.txt
#  - python3 -m coverage html --include "./*"
#  artifacts:
#      paths:
#      - htmlcov/*
#      - .coverage
#      - report.txt
    - pylint.html
    reports:
      codequality: codeclimate.json

tests:buster:sqlite:
  image: registry.git.cccv.de/uffd/docker-images/buster
  stage: test
  needs: []
  script:
  - python3 -m pytest --junitxml=report.xml
  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 html
  - python3-coverage xml
  - test ! -e failed
  artifacts:
    when: always
    paths:
    - htmlcov/index.html
    - htmlcov
    - pages
    expose_as: 'Coverage Report'
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
      junit: report.xml
  coverage: '/^TOTAL.*\s+(\d+\%)$/'

tests:bookworm:mysql:
  image: registry.git.cccv.de/uffd/docker-images/bookworm
  stage: test
  needs: []
  script:
  - service mariadb start
  - TEST_WITH_MYSQL=1 python3 -m pytest --junitxml=report.xml
  artifacts:
    when: always
    reports:
      junit: report.xml

html5validator:
  stage: test
  needs:
  - job: tests:bookworm:sqlite
  script:
  - html5validator --root pages 2>&1 | tee html5validator.log
  artifacts:
    when: on_failure
    paths:
    - pages
    - html5validator.log

.trans:
  stage: test
  needs: []
  script:
  - ./update_translations.sh $TRANSLATION_LANGUAGE
  coverage: '/^TOTAL.*\s+(\d+\%)$/'
trans_de:
  extends: .trans
  variables:
    TRANSLATION_LANGUAGE: de

test:package:pip:buster:
  image: registry.git.cccv.de/uffd/docker-images/buster
  stage: test
  needs:
  - job: build:pip
  script:
  - pip3 install dist/*.tar.gz

test:package:pip:bullseye:
  image: registry.git.cccv.de/uffd/docker-images/bullseye
  stage: test
  needs:
  - job: build:pip
  script:
  - pip3 install dist/*.tar.gz

test:package:pip:bookworm:
  image: registry.git.cccv.de/uffd/docker-images/bookworm
  stage: test
  needs:
  - job: build:pip
  script:
  - pip3 install dist/*.tar.gz

# Since we want to test if the package installs correctly on a fresh Debian
# install (has correct dependencies, etc.), we don't use uffd/docker-images
# here
test:package:apt:buster:
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/debian:buster
  stage: test
  needs:
  - job: build:apt
  before_script: []
  script:
  - apt -y update
  - apt -y install curl ./*.deb
  - service uwsgi start uffd || ( service uwsgi status uffd ; sleep 15; cat /var/log/uwsgi/app/uffd.log; )
  - echo "server { listen 127.0.0.1:5000 default_server;  include /etc/uffd/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd.ini
  - service nginx start || ( service nginx status; nginx -t; exit 1; )
  - uffd-admin routes
  - curl -Lv 127.0.0.1:5000

test:package:apt:bullseye:
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/debian:bullseye
  stage: test
  needs:
  - job: build:apt
  before_script: []
  script:
  - apt -y update
  - apt -y install curl ./*.deb
  - service uwsgi start uffd || ( service uwsgi status uffd ; sleep 15; cat /var/log/uwsgi/app/uffd.log; )
  - echo "server { listen 127.0.0.1:5000 default_server;  include /etc/uffd/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd.ini
  - service nginx start || ( service nginx status; nginx -t; exit 1; )
  - uffd-admin routes
  - curl -Lv 127.0.0.1:5000

test:package:apt:bookworm:
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/debian:bookworm
  stage: test
  needs:
  - job: build:apt
  before_script: []
  script:
  - apt -y update
  - apt -y install curl ./*.deb
  - service uwsgi start uffd || ( service uwsgi status uffd ; sleep 15; cat /var/log/uwsgi/app/uffd.log; )
  - echo "server { listen 127.0.0.1:5000 default_server;  include /etc/uffd/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd.ini
  - service nginx start || ( service nginx status; nginx -t; exit 1; )
  - uffd-admin routes
  - curl -Lv 127.0.0.1:5000

.publish:
  stage: deploy
  rules:
    - if: '$CI_COMMIT_TAG =~ /v[0-9]+[.][0-9]+[.][0-9]+.*/'

publish:pip:
  extends: .publish
  script:
  - TWINE_USERNAME="${GITLABPKGS_USERNAME}" TWINE_PASSWORD="${GITLABPKGS_PASSWORD}" python3 -m twine upload --repository-url ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi dist/*
  - TWINE_USERNAME="${PYPI_USERNAME}" TWINE_PASSWORD="${PYPI_PASSWORD}" python3 -m twine upload dist/*
  dependencies:
  - build:pip

publish:apt:
  extends: .publish
  script:
  - export DEBPATH="$(echo *.deb)"
  - echo Upload deb file, add it to repo and clean up upload
  - curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X POST -F file=@"$DEBPATH" "${APT_API_URL}/api/files/${APT_REPO}-ci-upload-${CI_JOB_ID}"
  - curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X POST "${APT_API_URL}/api/repos/${APT_REPO}/file/${APT_REPO}-ci-upload-${CI_JOB_ID}"
  - curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X DELETE "${APT_API_URL}/api/files/${APT_REPO}-ci-upload-${CI_JOB_ID}"
  - echo Update published repo for all distros
  - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/buster"'
  - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/bullseye"'
  - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/bookworm"'
  dependencies:
  - build:apt
+13 −96
Original line number Diff line number Diff line
@@ -19,7 +19,7 @@ ignore-patterns=

# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=1
jobs=0

# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
@@ -28,7 +28,7 @@ limit-inference-results=100

# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
load-plugins=pylint_flask_sqlalchemy

# Pickle collected data for later comparisons.
persistent=yes
@@ -63,89 +63,13 @@ confidence=
disable=missing-module-docstring,
        missing-class-docstring,
        missing-function-docstring,
        print-statement,
        parameter-unpacking,
        unpacking-in-except,
        old-raise-syntax,
        backtick,
        long-suffix,
        old-ne-operator,
        old-octal-literal,
        import-star-module-level,
        non-ascii-bytes-literal,
        raw-checker-failed,
        bad-inline-option,
        locally-disabled,
        file-ignored,
        suppressed-message,
        useless-suppression,
        deprecated-pragma,
        use-symbolic-message-instead,
        unused-wildcard-import,
        apply-builtin,
        basestring-builtin,
        buffer-builtin,
        cmp-builtin,
        coerce-builtin,
        execfile-builtin,
        file-builtin,
        long-builtin,
        raw_input-builtin,
        reduce-builtin,
        standarderror-builtin,
        unicode-builtin,
        xrange-builtin,
        coerce-method,
        delslice-method,
        getslice-method,
        setslice-method,
        no-absolute-import,
        old-division,
        dict-iter-method,
        dict-view-method,
        next-method-called,
        metaclass-assignment,
        indexing-exception,
        raising-string,
        reload-builtin,
        oct-method,
        hex-method,
        nonzero-method,
        cmp-method,
        input-builtin,
        round-builtin,
        intern-builtin,
        unichr-builtin,
        map-builtin-not-iterating,
        zip-builtin-not-iterating,
        range-builtin-not-iterating,
        filter-builtin-not-iterating,
        using-cmp-argument,
        eq-without-hash,
        div-method,
        idiv-method,
        rdiv-method,
        exception-message-attribute,
        invalid-str-codec,
        sys-max-int,
        bad-python3-import,
        deprecated-string-function,
        deprecated-str-translate-call,
        deprecated-itertools-function,
        deprecated-types-field,
        next-method-defined,
        dict-items-not-iterating,
        dict-keys-not-iterating,
        dict-values-not-iterating,
        deprecated-operator-function,
        deprecated-urllib-function,
        xreadlines-attribute,
        deprecated-sys-function,
        exception-escape,
        comprehension-escape,
        too-few-public-methods,
        method-hidden,
        bad-continuation,
        too-many-ancestors,
        duplicate-code,
        redefined-builtin,
        superfluous-parens,
        consider-using-f-string, # Temporary

# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
@@ -203,7 +127,7 @@ ignore-docstrings=yes
ignore-imports=no

# Minimum lines number of a similarity.
min-similarity-lines=4
min-similarity-lines=6


[TYPECHECK]
@@ -216,7 +140,7 @@ contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
generated-members=query,UniqueConstraint

# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
@@ -464,13 +388,6 @@ max-line-length=160
# Maximum number of lines in a module.
max-module-lines=1000

# List of optional constructs for which whitespace checking is disabled. `dict-
# separator` is used to allow tabulation in dicts, etc.: {1  : 1,\n222: 2}.
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
# `empty-line` allows space-only lines.
no-space-check=trailing-comma,
               dict-separator

# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
@@ -560,13 +477,13 @@ valid-metaclass-classmethod-first-arg=cls
max-args=7

# Maximum number of attributes for a class (see R0902).
max-attributes=7
max-attributes=12

# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5

# Maximum number of branch for function / method body.
max-branches=12
max-branches=15

# Maximum number of locals for function / method body.
max-locals=15
@@ -591,5 +508,5 @@ min-public-methods=2

# Exceptions that will emit a warning when being caught. Defaults to
# "BaseException, Exception".
overgeneral-exceptions=BaseException,
                       Exception
overgeneral-exceptions=builtin.BaseException,
                       builtin.Exception

LICENSE

0 → 100644
+661 −0

File added.

Preview size limit exceeded, changes collapsed.

MANIFEST.in

0 → 100644
+7 −0
Original line number Diff line number Diff line
include setup.py
recursive-include uffd *.py *.html *.js *.css *.txt *.po *.cfg
graft uffd/migrations
graft uffd/templates
graft uffd/*/templates
graft uffd/static/
graft uffd/translations
+185 −9
Original line number Diff line number Diff line
# uffd
# Uffd

This is the UserFrwaltungsFrontend.
A web service to manage LDAP users, groups and permissions.
Uffd (UserFerwaltungsFrontend) is a web-based user management and single sign-on software.

Development chat: [#uffd-development](https://rocket.cccv.de/channel/uffd-development)

## Dependencies

Please note that we refer to Debian packages here and **not** pip packages.

## dependencies
- python3
- python3-ldap3
- python3-flask
- python3-flask-sqlalchemy
- python3-flask-migrate
- python3-qrcode
- python3-fido2 (version 0.5.0 or 0.9.1, optional)
- python3-prometheus-client (optional, needed for metrics)
- python3-jwt
- python3-cryptography
- python3-flask-babel
- python3-argon2
- python3-itsdangerous (also a dependency of python3-flask)
- python3-mysqldb or python3-pymysql for MariaDB support
- python3-ua-parser (optional, better user agent parsing)

Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Bookworm, Bullseye or Buster.
For development, you can also use virtualenv with the supplied `requirements.txt`.

## Development

Before running uffd, you need to create the database with `flask db upgrade`. The database is placed in
`instance/uffd.sqlit3`.

Then use `flask run` to start the application:

```
FLASK_APP=uffd flask db upgrade
FLASK_APP=uffd FLASK_ENV=development flask run
```

During development, you may want to create some example data:

```
export FLASK_APP=uffd
flask group create 'uffd_access' --description 'Access to Single-Sign-On and Selfservice'
flask group create 'uffd_admin' --description 'Admin access to uffd'
flask role create 'base' --default --add-group 'uffd_access'
flask role create 'admin' --add-group 'uffd_admin'
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'
```

Afterwards you can login as a normal user with "testuser" and "userpassword", or as an admin with "testadmin" and "adminpassword".

## Deployment

Do not use `pip install uffd` for production deployments!
The dependencies of the pip package roughly represent the versions shipped by Debian stable.
We do not keep them updated and we do not test the pip package!
The pip package only exists for local testing/development and to help build the Debian package.

We provide packages for Debian stable, oldstable and oldoldstable (currently Bookworm, Bullseye and Buster).
Since all dependencies are available in the official package mirrors, you will get security updates for everything but uffd itself from Debian.

To install uffd on Debian Bullseye, add our package mirror to `/etc/sources.list`:

```
deb https://packages.cccv.de/uffd bullseye main
```

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.

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.
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.

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`.

## Python Coding Style Conventions

PEP 8 without double new lines, tabs instead of spaces and a max line length of 160 characters.
We ship a [pylint](https://pylint.org/) config to verify changes with.

## Configuration

Uffd reads its default config from `uffd/default_config.cfg`.
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`.
You can also set a custom file path with the environment variable `CONFIG_PATH`.

## OAuth2 Single-Sign-On Provider

Other services can use uffd as an OAuth2.0-based authentication provider.
The required credentials (client_id, client_secret and redirect_uris) for these services are defined in the config.
The services need to be setup to use the following URLs with the Authorization Code Flow:

* `/oauth2/authorize`: authorization endpoint
* `/oauth2/token`: token request endpoint
* `/oauth2/userinfo`: endpoint that provides information about the current user

If the service supports server metadata discovery ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), configuring the base url of your uffd installation or `/.well-known/openid-configuration` as the discovery endpoint should be sufficient.

The only OAuth2 scope supported is `profile`. The userinfo endpoint returns json data with the following structure:

```
{
  "id": 10000,
  "name": "Test User",
  "nickname": "testuser"
  "email": "testuser@example.com",
  "groups": [
    "uffd_access",
    "users"
  ],
}
```

`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

The web frontend is initially written in English and translated in the following Languages:

![status](https://git.cccv.de/uffd/uffd/badges/master/coverage.svg?job=trans_de&key_text=DE)

## deployment
The selection uses the language browser header by default but can be overwritten via a UI element.
You can specify the available languages in the config.

Use uwsgi.
Use the `update_translations.sh` to update the translation files.

## python style conventions
## License

tabs.
GNU Affero General Public License v3.0, see [LICENSE](LICENSE).

UPGRADE.md

0 → 100644
+152 −0
Original line number Diff line number Diff line
# 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

cccv-archive-key.gpg

0 → 100644
+41 −0
Original line number Diff line number Diff line
-----BEGIN PGP PUBLIC KEY BLOCK-----

mQGNBGEXIFwBDADRhAYP8td+AVcnbMkswu3SaF1FzqVldwQSHA0tVXpAw7wUtE9s
QEnbLE3cD//SEMQGzwr8LsMpnuWImcS5nk9gIc5p9M076tgyAeS4NFzbvaIpOZJL
V0VK2Q+o6fyaAriY5lb88pU3cR6uTJInwR5MgEki7RLCIjOPW/Nzvw8LdBhgtbJv
jW04IPI1gAiqSfPCjXY8z81JOSLhsk1ED8zrJ/kTWm4yIBbVLMhFu7Snz9UbbF2n
40dA9VydoxlVdjzH+AM7+Ga8FTYu4UivGO+5WFp+iWcoXLqmECSvW+H+Evy8ES9M
7QIkgGTXWsL3YrjrxcwOAu/dXhQVV9woDXWWQRwILNG2poSLUjmVuXMPKnofJpMO
34+n3dvaiPTp31YxTWhOSXdbO3e6Abpd+PKoXqaRy/HrulBuBRf+5/edDKLNVUC/
tPqs61AL9cw6Jxx1vFdmmZm6RWK2CgVWPc9e3GPGfbZYuUBgOphhkJ+3yXRcc1sN
VRyc3Ve87OG6GiUAEQEAAbQgcGFja2FnZXMuY2Njdi5kZSA8aW5mcmFAY2Njdi5k
ZT6JAdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQRVPlzDYknN
/1ubu7WpKBpvpuSJcQUCZPjPbAUJDUdK5QAKCRCpKBpvpuSJcVuFC/45TV/8Dvt8
VTS2yoFUjpy0las7qm0fPNkazSVpMhQkxcEz/LysEr5sbc0jZIQZ1zD+rm0RfahM
g7vytTs/xqplgmIXOEPub6CPr+G1ZHgU5pHAc2DqFUR4z3pp37RNtFuhi0TyK0Pp
qVJgAg6/Hf9dkEIwI5orUTTDWhAvxz7wo7/3tb4fqkrWk/Fp0qM8kMEjYyh9/PSb
V4HfhJauXxzBx8T/Wc7TveGyRGVMYH29bK0SssDDvzGJD3Mxd/dXV4JYTk8sw//k
zQwN3lZ7SfsZR5rddRr/BpghdR1k451FdCj9iWF3v3p1TwN93AL6TQ6AF2aFykkB
1JWxockDlGrlRkk+0WiEOYvDUaBo3ppz4QhrO8TFrluGyifv2BNSFMKHdhkvF2IE
DRQles45+CmhgPxVw7qc69pLsXRxN/0BE5P6wNl8DGnk2ZYDlYW/vcosHYbeeRCp
OUpsKF6OSHXjCfMObuG6wYulFhMqrDHtLiD0e6fxWjATqoj+F6TX7Te5AY0EYRcg
XAEMAKNhLd8nN2AYPdqn/9OfTzXOFEoHMGFKVH9E9LRFEp7SXI0Phr+2gPsBEP13
In0dGbvABRvywtTRih+3Jg/5QxyEDcVB0bbWK44XZLmShm9TYmJSqrW8sgOh2Nqi
2LcGroWg2crrd6t+HDmXFZVtiBRy/5Y7s5mqTM/byEvMnReczeTSlwmJHNLTOmME
tganIwmQxfbit99gxjjoz/sGqVxf59/Ytq8P6J+3LMt9ApmPFgK6wB0BAtTJGaOJ
rgSIVdNQ082laXQlHXKMguVKk8ivErzwsCs7ukxSVhIvfwgbM7WZfdM7l6h1ZhDr
mBBGGj+9Ag0mPHF3ycrh9fW43r8KYONbzQq0xtsE+WeOKPaFhMQ/dwv6d4Sn0gTV
crV++l6ut1DLlGHCZtSsB0z1LBUu4jMvpHwVfCeqZ4f5Al27oUhjTh3eoe184+VG
/M3nkh9C1wyvLBFo69AS+9VQSwnsWu/CXnWrzPZeX0KmbezNeNvwCbYgXIrEEWhy
XJgYLQARAQABiQG8BBgBCAAmAhsMFiEEVT5cw2JJzf9bm7u1qSgab6bkiXEFAmT4
z18FCQ1HSukACgkQqSgab6bkiXFVagv+LFrGoHKm4woVvlWHWfanok/YsPyGFsvL
Ogz6U0nhRB5f3wSq9kl0t1esdyNsFGfz+E0fCzyAyML6dBzKv9uHp2+TtcdKLTQ1
kSo/JdbMsva+/e8Y9OHmmv7pAFatLln7XXwa2cPiFRg0VkOQgByR1yEiGAyMIYL8
VLAqdE6fywGLXE5k91+XZCFqKu90+XrtiJo2xy4RQ8C5u2WQWI0k5V/oGgTxOh/J
uhXzmU1Goeie4ukjZYdzwZjzzm2vY9LWfZRaRtkJ0itxNezYCtWEOKHvto5PqtT4
thSsNuC9qQruh3itVykI7lZ9yxkOyuzqjFGKQDNcUlvnZHqdoKuW121/cgMXbAvz
HWHdY4cbc74obm8V8Gx4dX/GNFL868twzMVoBoEgQVA1PURz5Xu73RvWcBpOpYj0
GP3nLdP3s2J9rAhrzS6K+MIHeEUnPi1MavRd4bROpnbJ32yvkSGWR55mWCpdCepj
JRWMzY9EoBOHB1PubZuzUNIUQeui1vyX
=uRc5
-----END PGP PUBLIC KEY BLOCK-----
 No newline at end of file
+17 −0
Original line number Diff line number Diff line
#!/bin/sh

set -eu

export FLASK_APP=/usr/share/uffd/uffd
export CONFIG_PATH=/etc/uffd/uffd.cfg

if [ "$(whoami)" = "uffd" ]; then
	flask "$@"
elif command -v sudo > /dev/null 2>&1; then
	exec sudo --preserve-env=FLASK_APP,CONFIG_PATH -u uffd flask "$@"
elif command -v runuser > /dev/null 2>&1; then
	exec runuser --preserve-environment -u uffd -- flask "$@"
else
	echo "Could not not become 'uffd' user, exiting"
	exit 255
fi
+3 −0
Original line number Diff line number Diff line
FLASK_ENV="production"
SQLALCHEMY_DATABASE_URI="sqlite:////var/lib/uffd/db.sqlite"
#SECRET_KEY=autogenerated by postinst script

debian/control

0 → 100644
+38 −0
Original line number Diff line number Diff line
Source: uffd
Section: python
Priority: optional
Maintainer: CCCV <it@cccv.de>
Build-Depends:
 debhelper-compat (= 12),
 dh-python,
 python3-all,
 python3-setuptools,
Standards-Version: 4.5.0
Homepage: https://git.cccv.de/uffd/uffd
Vcs-Git: https://git.cccv.de/uffd/uffd.git

Package: uffd
Architecture: all
Depends:
 ${misc:Depends},
# Unlike most debian python packages, we depend directly on the deb packages and do not want to populate our dependencies from the setup.py .
# Because of this we do not use the variable from pybuild.
# ${python3:Depends},
 python3-flask,
 python3-flask-sqlalchemy,
 python3-flask-migrate,
 python3-qrcode,
 python3-fido2,
 python3-jwt,
 python3-cryptography,
 python3-flask-babel,
 python3-argon2,
 python3-itsdangerous,
 uwsgi,
 uwsgi-plugin-python3,
Recommends:
 nginx,
 python3-mysqldb,
 python3-prometheus-client,
 python3-ua-parser,
Description: Web-based user management and single sign-on software
+106 −0
Original line number Diff line number Diff line
#!/usr/bin/python3
import sys
import re
import textwrap
import datetime
import email.utils

import git

package_name = 'UNKNOWN'

alias_names = {
	'julian': 'Julian Rother',
	'Julian': 'Julian Rother',
}

ignore_commit_regexes = [
	'^fixup!',
]

def print_release(tag=None, commits=tuple(), last_tag=None):
	release_version = '0.0.0'
	release_author = git.objects.util.Actor('None', 'undefined@example.com')
	release_date = 0
	release_status = 'UNRELEASED'
	message = ''

	if tag:
		release_status = 'unstable'
		release_version = tag.name[1:] # strip leading "v"
		if isinstance(tag.object, git.TagObject):
			release_author = tag.object.tagger
			release_date = tag.object.tagged_date
			message = tag.object.message.split('-----BEGIN PGP SIGNATURE-----')[0].strip()
		else:
			release_author = tag.object.committer
			release_date = tag.object.committed_date
	elif commits:
		release_author = commits[0].committer
		release_date = commits[0].committed_date
		date = datetime.datetime.fromtimestamp(release_date).strftime('%Y%m%dT%H%M%S')
		last_version = '0.0.0'
		if last_tag:
			last_version = last_tag.name[1:] # strip leading "v"
		release_version = f'{last_version}+git{date}-{commits[0].hexsha[:8]}'

	print(f'{package_name} ({release_version}) {release_status}; urgency=medium')
	print()
	if message:
		print(textwrap.indent(message, '  '))
		print()
	commit_authors = [] # list of (key, author), sorted by first commit date
	commit_author_emails = {} # author email -> key
	commit_author_names = {} # author name -> key
	commit_author_commits = {} # key -> list of commits
	for commit in commits:
		if any(filter(lambda pattern: re.match(pattern, commit.summary), ignore_commit_regexes)):
			continue
		if len(commit.parents) > 1:
			continue # Ignore merge commits
		author_name = alias_names.get(commit.author.name, commit.author.name)
		key = commit_author_emails.get(commit.author.email)
		if key is None:
			key = commit_author_names.get(author_name)
		if key is None:
			key = commit.author.email
			commit_authors.append((key, author_name))
		commit_author_emails[commit.author.email] = key
		commit_author_names[author_name] = key
		commit_author_commits[key] = commit_author_commits.get(key, []) + [commit]
	commit_authors.sort(key=lambda args: len(commit_author_commits[args[0]]))
	for key, author_name in commit_authors:
		print(f'  [ {author_name} ]')
		for commit in commit_author_commits[key]:
			lines = '\n'.join(textwrap.wrap(commit.summary, 90))
			lines = '  * ' + textwrap.indent(lines, '    ').strip()
			print(lines)
		print()
	print(f' -- {alias_names.get(release_author.name, release_author.name)} <{release_author.email}>  {email.utils.formatdate(release_date)}')

if __name__ == '__main__':
	repo = git.Repo('.')
	package_name = sys.argv[1]

	version_commits = {}
	for tag in repo.tags:
		if not re.fullmatch('v[0-9]+[.][0-9]+[.][0-9]+.*', tag.name):
			continue
		if isinstance(tag.object, git.TagObject):
			commit_hexsha = tag.object.object.hexsha
		else:
			commit_hexsha = tag.object.hexsha
		version_commits[commit_hexsha] = tag

	tag = None
	commits = []
	for commit in repo.iter_commits('HEAD'):
		if commit.hexsha in version_commits:
			prev_tag = version_commits[commit.hexsha]
			if commits:
				print_release(tag, commits, last_tag=prev_tag)
				print()
			tag = prev_tag
			commits = []
		commits.append(commit)
	print_release(tag, commits)

debian/cron.d

0 → 100644
+4 −0
Original line number Diff line number Diff line
# Cronjobs for uffd

@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

debian/dirs

0 → 100644
+2 −0
Original line number Diff line number Diff line
/etc/uffd
/var/lib/uffd

debian/install

0 → 100644
+6 −0
Original line number Diff line number Diff line
uwsgi.ini /etc/uffd/
nginx.include.conf /etc/uffd/
debian/contrib/uffd.cfg /etc/uffd/
debian/contrib/uffd-admin /usr/bin/
README.md /usr/share/doc/uffd/
UPGRADE.md /usr/share/doc/uffd/

debian/links

0 → 100644
+2 −0
Original line number Diff line number Diff line
/etc/uffd/uwsgi.ini /etc/uwsgi/apps-available/uffd.ini
/etc/uwsgi/apps-available/uffd.ini /etc/uwsgi/apps-enabled/uffd.ini

debian/postinst

0 → 100755
+37 −0
Original line number Diff line number Diff line
#!/bin/sh

set -e

case "$1" in
	configure)
		getent group uffd >/dev/null 2>&1 || addgroup --system uffd
		adduser --system --home /var/lib/uffd --quiet uffd --ingroup uffd || true

		chown -R uffd:uffd /var/lib/uffd
		chmod 0770 /var/lib/uffd

		python3 <<EOF
import secrets
cfg = open('/etc/uffd/uffd.cfg', 'r').read()
cfg = cfg.replace('\n#SECRET_KEY=autogenerated by postinst script\n',
                  '\nSECRET_KEY="'+secrets.token_hex(128)+'"\n', 1)
open('/etc/uffd/uffd.cfg', 'w').write(cfg)
EOF
		chown root:uffd /etc/uffd/uffd.cfg
		chmod 0640 /etc/uffd/uffd.cfg

		invoke-rc.d uwsgi restart uffd
	;;

	abort-upgrade|abort-remove|abort-deconfigure)
	;;

	*)
		echo "postinst called with unknown argument \`$1'" >&2
		exit 1
	;;
esac

#DEBHELPER#

exit 0

debian/postrm

0 → 100755
+20 −0
Original line number Diff line number Diff line
#!/bin/sh

set -e

case "$1" in
	purge)
		delgroup uffd || true
		userdel uffd || true
		rm -rf /var/lib/uffd
	;;
	remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
	;;
	*)
		echo "postrm called with unknown argument \`$1'" >&2
		exit 1
esac

#DEBHELPER#

exit 0

debian/rules

0 → 100755
+7 −0
Original line number Diff line number Diff line
#!/usr/bin/make -f
#export DH_VERBOSE = 1

export PYBUILD_NAME=uffd

%:
	dh $@ --with python3 --buildsystem pybuild

debian/tmpfile

0 → 100644
+2 −0
Original line number Diff line number Diff line
#Type Path         Mode UID      GID      Age Argument
d    /run/uffd     0755 uffd     uffd     -   -

nginx.include.conf

0 → 100644
+7 −0
Original line number Diff line number Diff line
location / {
	uwsgi_pass unix:///run/uwsgi/app/uffd/socket;
	include uwsgi_params;
}
location /static {
	alias /usr/share/uffd/uffd/static;
}

profiling.py

deleted100755 → 0
+0 −6
Original line number Diff line number Diff line
#!/usr/bin/python3
from werkzeug.contrib.profiler import ProfilerMiddleware
from uffd import create_app
app = create_app()
app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30])
app.run(debug=True)

pytest.ini

0 → 100644
+5 −0
Original line number Diff line number Diff line
[pytest]
filterwarnings =
	# DeprecationWarning from dependencies that we use
	ignore:`formatargspec` is deprecated since Python 3.5. Use `signature` and the `Signature` object directly:DeprecationWarning
	ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3, and in 3.10 it will stop working:DeprecationWarning

run.py

deleted100755 → 0
+0 −8
Original line number Diff line number Diff line
#!/usr/bin/env python3
from uffd import *

if __name__ == '__main__':
	app = create_app()
	init_db(app)
	print(app.url_map)
	app.run(threaded=True, debug=True)

runTests.py

deleted100755 → 0
+0 −19
Original line number Diff line number Diff line
#!/usr/bin/env python3
import unittest
import os
from src import server

def setUp():
	server.app.testing = True

def tearDown():
	os.unlink(server.app.config['SQLITE_DB'])

if __name__ == '__main__':
	setUp()
	try:
		suite = unittest.defaultTestLoader.discover('./tests/', pattern="*")
		unittest.TextTestRunner(verbosity=2, failfast=True).run(suite)
	finally:
		tearDown()

setup.py

0 → 100644
+72 −0
Original line number Diff line number Diff line
from setuptools import setup, find_packages
import os

with open('README.md', 'r', encoding='utf-8') as f:
	long_description = f.read()
	long_description = '**DO NOT INSTALL FROM PIP FOR PRODUCTION DEPLOYMENTS**, see [Deployment](#Deployment) for more information.\n\n\n\n' + long_description

setup(
	name='uffd',
	version=os.environ.get('PACKAGE_VERSION', 'local'),
	description='Web-based user management and single sign-on software',
	long_description=long_description,
	long_description_content_type='text/markdown',
	url='https://git.cccv.de/uffd/uffd',
	classifiers=[
		'Programming Language :: Python :: 3',
		'Development Status :: 5 - Production/Stable',
		'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
		'Operating System :: OS Independent',
		'Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP',
		'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
		'Environment :: Web Environment',
		'Framework :: Flask',
	],
	author='CCCV',
	author_email='it@cccv.de',
	license='AGPL3',
	packages=['uffd'],
	include_package_data=True,
	zip_safe=False,
	python_requires='>=3.7',
	install_requires=[
		# Versions Debian Buster packages are based on.
		# DO NOT USE FOR PRODUCTION, those in the setup.py are not updated regularly
		'flask==1.0.2',
		'Flask-SQLAlchemy==2.1',
		'qrcode==6.1',
		'fido2==0.5.0',
		'cryptography==2.6.1',
		'pyjwt==1.7.0',
		'Flask-Migrate==2.1.1',
		'Flask-Babel==0.11.2',
		'alembic==1.0.0',
		'argon2-cffi==18.3.0',
		'itsdangerous==0.24',
		'prometheus-client==0.9',
		'ua-parser==0.8.0',

		# The main dependencies on their own lead to version collisions and pip is
		# not very good at resolving them, so we pin the versions from Debian Buster
		# for all dependencies.
		'certifi==2018.8.24',
		#cffi==1.12.2'
		'cffi # v1.12.2 no longer works with python3.9. Newer versions seem to work fine.',
		'chardet==3.0.4',
		'click==7.0',
		'idna==2.6',
		'Jinja2==2.10',
		'MarkupSafe==1.1.0',
		'pyasn1==0.4.2',
		'pycparser==2.19',
		'requests==2.21.0',
		'requests-oauthlib==1.0.0',
		'six==1.12.0',
		'SQLAlchemy==1.2.18',
		'urllib3==1.24.1',
		'Werkzeug==0.14.1',
		'python-dateutil==2.7.3',
		#editor==1.0.3
		'Mako==1.0.7',
	],
)
+148 −0
Original line number Diff line number Diff line
from uffd.database import db
from uffd.models import User, Role, RoleGroup

from tests.utils import UffdTestCase

class TestRoleCLI(UffdTestCase):
	def setUp(self):
		super().setUp()
		role = Role(name='admin')
		db.session.add(role)
		role.groups[self.get_admin_group()] = RoleGroup(group=self.get_admin_group())
		role.members.append(self.get_admin())
		role = Role(name='base', is_default=True)
		db.session.add(role)
		role.groups[self.get_access_group()] = RoleGroup(group=self.get_access_group())
		db.session.add(Role(name='test'))
		for user in User.query:
			user.update_groups()
		db.session.commit()
		self.client.__exit__(None, None, None)

	def test_list(self):
		result = self.app.test_cli_runner().invoke(args=['role', 'list'])
		self.assertEqual(result.exit_code, 0)

	def test_show(self):
		result = self.app.test_cli_runner().invoke(args=['role', 'show', 'admin'])
		self.assertEqual(result.exit_code, 0)
		result = self.app.test_cli_runner().invoke(args=['role', 'show', 'doesnotexist'])
		self.assertEqual(result.exit_code, 1)

	def test_create(self):
		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'test']) # conflicting name
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--moderator-group', 'doesnotexist']) # invalid mod group
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--add-group', 'doesnotexist']) # invalid group
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--add-role', 'doesnotexist']) # invalid role
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--description', 'Role description',
		                                                 '--moderator-group', 'uffd_admin', '--add-group', 'users',
		                                                 '--add-role', 'admin'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			role = Role.query.filter_by(name='newrole').one()
			self.assertIsNotNone(role)
			self.assertEqual(role.description, 'Role description')
			self.assertEqual(role.moderator_group, self.get_admin_group())
			self.assertEqual(list(role.groups), [self.get_users_group()])
			self.assertEqual(role.included_roles, Role.query.filter_by(name='admin').all())
		with self.app.test_request_context():
			for user in User.query:
				self.assertNotIn(self.get_users_group(), user.groups)
		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newbase', '--add-group', 'users', '--default'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			for user in User.query:
				self.assertIn(self.get_users_group(), user.groups)

	def test_update(self):
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'doesnotexist', '--description', 'New description'])
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--add-group', 'doesnotexist'])
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--remove-group', 'doesnotexist'])
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--add-role', 'doesnotexist'])
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--remove-role', 'doesnotexist'])
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--moderator-group', 'doesnotexist'])
		self.assertEqual(result.exit_code, 1)
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--description', 'New description',
		                                                 '--moderator-group', 'uffd_admin', '--add-group', 'users',
		                                                 '--remove-group', 'uffd_access', '--add-role', 'admin'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			role = Role.query.filter_by(name='base').first()
			self.assertIsNotNone(role)
			self.assertEqual(role.description, 'New description')
			self.assertEqual(role.moderator_group, self.get_admin_group())
			self.assertEqual(list(role.groups), [self.get_users_group()])
			self.assertEqual(role.included_roles, Role.query.filter_by(name='admin').all())
			self.assertEqual(set(self.get_user().groups), {self.get_users_group(), self.get_admin_group()})
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--no-moderator-group', '--clear-groups',
		                                                 '--add-group', 'uffd_access', '--remove-role', 'admin',
		                                                 '--add-role', 'test'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			role = Role.query.filter_by(name='base').first()
			self.assertIsNone(role.moderator_group)
			self.assertEqual(list(role.groups), [self.get_access_group()])
			self.assertEqual(role.included_roles, Role.query.filter_by(name='test').all())
			self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--clear-roles'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			role = Role.query.filter_by(name='base').first()
			self.assertEqual(role.included_roles, [])
			self.assertEqual(role.is_default, True)
			self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--no-default'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			role = Role.query.filter_by(name='base').first()
			self.assertEqual(role.is_default, False)
			self.assertEqual(set(self.get_user().groups), set())
		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--default'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			role = Role.query.filter_by(name='base').first()
			self.assertEqual(role.is_default, True)
			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):
		with self.app.test_request_context():
			self.assertIsNotNone(Role.query.filter_by(name='test').first())
		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'test'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			self.assertIsNone(Role.query.filter_by(name='test').first())
		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'doesnotexist'])
		self.assertEqual(result.exit_code, 1)
		with self.app.test_request_context():
			self.assertIn(self.get_admin_group(), self.get_admin().groups)
		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'admin'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			self.assertNotIn(self.get_admin_group(), self.get_admin().groups)
		with self.app.test_request_context():
			self.assertIn(self.get_access_group(), self.get_user().groups)
		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'base'])
		self.assertEqual(result.exit_code, 0)
		with self.app.test_request_context():
			self.assertNotIn(self.get_access_group(), self.get_user().groups)
Original line number Diff line number Diff line
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)
+188 −0
Original line number Diff line number Diff line
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)
+81 −0
Original line number Diff line number Diff line
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')
+16 −0
Original line number Diff line number Diff line
from uffd.database import db
from uffd.models.misc import lock_table, Lock

from tests.utils import MigrationTestCase

class TestForMissingLockRows(MigrationTestCase):
	def test_check_missing_lock_rows(self):
		self.upgrade('head')
		existing_locks = {row[0] for row in db.session.execute(db.select([lock_table.c.name])).fetchall()}
		for name in Lock.ALL_LOCKS - existing_locks:
			self.fail(f'Lock "{name}" is missing. Make sure to add a migration that inserts it.')

# Add something like this:
#  conn = op.get_bind()
#  lock_table = sa.table('lock', sa.column('name'))
#  conn.execute(sa.insert(lock_table).values(name='NAME'))
Original line number Diff line number Diff line
from uffd.database import db
from uffd.models.misc import lock_table, Lock

from tests.utils import MigrationTestCase

user_table = db.table('user',
	db.column('id'),
	db.column('unix_uid'),
	db.column('loginname'),
	db.column('displayname'),
	db.column('primary_email_id'),
	db.column('is_service_user'),
)

user_email_table = db.table('user_email',
	db.column('id'),
	db.column('address'),
	db.column('address_normalized'),
	db.column('verified'),
)

group_table = db.table('group',
	db.column('id'),
	db.column('unix_gid'),
	db.column('name'),
	db.column('description')
)

uid_allocation_table = db.table('uid_allocation', db.column('id'))
gid_allocation_table = db.table('gid_allocation', db.column('id'))

class TestMigration(MigrationTestCase):
	REVISION = 'aeb07202a6c8'

	def setUpApp(self):
		self.app.config['USER_MIN_UID'] = 10000
		self.app.config['USER_MAX_UID'] = 10005
		self.app.config['USER_SERVICE_MIN_UID'] = 10006
		self.app.config['USER_SERVICE_MAX_UID'] = 10010
		self.app.config['GROUP_MIN_GID'] = 20000
		self.app.config['GROUP_MAX_GID'] = 20005

	def create_user(self, uid):
		db.session.execute(db.insert(user_email_table).values(
			address=f'email{uid}@example.com',
			address_normalized=f'email{uid}@example.com',
			verified=True
		))
		email_id = db.session.execute(
			db.select([user_email_table.c.id])
			.where(user_email_table.c.address == f'email{uid}@example.com')
		).scalar()
		db.session.execute(db.insert(user_table).values(
			unix_uid=uid,
			loginname=f'user{uid}',
			displayname='user',
			primary_email_id=email_id,
			is_service_user=False
		))

	def create_group(self, gid):
		db.session.execute(db.insert(group_table).values(unix_gid=gid, name=f'group{gid}', description=''))

	def fetch_uid_allocations(self):
		return [row[0] for row in db.session.execute(
			db.select([uid_allocation_table])
			.order_by(uid_allocation_table.c.id)
		).fetchall()]

	def fetch_gid_allocations(self):
		return [row[0] for row in db.session.execute(
			db.select([gid_allocation_table])
			.order_by(gid_allocation_table.c.id)
		).fetchall()]

	def test_empty(self):
		# No users/groups
		self.upgrade()
		self.assertEqual(self.fetch_uid_allocations(), [])
		self.assertEqual(self.fetch_gid_allocations(), [])

	def test_gid_first_minus_one(self):
		self.create_group(19999)
		self.upgrade()
		self.assertEqual(self.fetch_gid_allocations(), [19999])

	def test_gid_first(self):
		self.create_group(20000)
		self.upgrade()
		self.assertEqual(self.fetch_gid_allocations(), [20000])

	def test_gid_first_plus_one(self):
		self.create_group(20001)
		self.upgrade()
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001])

	def test_gid_last_minus_one(self):
		self.create_group(20004)
		self.upgrade()
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004])

	def test_gid_last(self):
		self.create_group(20005)
		self.upgrade()
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004, 20005])

	def test_gid_last_plus_one(self):
		self.create_group(20006)
		self.upgrade()
		self.assertEqual(self.fetch_gid_allocations(), [20006])

	def test_gid_complex(self):
		self.create_group(10)
		self.create_group(20001)
		self.create_group(20003)
		self.create_group(20010)
		self.upgrade()
		self.assertEqual(self.fetch_gid_allocations(), [10, 20000, 20001, 20002, 20003, 20010])

	# The code for UIDs is mostly the same as for GIDs, so we don't test all
	# the edge cases again.
	def test_uid_different_ranges(self):
		self.create_user(10)
		self.create_user(10000)
		self.create_user(10002)
		self.create_user(10007)
		self.create_user(10009)
		self.create_user(90000)
		self.upgrade()
		self.assertEqual(self.fetch_uid_allocations(), [10, 10000, 10001, 10002, 10006, 10007, 10008, 10009, 90000])

	def test_uid_same_ranges(self):
		self.app.config['USER_MIN_UID'] = 10000
		self.app.config['USER_MAX_UID'] = 10010
		self.app.config['USER_SERVICE_MIN_UID'] = 10000
		self.app.config['USER_SERVICE_MAX_UID'] = 10010
		self.create_user(10)
		self.create_user(10000)
		self.create_user(10002)
		self.create_user(10007)
		self.create_user(10009)
		self.create_user(90000)
		self.upgrade()
		self.assertEqual(self.fetch_uid_allocations(), [10, 10000, 10001, 10002, 10003, 10004, 10005, 10006, 10007, 10008, 10009, 90000])
+269 −0
Original line number Diff line number Diff line
import datetime

from flask import current_app

from uffd.database import db
from uffd.models import Invite, InviteGrant, InviteSignup, User, Role, RoleGroup

from tests.utils import UffdTestCase, db_flush

class TestInviteModel(UffdTestCase):
	def test_expire(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
		self.assertFalse(invite.expired)
		self.assertTrue(invite.active)
		invite.valid_until = datetime.datetime.utcnow() - datetime.timedelta(seconds=60)
		self.assertTrue(invite.expired)
		self.assertFalse(invite.active)

	def test_void(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=False, creator=self.get_admin())
		self.assertFalse(invite.voided)
		self.assertTrue(invite.active)
		invite.used = True
		self.assertFalse(invite.voided)
		self.assertTrue(invite.active)
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=True, creator=self.get_admin())
		self.assertFalse(invite.voided)
		self.assertTrue(invite.active)
		invite.used = True
		self.assertTrue(invite.voided)
		self.assertFalse(invite.active)

	def test_permitted(self):
		role = Role(name='testrole')
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, roles=[role])
		self.assertFalse(invite.permitted)
		self.assertFalse(invite.active)
		invite.creator = self.get_admin()
		self.assertTrue(invite.permitted)
		self.assertTrue(invite.active)
		invite.creator.is_deactivated = True
		self.assertFalse(invite.permitted)
		self.assertFalse(invite.active)
		invite.creator = self.get_user()
		self.assertFalse(invite.permitted)
		self.assertFalse(invite.active)
		role.moderator_group = self.get_access_group()
		current_app.config['ACL_SIGNUP_GROUP'] = 'uffd_access'
		self.assertTrue(invite.permitted)
		self.assertTrue(invite.active)
		role.moderator_group = None
		self.assertFalse(invite.permitted)
		self.assertFalse(invite.active)
		role.moderator_group = self.get_access_group()
		current_app.config['ACL_SIGNUP_GROUP'] = 'uffd_admin'
		self.assertFalse(invite.permitted)
		self.assertFalse(invite.active)

	def test_disable(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
		self.assertTrue(invite.active)
		invite.disable()
		self.assertFalse(invite.active)

	def test_reset_disabled(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
		invite.disable()
		self.assertFalse(invite.active)
		invite.reset()
		self.assertTrue(invite.active)

	def test_reset_expired(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() - datetime.timedelta(seconds=60), creator=self.get_admin())
		self.assertFalse(invite.active)
		invite.reset()
		self.assertFalse(invite.active)

	def test_reset_single_use(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=False, creator=self.get_admin())
		invite.used = True
		invite.disable()
		self.assertFalse(invite.active)
		invite.reset()
		self.assertTrue(invite.active)

	def test_short_token(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
		db.session.add(invite)
		db.session.commit()
		self.assertTrue(len(invite.short_token) <= len(invite.token)/3)

class TestInviteGrantModel(UffdTestCase):
	def test_success(self):
		user = self.get_user()
		group0 = self.get_access_group()
		role0 = Role(name='baserole', groups={group0: RoleGroup(group=group0)})
		db.session.add(role0)
		user.roles.append(role0)
		user.update_groups()
		group1 = self.get_admin_group()
		role1 = Role(name='testrole1', groups={group1: RoleGroup(group=group1)})
		db.session.add(role1)
		role2 = Role(name='testrole2')
		db.session.add(role2)
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role1, role2], creator=self.get_admin())
		self.assertIn(role0, user.roles)
		self.assertNotIn(role1, user.roles)
		self.assertNotIn(role2, user.roles)
		self.assertIn(group0, user.groups)
		self.assertNotIn(group1, user.groups)
		self.assertFalse(invite.used)
		grant = InviteGrant(invite=invite, user=user)
		success, msg = grant.apply()
		self.assertTrue(success)
		self.assertIn(role0, user.roles)
		self.assertIn(role1, user.roles)
		self.assertIn(role2, user.roles)
		self.assertIn(group0, user.groups)
		self.assertIn(group1, user.groups)
		self.assertTrue(invite.used)
		db.session.commit()
		db_flush()
		user = self.get_user()
		self.assertIn('baserole', [role.name for role in user.roles_effective])
		self.assertIn('testrole1', [role.name for role in user.roles])
		self.assertIn('testrole2', [role.name for role in user.roles])
		self.assertIn(self.get_access_group(), user.groups)
		self.assertIn(self.get_admin_group(), user.groups)

	def test_inactive(self):
		user = self.get_user()
		group = self.get_admin_group()
		role = Role(name='testrole1', groups={group: RoleGroup(group=group)})
		db.session.add(role)
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role], single_use=True, used=True, creator=self.get_admin())
		self.assertFalse(invite.active)
		grant = InviteGrant(invite=invite, user=user)
		success, msg = grant.apply()
		self.assertFalse(success)
		self.assertIsInstance(msg, str)
		self.assertNotIn(role, user.roles)
		self.assertNotIn(group, user.groups)

	def test_no_roles(self):
		user = self.get_user()
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
		self.assertTrue(invite.active)
		grant = InviteGrant(invite=invite, user=user)
		success, msg = grant.apply()
		self.assertFalse(success)
		self.assertIsInstance(msg, str)

	def test_no_new_roles(self):
		user = self.get_user()
		role = Role(name='testrole1')
		db.session.add(role)
		user.roles.append(role)
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role], creator=self.get_admin())
		self.assertTrue(invite.active)
		grant = InviteGrant(invite=invite, user=user)
		success, msg = grant.apply()
		self.assertFalse(success)
		self.assertIsInstance(msg, str)

class TestInviteSignupModel(UffdTestCase):
	def create_base_roles(self):
		baserole = Role(name='base', is_default=True)
		baserole.groups[self.get_access_group()] = RoleGroup()
		baserole.groups[self.get_users_group()] = RoleGroup()
		db.session.add(baserole)
		db.session.commit()

	def test_success(self):
		self.create_base_roles()
		base_role = Role.query.filter_by(name='base').one()
		base_group1 = self.get_access_group()
		base_group2 = self.get_users_group()
		group = self.get_admin_group()
		role1 = Role(name='testrole1', groups={group: RoleGroup(group=group)})
		db.session.add(role1)
		role2 = Role(name='testrole2')
		db.session.add(role2)
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role1, role2], allow_signup=True, creator=self.get_admin())
		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
		self.assertFalse(invite.used)
		valid, msg = signup.validate()
		self.assertTrue(valid)
		self.assertFalse(invite.used)
		user, msg = signup.finish('notsecret')
		self.assertIsInstance(user, User)
		self.assertTrue(invite.used)
		self.assertEqual(user.loginname, 'newuser')
		self.assertEqual(user.displayname, 'New User')
		self.assertEqual(user.primary_email.address, 'test@example.com')
		self.assertEqual(signup.user, user)
		self.assertIn(base_role, user.roles_effective)
		self.assertIn(role1, user.roles)
		self.assertIn(role2, user.roles)
		self.assertIn(base_group1, user.groups)
		self.assertIn(base_group2, user.groups)
		self.assertIn(group, user.groups)
		db.session.commit()
		db_flush()
		self.assertEqual(len(User.query.filter_by(loginname='newuser').all()), 1)

	def test_success_no_roles(self):
		self.create_base_roles()
		base_role = Role.query.filter_by(name='base').one()
		base_group1 = self.get_access_group()
		base_group2 = self.get_users_group()
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
		self.assertFalse(invite.used)
		valid, msg = signup.validate()
		self.assertTrue(valid)
		self.assertFalse(invite.used)
		user, msg = signup.finish('notsecret')
		self.assertIsInstance(user, User)
		self.assertTrue(invite.used)
		self.assertEqual(user.loginname, 'newuser')
		self.assertEqual(user.displayname, 'New User')
		self.assertEqual(user.primary_email.address, 'test@example.com')
		self.assertEqual(signup.user, user)
		self.assertIn(base_role, user.roles_effective)
		self.assertEqual(len(user.roles_effective), 1)
		self.assertIn(base_group1, user.groups)
		self.assertIn(base_group2, user.groups)
		self.assertEqual(len(user.groups), 2)
		db.session.commit()
		db_flush()
		self.assertEqual(len(User.query.filter_by(loginname='newuser').all()), 1)

	def test_inactive(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, single_use=True, used=True, creator=self.get_admin())
		self.assertFalse(invite.active)
		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
		valid, msg = signup.validate()
		self.assertFalse(valid)
		self.assertIsInstance(msg, str)
		user, msg = signup.finish('notsecret')
		self.assertIsNone(user)
		self.assertIsInstance(msg, str)

	def test_invalid(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
		self.assertTrue(invite.active)
		signup = InviteSignup(invite=invite, loginname='', displayname='New User', mail='test@example.com', password='notsecret')
		valid, msg = signup.validate()
		self.assertFalse(valid)
		self.assertIsInstance(msg, str)

	def test_invalid2(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
		self.assertTrue(invite.active)
		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
		user, msg = signup.finish('wrongpassword')
		self.assertIsNone(user)
		self.assertIsInstance(msg, str)

	def test_no_signup(self):
		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=False, creator=self.get_admin())
		self.assertTrue(invite.active)
		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
		valid, msg = signup.validate()
		self.assertFalse(valid)
		self.assertIsInstance(msg, str)
		user, msg = signup.finish('notsecret')
		self.assertIsNone(user)
		self.assertIsInstance(msg, str)
+108 −0
Original line number Diff line number Diff line
import unittest
import datetime
import time

from uffd.database import db
from uffd.models import RecoveryCodeMethod, TOTPMethod, WebauthnMethod
from uffd.models.mfa import _hotp

from tests.utils import UffdTestCase

class TestMfaPrimitives(unittest.TestCase):
	def test_hotp(self):
		self.assertEqual(_hotp(5555555, b'\xae\xa3T\x05\x89\xd6\xb76\xf61r\x92\xcc\xb5WZ\xe6)\x05q'), '458290')
		self.assertEqual(_hotp(5555555, b'\xae\xa3T\x05\x89\xd6\xb76\xf61r\x92\xcc\xb5WZ\xe6)\x05q', digits=8), '20458290')
		for digits in range(1, 10):
			self.assertEqual(len(_hotp(1, b'abcd', digits=digits)), digits)
		self.assertEqual(_hotp(1234, b''), '161024')
		self.assertEqual(_hotp(0, b'\x04\x8fM\xcc\x7f\x82\x9c$a\x1b\xb3'), '279354')
		self.assertEqual(_hotp(2**64-1, b'abcde'), '899292')

def get_fido2_test_cred(self):
	try:
		from uffd.fido2_compat import AttestedCredentialData
	except ImportError:
		self.skipTest('fido2 could not be imported')
	# Example public key from webauthn spec 6.5.1.1
	return AttestedCredentialData(bytes.fromhex('00000000000000000000000000000000'+'0040'+'053cbcc9d37a61d3bac87cdcc77ee326256def08ab15775d3a720332e4101d14fae95aeee3bc9698781812e143c0597dc6e180595683d501891e9dd030454c0a'+'A501020326200121582065eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d2258201e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c'))

class TestMfaMethodModels(UffdTestCase):
	def test_common_attributes(self):
		method = TOTPMethod(user=self.get_user(), name='testname')
		self.assertTrue(method.created <= datetime.datetime.utcnow())
		self.assertEqual(method.name, 'testname')
		self.assertEqual(method.user.loginname, 'testuser')
		method.user = self.get_admin()
		self.assertEqual(method.user.loginname, 'testadmin')

	def test_recovery_code_method(self):
		method = RecoveryCodeMethod(user=self.get_user())
		db.session.add(method)
		db.session.commit()
		method_id = method.id
		method_code = method.code_value
		db.session.expunge(method)
		method = RecoveryCodeMethod.query.get(method_id)
		self.assertFalse(hasattr(method, 'code_value'))
		self.assertFalse(method.verify(''))
		self.assertFalse(method.verify('A'*8))
		self.assertTrue(method.verify(method_code))

	def test_totp_method_attributes(self):
		method = TOTPMethod(user=self.get_user(), name='testname')
		raw_key = method.raw_key
		issuer = method.issuer
		accountname = method.accountname
		key_uri = method.key_uri
		self.assertEqual(method.name, 'testname')
		# Restore method with key parameter
		_method = TOTPMethod(user=self.get_user(), key=method.key, name='testname')
		self.assertEqual(_method.name, 'testname')
		self.assertEqual(_method.raw_key, raw_key)
		self.assertEqual(_method.issuer, issuer)
		self.assertEqual(_method.accountname, accountname)
		self.assertEqual(_method.key_uri, key_uri)
		db.session.add(method)
		db.session.commit()
		_method_id = _method.id
		db.session.expunge(_method)
		# Restore method from db
		_method = TOTPMethod.query.get(_method_id)
		self.assertEqual(_method.name, 'testname')
		self.assertEqual(_method.raw_key, raw_key)
		self.assertEqual(_method.issuer, issuer)
		self.assertEqual(_method.accountname, accountname)
		self.assertEqual(_method.key_uri, key_uri)

	def test_totp_method_verify(self):
		method = TOTPMethod(user=self.get_user())
		counter = int(time.time()/30)
		self.assertFalse(method.verify(''))
		self.assertFalse(method.verify(_hotp(counter-2, method.raw_key)))
		self.assertTrue(method.verify(_hotp(counter, method.raw_key)))
		self.assertFalse(method.verify(_hotp(counter+2, method.raw_key)))

	def test_totp_method_verify_reuse(self):
		method = TOTPMethod(user=self.get_user())
		counter = int(time.time()/30)
		self.assertFalse(method.verify(_hotp(counter-2, method.raw_key)))
		self.assertTrue(method.verify(_hotp(counter-1, method.raw_key)))
		self.assertTrue(method.verify(_hotp(counter, method.raw_key)))
		self.assertFalse(method.verify(_hotp(counter-1, method.raw_key)))
		self.assertFalse(method.verify(_hotp(counter, method.raw_key)))

	def test_webauthn_method(self):
		data = get_fido2_test_cred(self)
		method = WebauthnMethod(user=self.get_user(), cred=data, name='testname')
		self.assertEqual(method.name, 'testname')
		db.session.add(method)
		db.session.commit()
		method_id = method.id
		method_cred = method.cred
		db.session.expunge(method)
		_method = WebauthnMethod.query.get(method_id)
		self.assertEqual(_method.name, 'testname')
		self.assertEqual(bytes(method_cred), bytes(_method.cred))
		self.assertEqual(data.credential_id, _method.cred.credential_id)
		self.assertEqual(data.public_key, _method.cred.public_key)
		# We only test (de-)serialization here, as everything else is currently implemented in the views
+84 −0
Original line number Diff line number Diff line
import time
import threading

from sqlalchemy.exc import IntegrityError

from uffd.database import db
from uffd.models import FeatureFlag, Lock
from uffd.models.misc import feature_flag_table

from tests.utils import ModelTestCase

class TestFeatureFlag(ModelTestCase):
	def test_disabled(self):
		flag = FeatureFlag('foo')
		self.assertFalse(flag)
		self.assertFalse(db.session.execute(db.select([flag.expr])).scalar())

	def test_enabled(self):
		db.session.execute(db.insert(feature_flag_table).values(name='foo'))
		flag = FeatureFlag('foo')
		self.assertTrue(flag)
		self.assertTrue(db.session.execute(db.select([flag.expr])).scalar())

	def test_toggle(self):
		flag = FeatureFlag('foo')
		hooks_called = []

		@flag.enable_hook
		def enable_hook1():
			hooks_called.append('enable1')

		@flag.enable_hook
		def enable_hook2():
			hooks_called.append('enable2')

		@flag.disable_hook
		def disable_hook1():
			hooks_called.append('disable1')

		@flag.disable_hook
		def disable_hook2():
			hooks_called.append('disable2')

		hooks_called.clear()
		flag.enable()
		self.assertTrue(flag)
		self.assertEqual(hooks_called, ['enable1', 'enable2'])
		hooks_called.clear()
		flag.disable()
		self.assertFalse(flag)
		self.assertEqual(hooks_called, ['disable1', 'disable2'])
		flag.disable() # does nothing
		self.assertFalse(flag)
		flag.enable()
		self.assertTrue(flag)
		with self.assertRaises(IntegrityError):
			flag.enable()
		self.assertTrue(flag)

class TestLock(ModelTestCase):
	DISABLE_SQLITE_MEMORY_DB = True

	def setUpApp(self):
		self.lock = Lock('testlock')

	def run_lock_test(self):
		result = []
		def func():
			with self.app.test_request_context():
				self.lock.acquire()
				result.append('bar')
		t = threading.Thread(target=func)
		t.start()
		time.sleep(1)
		result.append('foo')
		time.sleep(1)
		db.session.rollback()
		t.join()
		return result

	def test_lock2(self):
		self.assertEqual(self.run_lock_test(), ['bar', 'foo'])
		self.lock.acquire()
		self.assertEqual(self.run_lock_test(), ['foo', 'bar'])
+159 −0
Original line number Diff line number Diff line
import unittest
import datetime

import jwt

from uffd.database import db
from uffd.models import OAuth2Key

from tests.utils import UffdTestCase

TEST_JWK = dict(
	id='HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU',
	created=datetime.datetime(2023, 11, 9, 0, 21, 10),
	active=True,
	algorithm='RS256',
	private_key_jwk='''{
		"kty": "RSA",
		"key_ops": ["sign"],
		"n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H",
		"e": "AQAB",
		"d": "G7yoH5mLcZTA6ia-byCoN-zpofGvdga9AZnxPO0vsq6K_cY_O2gxuVZ3n6reAKKbuLNGCbb_D_Dffs4q8rprlfkgi3TCLzXX5Zv5HWTD7a4Y7xpxEzQ2sWo-iagVIqZVPh0pyjliqnTyUWnFmWiY0gBe9UHianHjFVZqe8E2HFOKgW3UUbQz0keg8JtJ3T9gzZrM38KWbqhOJO0VVSRAoANPTSnumfRsUCyWywrMtIfgAbQaKazqX3xkOsAF1L-iNfd6slzPvRyIQVflVDMdfKnsu-lHiKJ0DK_lg9f55T5FymgcXsq43EKBQ2H4v2dafIm-vtWx_TRZWj_msD32BEPBA-zTqh_oP1r6a3DZh4DBtWY3vzSiuhAC0erlRs-hRTX_e9ET5fUbJnmNxjnxQD9zZmwq4ujMK6KFnHct8t77Qxj3a-wDR_XyDJ4_EKYqHlcVHfxGNBSvIdjuZJkPJnVpVtfCtpyamQIR4u5oNV7fIwYe_tFnw0Y90rGoJMzB",
		"p": "-A-FnH21HJ7GPWUm9k3mxsxSchy89QEUCZZiH6EcB4ZP8wJsxrQsUSIHCR74YmZEI3Ulsum1Ql4x50k7Q2sNh9SnwKvmctjksehGy4yCrdunAqjqyz3wFwGaKWnhn3frkiqH5ATjkOoc8qHz8saa7reeVClj47ZWyy-Nl559ycLMs0rI1N_THzO07C3jSbJhyPj0yeygAflsRqqnNvEQ6ps1VLiqf9G5jfSvUUn5DyKIpep9iGo29caGSIPIy_2h",
		"q": "xNe1-QWskxOcY_GiHpFWdvzqr1o9fxg5whgpNcGi3caokw2iNHRYut4cbVvFFBlv_9B5QCl9WVfR2ADG0AtvkvUxEZqCdxEvcqjIANeRLKHDjW5kMuPS0_fcskFP-r7mCM9SBfPplfMVCF5nuNWf5LzNopWfsTChIDD1rSpPjItNYuwLXszm_3R81HHHeQLcyvoMxLCmeLy5TXX2hXOMHh2IMZCXAHopJmLJUVnQ48kr5jd2l0kLbmx3aBqdccJn",
		"dp": "MLS7g1KbcRcrzXpDADGjkn0j4wwJfgHMMWW5toQnwMJ6iDh9qzZNTVDlGMFf-9IgpuWllU-WK4XbPpJ-dGpcqcLzfT1DbmFv5g65d9YLAqASVs9b6rQqpBnIb0E-79TYCEcZj4f2NsoBDRMHly-v1BdxmwzVdCylNhgMMS0Jfcgl8T5J2KJqDcJVT9piumGwGYnoZo1zjW-v9uAjHQKQU8BN5Git8ZL4YAsfMVLY-EPLmOhF5bcVO4TTcQGPN56B",
		"dq": "HiiSl-G3rB0QE_v8g8Ruw_JCHrWrwGI8zzEWd0cApgv-3fDzzieZRKAtKNArpMW09DPDsAHrU5nx669KxqtJ3_EzIGhU3ttCMsYLRp3Af18VcADe1zEypwlNxf3dvCQtaGIjRgg13KSOr2aPa7FHOyt2MhfMjMBPn3gA3BQkdfsN0z8pCtBIABGf4ojAMBkxLOQcurH5_3uixGxzZcTrTd3mdPmbORZ-YYQ3JgCl0ZCL6kzLHaiyWKvDq66QOtK3",
		"qi": "ySqD9cUxbq3wkCsPQId_YfQLIqb5RK_JJIMjtBOdTdo4aT5tmodYCSmjBmhrYXjDWtyJdelvPfdSfgncHJhf8VgkZ8TPvUeaQwsQFBwB5llwpdb72eEEJrmG1SVwNMoFCLXdNT3ACad16cUDMnWmklH0X07OzdxGOBnGhgLZUs4RbPjLH7OpYTyQqVy2L8vofqJR42cfePZw8WQM4k0PPbhralhybExIkSCmaQyYbACZ5k0OVQErEqnj4elglA0h"
	}''',
	public_key_jwk='''{
		"kty": "RSA",
		"key_ops": ["verify"],
		"n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H",
		"e": "AQAB"
	}''',
)

class TestOAuth2Key(UffdTestCase):
	def setUp(self):
		super().setUp()
		db.session.add(OAuth2Key(**TEST_JWK))
		db.session.add(OAuth2Key(
			id='1e9gdk7',
			created=datetime.datetime(2014, 11, 8, 0, 0, 0),
			active=True,
			algorithm='RS256',
			private_key_jwk='invalid',
			public_key_jwk='''{
				"kty":"RSA",
				"n":"w7Zdfmece8iaB0kiTY8pCtiBtzbptJmP28nSWwtdjRu0f2GFpajvWE4VhfJAjEsOcwYzay7XGN0b-X84BfC8hmCTOj2b2eHT7NsZegFPKRUQzJ9wW8ipn_aDJWMGDuB1XyqT1E7DYqjUCEOD1b4FLpy_xPn6oV_TYOfQ9fZdbE5HGxJUzekuGcOKqOQ8M7wfYHhHHLxGpQVgL0apWuP2gDDOdTtpuld4D2LK1MZK99s9gaSjRHE8JDb1Z4IGhEcEyzkxswVdPndUWzfvWBBWXWxtSUvQGBRkuy1BHOa4sP6FKjWEeeF7gm7UMs2Nm2QUgNZw6xvEDGaLk4KASdIxRQ",
				"e":"AQAB"
			}'''
		))
		db.session.commit()
		self.key = OAuth2Key.query.get('HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU')
		self.key_oidc_spec = OAuth2Key.query.get('1e9gdk7')

	def test_private_key(self):
		self.key.private_key

	def test_public_key(self):
		self.key.private_key

	def test_public_key_jwks_dict(self):
		self.assertEqual(self.key.public_key_jwks_dict, {
				"kid": "HvOn74G7njK1GoFNe8Dta087casdWMsm06pNhOXRgJU",
				"kty": "RSA",
				"alg": "RS256",
				"use": "sig",
				"n": "vrznqUy8Xamph6s0Z02fFMIyjwLAMio35i9DXYjXP1ZQwSZ3SsIh3m2ablMnlu8PVlnYUzoj8rXyAWND0FSfWoQQxv1rq15pllKueddLoJsv321N_NRB8beGsLrsndw8QO0q3RWqV9O3kqhlTMjgj6bquX42wLaXrPLJyfbT3zObBsToG4UxpOyly84aklJXU5wIs0cbmjbfd8Xld38BG8Oh7Ozy5b93vPpJW6rudZRxU6QYC0r9bFFLIHJWrR4bzQMLGoJ63xjPOCl4WNpOYc9B7PNgnWTLXlFd51Hw9CaT2MRWsKNCSU77f6nZkfjWa1IsQdF0I48m46qgq7bEOOl9DbThbCnpblWrctdyg6du-OvCyVmkAo1KGtANl0027pgqUI_9HBMi33y3UPQm1ALHXIyIDBZtExH3lD6MMK3XGJfUxZuIOBndK-PXm5Fed52bgLOcf-24X6aHFn-8oyDVIj9OHkKWjy7jtKdmqZc4pBdVuCaMCYzj8iERWA3H",
				"e": "AQAB"
		})

	def test_encode_jwt(self):
		jwtdata = self.key.encode_jwt({'aud': 'test', 'foo': 'bar'})
		self.assertIsInstance(jwtdata, str) # Regression check for #165
		self.assertEqual(
			jwt.get_unverified_header(jwtdata),
			# typ is optional, x5u/x5c/jku/jwk are discoraged by OIDC Core 1.0 spec section 2
			{'kid': self.key.id, 'alg': self.key.algorithm, 'typ': 'JWT'}
		)
		self.assertEqual(
      OAuth2Key.decode_jwt(jwtdata, audience='test'),
			{'aud': 'test', 'foo': 'bar'}
		)
		self.key.active = False
		with self.assertRaises(jwt.exceptions.InvalidKeyError):
			self.key.encode_jwt({'aud': 'test', 'foo': 'bar'})

	def test_oidc_hash(self):
		# Example from OIDC Core 1.0 spec A.3
		self.assertEqual(
			self.key.oidc_hash(b'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y'),
			'77QmUPtjPfzWtF2AnpK9RQ'
		)
		# Example from OIDC Core 1.0 spec A.4
		self.assertEqual(
			self.key.oidc_hash(b'Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk'),
			'LDktKdoQak3Pk0cnXxCltA'
		)
		# Example from OIDC Core 1.0 spec A.6
		self.assertEqual(
			self.key.oidc_hash(b'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y'),
			'77QmUPtjPfzWtF2AnpK9RQ'
		)
		self.assertEqual(
			self.key.oidc_hash(b'Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk'),
			'LDktKdoQak3Pk0cnXxCltA'
		)

	def test_decode_jwt(self):
		# Example from OIDC Core 1.0 spec A.2
		jwt_data = (
			'eyJraWQiOiIxZTlnZGs3IiwiYWxnIjoiUlMyNTYifQ.ewogImlz'
			'cyI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4'
			'Mjg5NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAi'
			'bi0wUzZfV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEz'
			'MTEyODA5NzAsCiAibmFtZSI6ICJKYW5lIERvZSIsCiAiZ2l2ZW5fbmFtZSI6'
			'ICJKYW5lIiwKICJmYW1pbHlfbmFtZSI6ICJEb2UiLAogImdlbmRlciI6ICJm'
			'ZW1hbGUiLAogImJpcnRoZGF0ZSI6ICIwMDAwLTEwLTMxIiwKICJlbWFpbCI6'
			'ICJqYW5lZG9lQGV4YW1wbGUuY29tIiwKICJwaWN0dXJlIjogImh0dHA6Ly9l'
			'eGFtcGxlLmNvbS9qYW5lZG9lL21lLmpwZyIKfQ.rHQjEmBqn9Jre0OLykYNn'
			'spA10Qql2rvx4FsD00jwlB0Sym4NzpgvPKsDjn_wMkHxcp6CilPcoKrWHcip'
			'R2iAjzLvDNAReF97zoJqq880ZD1bwY82JDauCXELVR9O6_B0w3K-E7yM2mac'
			'AAgNCUwtik6SjoSUZRcf-O5lygIyLENx882p6MtmwaL1hd6qn5RZOQ0TLrOY'
			'u0532g9Exxcm-ChymrB4xLykpDj3lUivJt63eEGGN6DH5K6o33TcxkIjNrCD'
			'4XB1CKKumZvCedgHHF3IAK4dVEDSUoGlH9z4pP_eWYNXvqQOjGs-rDaQzUHl'
			'6cQQWNiDpWOl_lxXjQEvQ'
		)
		self.assertEqual(
			OAuth2Key.decode_jwt(jwt_data, options={'verify_exp': False, 'verify_aud': False}),
			{
				"iss": "http://server.example.com",
				"sub": "248289761001",
				"aud": "s6BhdRkqt3",
				"nonce": "n-0S6_WzA2Mj",
				"exp": 1311281970,
				"iat": 1311280970,
				"name": "Jane Doe",
				"given_name": "Jane",
				"family_name": "Doe",
				"gender": "female",
				"birthdate": "0000-10-31",
				"email": "janedoe@example.com",
				"picture": "http://example.com/janedoe/me.jpg"
			}
		)
		with self.assertRaises(jwt.exceptions.InvalidKeyError):
			# {"alg":"RS256"} -> no key id
			OAuth2Key.decode_jwt('eyJhbGciOiJSUzI1NiJ9.' + jwt_data.split('.', 1)[-1])
		with self.assertRaises(jwt.exceptions.InvalidKeyError):
			# {"kid":"XXXXX","alg":"RS256"} -> unknown key id
			OAuth2Key.decode_jwt('eyJraWQiOiJYWFhYWCIsImFsZyI6IlJTMjU2In0.' + jwt_data.split('.', 1)[-1])
		OAuth2Key.query.get('1e9gdk7').active = False
		with self.assertRaises(jwt.exceptions.InvalidKeyError):
			# not active
			OAuth2Key.decode_jwt(jwt_data)

	def test_generate_rsa_key(self):
		key = OAuth2Key.generate_rsa_key()
		self.assertEqual(key.algorithm, 'RS256')
+135 −0
Original line number Diff line number Diff line
import unittest

from uffd.database import db
from uffd.models import User, Role, RoleGroup, TOTPMethod
from uffd.models.role import flatten_recursive

from tests.utils import 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, primary_email_address='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))
		db.session.commit()
		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, primary_email_address='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})
+186 −0
Original line number Diff line number Diff line
import itertools

from uffd.remailer import remailer
from uffd.tasks import cleanup_task
from uffd.database import db
from uffd.models import Service, ServiceUser, User, UserEmail, RemailerMode

from tests.utils import UffdTestCase

class TestServiceUser(UffdTestCase):
	def setUp(self):
		super().setUp()
		db.session.add_all([Service(name='service1', limit_access=False), Service(name='service2', remailer_mode=RemailerMode.ENABLED_V1, limit_access=False)])
		db.session.commit()

	def test_auto_create(self):
		service_count = Service.query.count()
		user_count = User.query.count()
		self.assertEqual(ServiceUser.query.count(), service_count * user_count)
		db.session.add(User(loginname='newuser1', displayname='New User', primary_email_address='new1@example.com'))
		db.session.commit()
		self.assertEqual(ServiceUser.query.count(), service_count * (user_count + 1))
		db.session.add(Service(name='service3'))
		db.session.commit()
		self.assertEqual(ServiceUser.query.count(), (service_count + 1) * (user_count + 1))
		db.session.add(User(loginname='newuser2', displayname='New User', primary_email_address='new2@example.com'))
		db.session.add(User(loginname='newuser3', displayname='New User', primary_email_address='new3@example.com'))
		db.session.add(Service(name='service4'))
		db.session.add(Service(name='service5'))
		db.session.commit()
		self.assertEqual(ServiceUser.query.count(), (service_count + 3) * (user_count + 3))

	def test_create_missing(self):
		service_count = Service.query.count()
		user_count = User.query.count()
		self.assertEqual(ServiceUser.query.count(), service_count * user_count)
		db.session.delete(ServiceUser.query.first())
		db.session.commit()
		self.assertEqual(ServiceUser.query.count(), service_count * user_count - 1)
		cleanup_task.run()
		db.session.commit()
		self.assertEqual(ServiceUser.query.count(), service_count  * user_count)

	def test_effective_remailer_mode(self):
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		user = self.get_user()
		service = Service.query.filter_by(name='service1').first()
		service.remailer_mode = RemailerMode.ENABLED_V2
		service_user = ServiceUser.query.get((service.id, user.id))
		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.ENABLED_V2)
		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin']
		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.DISABLED)
		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser']
		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.ENABLED_V2)
		self.app.config['REMAILER_LIMIT_TO_USERS'] = None
		service_user.remailer_overwrite_mode = RemailerMode.ENABLED_V1
		service.remailer_mode = RemailerMode.DISABLED
		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.ENABLED_V1)
		self.app.config['REMAILER_DOMAIN'] = ''
		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.DISABLED)

	def test_service_email(self):
		user = self.get_user()
		service = Service.query.filter_by(name='service1').first()
		service_user = ServiceUser.query.get((service.id, user.id))
		self.assertEqual(service_user.service_email, None)
		service_user.service_email = UserEmail(user=user, address='foo@bar', verified=True)
		with self.assertRaises(Exception):
			service_user.service_email = UserEmail(user=user, address='foo2@bar', verified=False)
		with self.assertRaises(Exception):
			service_user.service_email = UserEmail(user=self.get_admin(), address='foo3@bar', verified=True)

	def test_real_email(self):
		user = self.get_user()
		service = Service.query.filter_by(name='service1').first()
		service_user = ServiceUser.query.get((service.id, user.id))
		self.assertEqual(service_user.real_email, user.primary_email.address)
		service_user.service_email = UserEmail(user=user, address='foo@bar', verified=True)
		self.assertEqual(service_user.real_email, user.primary_email.address)
		service.enable_email_preferences = True
		self.assertEqual(service_user.real_email, service_user.service_email.address)
		service.limit_access = True
		self.assertEqual(service_user.real_email, user.primary_email.address)
		service.access_group = self.get_admin_group()
		self.assertEqual(service_user.real_email, user.primary_email.address)
		service.access_group = self.get_users_group()
		self.assertEqual(service_user.real_email, service_user.service_email.address)

	def test_get_by_remailer_email(self):
		user = self.get_user()
		service = Service.query.filter_by(name='service1').first()
		service_user = ServiceUser.query.get((service.id, user.id))
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		remailer_email = remailer.build_v1_address(service.id, user.id)
		# 1. remailer not setup
		self.app.config['REMAILER_DOMAIN'] = ''
		self.assertIsNone(ServiceUser.get_by_remailer_email(user.primary_email.address))
		self.assertIsNone(ServiceUser.get_by_remailer_email(remailer_email))
		self.assertIsNone(ServiceUser.get_by_remailer_email('invalid'))
		# 2. remailer setup
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		self.assertIsNone(ServiceUser.get_by_remailer_email(user.primary_email.address))
		self.assertEqual(ServiceUser.get_by_remailer_email(remailer_email), service_user)
		self.assertIsNone(ServiceUser.get_by_remailer_email('invalid'))

	def test_email(self):
		user = self.get_user()
		service = Service.query.filter_by(name='service1').first()
		service_user = ServiceUser.query.get((service.id, user.id))
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		remailer_email = remailer.build_v1_address(service.id, user.id)
		# 1. remailer not setup
		self.app.config['REMAILER_DOMAIN'] = ''
		self.assertEqual(service_user.email, user.primary_email.address)
		# 2. remailer setup + remailer disabled
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		self.assertEqual(service_user.email, user.primary_email.address)
		# 3. remailer setup + remailer enabled + REMAILER_LIMIT_TO_USERS unset
		service.remailer_mode = RemailerMode.ENABLED_V1
		db.session.commit()
		self.assertEqual(service_user.email, remailer_email)
		# 4. remailer setup + remailer enabled + REMAILER_LIMIT_TO_USERS does not include user
		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin']
		self.assertEqual(service_user.email, user.primary_email.address)
		# 5. remailer setup + remailer enabled + REMAILER_LIMIT_TO_USERS includes user
		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser']
		self.assertEqual(service_user.email, remailer_email)
		# 6. remailer setup + remailer disabled + user overwrite
		self.app.config['REMAILER_LIMIT_TO_USERS'] = None
		service.remailer_mode = RemailerMode.DISABLED
		service_user.remailer_overwrite_mode = RemailerMode.ENABLED_V1
		self.assertEqual(service_user.email, remailer_email)
		# 7. remailer setup + remailer enabled + user overwrite
		self.app.config['REMAILER_LIMIT_TO_USERS'] = None
		service.remailer_mode = RemailerMode.ENABLED_V1
		service_user.remailer_overwrite_mode = RemailerMode.DISABLED
		self.assertEqual(service_user.email, user.primary_email.address)

	def test_filter_query_by_email(self):
		service = Service.query.filter_by(name='service1').first()
		user = self.get_user()
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		remailer_email_v1 = remailer.build_v1_address(service.id, user.id)
		remailer_email_v2 = remailer.build_v2_address(service.id, user.id)
		email1 = user.primary_email
		email2 = UserEmail(user=user, address='test2@example.com', verified=True)
		db.session.add(email2)
		service_user = ServiceUser.query.get((service.id, user.id))
		all_service_users = ServiceUser.query.all()
		cases = itertools.product(
			# Input values
			[
				'test@example.com',
				'test2@example.com',
				'other@example.com',
				remailer_email_v1,
				remailer_email_v2,
			],
			# REMAILER_DOMAIN config
			[None, 'remailer.example.com'],
			# REMAILER_LIMIT config
			[None, ['testuser', 'otheruser'], ['testadmin', 'otheruser']],
			# service.remailer_mode
			[RemailerMode.DISABLED, RemailerMode.ENABLED_V1, RemailerMode.ENABLED_V2],
			# service.enable_email_preferences
			[True, False],
			# service.limit_access, service.access_group
			[(False, None), (True, None), (True, self.get_admin_group()), (True, self.get_users_group())],
			# service_user.service_email
			[None, email1, email2],
			# service_user.remailer_overwrite_mode
			[None, RemailerMode.DISABLED, RemailerMode.ENABLED_V1, RemailerMode.ENABLED_V2],
		)
		for options in cases:
			value = options[0]
			self.app.config['REMAILER_DOMAIN'] = options[1]
			self.app.config['REMAILER_LIMIT_TO_USERS'] = options[2]
			service.remailer_mode = options[3]
			service.enable_email_preferences = options[4]
			service.limit_access, service.access_group = options[5]
			service_user.service_email = options[6]
			service_user.remailer_overwrite_mode = options[7]
			a = {result for result in all_service_users if result.email == value}
			b = set(ServiceUser.filter_query_by_email(ServiceUser.query, value).all())
			if a != b:
				self.fail(f'{a} != {b} with ' + repr(options))
+48 −0
Original line number Diff line number Diff line
import unittest
import datetime

from uffd.database import db
from uffd.models.session import Session, USER_AGENT_PARSER_SUPPORTED

from tests.utils import UffdTestCase

class TestSession(UffdTestCase):
	def test_expire(self):
		self.app.config['SESSION_LIFETIME_SECONDS'] = 100
		self.app.config['PERMANENT_SESSION_LIFETIME'] = 10
		user = self.get_user()
		def make_session(created_age, last_used_age):
			return Session(
				user=user,
				created=datetime.datetime.utcnow() - datetime.timedelta(seconds=created_age),
				last_used=datetime.datetime.utcnow() - datetime.timedelta(seconds=last_used_age),
			)
		session1 = Session(user=user)
		self.assertFalse(session1.expired)
		session2 = make_session(0, 0)
		self.assertFalse(session2.expired)
		session3 = make_session(50, 5)
		self.assertFalse(session3.expired)
		session4 = make_session(50, 15)
		self.assertTrue(session4.expired)
		session5 = make_session(105, 5)
		self.assertTrue(session5.expired)
		session6 = make_session(105, 15)
		self.assertTrue(session6.expired)
		db.session.add_all([session1, session2, session3, session4, session5, session6])
		db.session.commit()
		self.assertEqual(set(Session.query.filter_by(expired=False).all()), {session1, session2, session3})
		self.assertEqual(set(Session.query.filter_by(expired=True).all()), {session4, session5, session6})

	def test_useragent_ua_parser(self):
		if not USER_AGENT_PARSER_SUPPORTED:
			self.skipTest('ua_parser not available')
		session = Session(user_agent='Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0')
		self.assertEqual(session.user_agent_browser, 'Firefox')
		self.assertEqual(session.user_agent_platform, 'Windows')

	def test_useragent_no_ua_parser(self):
		session = Session(user_agent='Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0')
		session.DISABLE_USER_AGENT_PARSER = True
		self.assertEqual(session.user_agent_browser, 'Firefox')
		self.assertEqual(session.user_agent_platform, 'Windows')
+175 −0
Original line number Diff line number Diff line
import datetime

from uffd.database import db
from uffd.models import Signup, User, FeatureFlag

from tests.utils import UffdTestCase, db_flush

def refetch_signup(signup):
	db.session.add(signup)
	db.session.commit()
	id = signup.id
	db.session.expunge(signup)
	return Signup.query.get(id)

# We assume in all tests that Signup.validate and Signup.password.verify do
# not alter any state

class TestSignupModel(UffdTestCase):
	def assert_validate_valid(self, signup):
		valid, msg = signup.validate()
		self.assertTrue(valid)
		self.assertIsInstance(msg, str)

	def assert_validate_invalid(self, signup):
		valid, msg = signup.validate()
		self.assertFalse(valid)
		self.assertIsInstance(msg, str)
		self.assertNotEqual(msg, '')

	def assert_finish_success(self, signup, password):
		self.assertIsNone(signup.user)
		user, msg = signup.finish(password)
		db.session.commit()
		self.assertIsNotNone(user)
		self.assertIsInstance(msg, str)
		self.assertIsNotNone(signup.user)

	def assert_finish_failure(self, signup, password):
		prev_id = signup.user_id
		user, msg = signup.finish(password)
		self.assertIsNone(user)
		self.assertIsInstance(msg, str)
		self.assertNotEqual(msg, '')
		self.assertEqual(signup.user_id, prev_id)

	def test_password(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com')
		self.assertFalse(signup.password.verify('notsecret'))
		self.assertFalse(signup.password.verify(''))
		self.assertFalse(signup.password.verify('wrongpassword'))
		self.assertTrue(signup.set_password('notsecret'))
		self.assertTrue(signup.password.verify('notsecret'))
		self.assertFalse(signup.password.verify('wrongpassword'))

	def test_expired(self):
		# TODO: Find a better way to test this!
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assertFalse(signup.expired)
		signup.created = created=datetime.datetime.utcnow() - datetime.timedelta(hours=49)
		self.assertTrue(signup.expired)

	def test_completed(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assertFalse(signup.completed)
		signup.finish('notsecret')
		db.session.commit()
		self.assertTrue(signup.completed)
		signup = refetch_signup(signup)
		self.assertTrue(signup.completed)

	def test_validate(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assert_validate_valid(signup)
		self.assert_validate_valid(refetch_signup(signup))

	def test_validate_completed(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assert_finish_success(signup, 'notsecret')
		self.assert_validate_invalid(signup)
		self.assert_validate_invalid(refetch_signup(signup))

	def test_validate_expired(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com',
		                password='notsecret', created=datetime.datetime.utcnow()-datetime.timedelta(hours=49))
		self.assert_validate_invalid(signup)
		self.assert_validate_invalid(refetch_signup(signup))

	def test_validate_loginname(self):
		signup = Signup(loginname='', displayname='New User', mail='new@example.com', password='notsecret')
		self.assert_validate_invalid(signup)
		self.assert_validate_invalid(refetch_signup(signup))

	def test_validate_displayname(self):
		signup = Signup(loginname='newuser', displayname='', mail='new@example.com', password='notsecret')
		self.assert_validate_invalid(signup)
		self.assert_validate_invalid(refetch_signup(signup))

	def test_validate_mail(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='', password='notsecret')
		self.assert_validate_invalid(signup)
		self.assert_validate_invalid(refetch_signup(signup))

	def test_validate_password(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com')
		self.assertFalse(signup.set_password(''))
		self.assert_validate_invalid(signup)
		self.assert_validate_invalid(refetch_signup(signup))

	def test_validate_exists(self):
		signup = Signup(loginname='testuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assert_validate_invalid(signup)
		self.assert_validate_invalid(refetch_signup(signup))

	def test_finish(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assert_finish_success(signup, 'notsecret')
		user = User.query.filter_by(loginname='newuser').one_or_none()
		self.assertEqual(user.loginname, 'newuser')
		self.assertEqual(user.displayname, 'New User')
		self.assertEqual(user.primary_email.address, 'new@example.com')

	def test_finish_completed(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assert_finish_success(signup, 'notsecret')
		self.assert_finish_failure(refetch_signup(signup), 'notsecret')

	def test_finish_expired(self):
		# TODO: Find a better way to test this!
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com',
		                password='notsecret', created=datetime.datetime.utcnow()-datetime.timedelta(hours=49))
		self.assert_finish_failure(signup, 'notsecret')
		self.assert_finish_failure(refetch_signup(signup), 'notsecret')

	def test_finish_wrongpassword(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com')
		self.assert_finish_failure(signup, '')
		self.assert_finish_failure(signup, 'wrongpassword')
		signup = refetch_signup(signup)
		self.assert_finish_failure(signup, '')
		self.assert_finish_failure(signup, 'wrongpassword')
		signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assert_finish_failure(signup, 'wrongpassword')
		self.assert_finish_failure(refetch_signup(signup), 'wrongpassword')

	def test_finish_duplicate(self):
		signup = Signup(loginname='testuser', displayname='New User', mail='new@example.com', password='notsecret')
		self.assert_finish_failure(signup, 'notsecret')
		self.assert_finish_failure(refetch_signup(signup), 'notsecret')

	def test_finish_duplicate_email_strict_uniqueness(self):
		FeatureFlag.unique_email_addresses.enable()
		db.session.commit()
		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
		self.assert_finish_failure(signup, 'notsecret')

	def test_duplicate(self):
		signup = Signup(loginname='newuser', displayname='New User', mail='test1@example.com', password='notsecret')
		self.assert_validate_valid(signup)
		db.session.add(signup)
		db.session.commit()
		signup1_id = signup.id
		signup = Signup(loginname='newuser', displayname='New User', mail='test2@example.com', password='notsecret')
		self.assert_validate_valid(signup)
		db.session.add(signup)
		db.session.commit()
		signup2_id = signup.id
		db_flush()
		signup = Signup.query.get(signup2_id)
		self.assert_finish_success(signup, 'notsecret')
		db.session.commit()
		db_flush()
		signup = Signup.query.get(signup1_id)
		self.assert_finish_failure(signup, 'notsecret')
		user = User.query.filter_by(loginname='newuser').one_or_none()
		self.assertEqual(user.primary_email.address, 'test2@example.com')
+403 −0
Original line number Diff line number Diff line
import datetime

import sqlalchemy

from uffd.database import db
from uffd.models import User, UserEmail, Group, FeatureFlag, IDAlreadyAllocatedError, IDRangeExhaustedError

from tests.utils import UffdTestCase, ModelTestCase

class TestUserModel(UffdTestCase):
	def test_has_permission(self):
		user_ = self.get_user() # has 'users' and 'uffd_access' group
		admin = self.get_admin() # has 'users', 'uffd_access' and 'uffd_admin' group
		self.assertTrue(user_.has_permission(None))
		self.assertTrue(admin.has_permission(None))
		self.assertTrue(user_.has_permission('users'))
		self.assertTrue(admin.has_permission('users'))
		self.assertFalse(user_.has_permission('notagroup'))
		self.assertFalse(admin.has_permission('notagroup'))
		self.assertFalse(user_.has_permission('uffd_admin'))
		self.assertTrue(admin.has_permission('uffd_admin'))
		self.assertFalse(user_.has_permission(['uffd_admin']))
		self.assertTrue(admin.has_permission(['uffd_admin']))
		self.assertFalse(user_.has_permission(['uffd_admin', 'notagroup']))
		self.assertTrue(admin.has_permission(['uffd_admin', 'notagroup']))
		self.assertFalse(user_.has_permission(['notagroup', 'uffd_admin']))
		self.assertTrue(admin.has_permission(['notagroup', 'uffd_admin']))
		self.assertTrue(user_.has_permission(['uffd_admin', 'users']))
		self.assertTrue(admin.has_permission(['uffd_admin', 'users']))
		self.assertTrue(user_.has_permission([['uffd_admin', 'users'], ['users', 'uffd_access']]))
		self.assertTrue(admin.has_permission([['uffd_admin', 'users'], ['users', 'uffd_access']]))
		self.assertFalse(user_.has_permission(['uffd_admin', ['users', 'notagroup']]))
		self.assertTrue(admin.has_permission(['uffd_admin', ['users', 'notagroup']]))

	def test_unix_uid_generation(self):
		self.app.config['USER_MIN_UID'] = 10000
		self.app.config['USER_MAX_UID'] = 18999
		self.app.config['USER_SERVICE_MIN_UID'] = 19000
		self.app.config['USER_SERVICE_MAX_UID'] = 19999
		db.drop_all()
		db.create_all()
		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
		user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com')
		db.session.add_all([user0, user1, user2])
		db.session.commit()
		self.assertEqual(user0.unix_uid, 10000)
		self.assertEqual(user1.unix_uid, 10001)
		self.assertEqual(user2.unix_uid, 10002)
		db.session.delete(user1)
		db.session.commit()
		user3 = User(loginname='user3', displayname='user3', primary_email_address='user3@example.com')
		db.session.add(user3)
		db.session.commit()
		self.assertEqual(user3.unix_uid, 10003)
		db.session.delete(user2)
		db.session.commit()
		user4 = User(loginname='user4', displayname='user4', primary_email_address='user4@example.com')
		db.session.add(user4)
		db.session.commit()
		self.assertEqual(user4.unix_uid, 10004)
		service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True)
		service1 = User(loginname='service1', displayname='service1', primary_email_address='service1@example.com', is_service_user=True)
		db.session.add_all([service0, service1])
		db.session.commit()
		self.assertEqual(service0.unix_uid, 19000)
		self.assertEqual(service1.unix_uid, 19001)

	def test_unix_uid_generation_overlapping(self):
		self.app.config['USER_MIN_UID'] = 10000
		self.app.config['USER_MAX_UID'] = 19999
		self.app.config['USER_SERVICE_MIN_UID'] = 10000
		self.app.config['USER_SERVICE_MAX_UID'] = 19999
		db.drop_all()
		db.create_all()
		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
		service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True)
		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
		db.session.add_all([user0, service0, user1])
		db.session.commit()
		self.assertEqual(user0.unix_uid, 10000)
		self.assertEqual(service0.unix_uid, 10001)
		self.assertEqual(user1.unix_uid, 10002)

	def test_unix_uid_generation_overflow(self):
		self.app.config['USER_MIN_UID'] = 10000
		self.app.config['USER_MAX_UID'] = 10001
		db.drop_all()
		db.create_all()
		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
		db.session.add_all([user0, user1])
		db.session.commit()
		self.assertEqual(user0.unix_uid, 10000)
		self.assertEqual(user1.unix_uid, 10001)
		with self.assertRaises(sqlalchemy.exc.StatementError):
			user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com')
			db.session.add(user2)
			db.session.commit()

	def test_init_primary_email_address(self):
		user = User(primary_email_address='foobar@example.com')
		self.assertEqual(user.primary_email.address, 'foobar@example.com')
		self.assertEqual(user.primary_email.verified, True)
		self.assertEqual(user.primary_email.user, user)
		user = User(primary_email_address='invalid')
		self.assertEqual(user.primary_email.address, 'invalid')
		self.assertEqual(user.primary_email.verified, True)
		self.assertEqual(user.primary_email.user, user)

	def test_set_primary_email_address(self):
		user = User()
		self.assertFalse(user.set_primary_email_address('invalid'))
		self.assertIsNone(user.primary_email)
		self.assertEqual(len(user.all_emails), 0)
		self.assertTrue(user.set_primary_email_address('foobar@example.com'))
		self.assertEqual(user.primary_email.address, 'foobar@example.com')
		self.assertEqual(len(user.all_emails), 1)
		self.assertFalse(user.set_primary_email_address('invalid'))
		self.assertEqual(user.primary_email.address, 'foobar@example.com')
		self.assertEqual(len(user.all_emails), 1)
		self.assertTrue(user.set_primary_email_address('other@example.com'))
		self.assertEqual(user.primary_email.address, 'other@example.com')
		self.assertEqual(len(user.all_emails), 2)
		self.assertEqual({user.all_emails[0].address, user.all_emails[1].address}, {'foobar@example.com', 'other@example.com'})

class TestUserEmailModel(UffdTestCase):
	def test_normalize_address(self):
		ref = UserEmail.normalize_address('foo@example.com')
		self.assertEqual(ref, UserEmail.normalize_address('foo@example.com'))
		self.assertEqual(ref, UserEmail.normalize_address('Foo@Example.Com'))
		self.assertEqual(ref, UserEmail.normalize_address(' foo@example.com  '))
		self.assertNotEqual(ref, UserEmail.normalize_address('bar@example.com'))
		self.assertNotEqual(ref, UserEmail.normalize_address('foo @example.com'))
		# "No-Break Space" instead of SPACE (Unicode normalization + stripping)
		self.assertEqual(ref, UserEmail.normalize_address('\u00A0foo@example.com '))
		# Pre-composed "Angstrom Sign" vs. "A" + "Combining Ring Above" (Unicode normalization)
		self.assertEqual(UserEmail.normalize_address('\u212B@example.com'), UserEmail.normalize_address('A\u030A@example.com'))

	def test_address(self):
		email = UserEmail()
		self.assertIsNone(email.address)
		self.assertIsNone(email.address_normalized)
		email.address = 'Foo@example.com'
		self.assertEqual(email.address, 'Foo@example.com')
		self.assertEqual(email.address_normalized, UserEmail.normalize_address('Foo@example.com'))
		with self.assertRaises(ValueError):
			email.address = 'bar@example.com'
		with self.assertRaises(ValueError):
			email.address = None

	def test_set_address(self):
		email = UserEmail()
		self.assertFalse(email.set_address('invalid'))
		self.assertIsNone(email.address)
		self.assertFalse(email.set_address(''))
		self.assertFalse(email.set_address('@'))
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		self.assertFalse(email.set_address('foobar@remailer.example.com'))
		self.assertFalse(email.set_address('v1-1-testuser@remailer.example.com'))
		self.assertFalse(email.set_address('v1-1-testuser @ remailer.example.com'))
		self.assertFalse(email.set_address('v1-1-testuser@REMAILER.example.com'))
		self.assertFalse(email.set_address('v1-1-testuser@foobar@remailer.example.com'))
		self.assertTrue(email.set_address('foobar@example.com'))
		self.assertEqual(email.address, 'foobar@example.com')

	def test_verified(self):
		email = UserEmail(user=self.get_user(), address='foo@example.com')
		db.session.add(email)
		self.assertEqual(email.verified, False)
		self.assertEqual(UserEmail.query.filter_by(address='foo@example.com', verified=True).count(), 0)
		self.assertEqual(UserEmail.query.filter_by(address='foo@example.com', verified=False).count(), 1)
		email.verified = True
		self.assertEqual(email.verified, True)
		self.assertEqual(UserEmail.query.filter_by(address='foo@example.com', verified=True).count(), 1)
		self.assertEqual(UserEmail.query.filter_by(address='foo@example.com', verified=False).count(), 0)
		with self.assertRaises(ValueError):
			email.verified = False
		self.assertEqual(email.verified, True)
		with self.assertRaises(ValueError):
			email.verified = None
		self.assertEqual(email.verified, True)

	def test_verification(self):
		email = UserEmail(address='foo@example.com')
		self.assertFalse(email.finish_verification('test'))
		secret = email.start_verification()
		self.assertTrue(email.verification_secret)
		self.assertTrue(email.verification_secret.verify(secret))
		self.assertFalse(email.verification_expired)
		self.assertFalse(email.finish_verification('test'))
		orig_expires = email.verification_expires
		email.verification_expires = datetime.datetime.utcnow() - datetime.timedelta(days=1)
		self.assertFalse(email.finish_verification(secret))
		email.verification_expires = orig_expires
		self.assertTrue(email.finish_verification(secret))
		self.assertFalse(email.verification_secret)
		self.assertTrue(email.verification_expired)

	def test_enable_strict_constraints(self):
		email = UserEmail(address='foo@example.com', user=self.get_user())
		db.session.add(email)
		db.session.commit()
		self.assertIsNone(email.enable_strict_constraints)
		FeatureFlag.unique_email_addresses.enable()
		self.assertTrue(email.enable_strict_constraints)
		FeatureFlag.unique_email_addresses.disable()
		self.assertIsNone(email.enable_strict_constraints)

	def assert_can_add_address(self, **kwargs):
		user_email = UserEmail(**kwargs)
		db.session.add(user_email)
		db.session.commit()
		db.session.delete(user_email)
		db.session.commit()

	def assert_cannot_add_address(self, **kwargs):
		with self.assertRaises(sqlalchemy.exc.IntegrityError):
			db.session.add(UserEmail(**kwargs))
			db.session.commit()
		db.session.rollback()

	def test_unique_constraints_old(self):
		# The same user cannot add the same exact address multiple times, but
		# different users can have the same address
		user = self.get_user()
		admin = self.get_admin()
		db.session.add(UserEmail(user=user, address='foo@example.com'))
		db.session.add(UserEmail(user=user, address='bar@example.com', verified=True))
		db.session.commit()

		self.assert_can_add_address(user=user, address='foobar@example.com')
		self.assert_can_add_address(user=user, address='foobar@example.com', verified=True)

		self.assert_cannot_add_address(user=user, address='foo@example.com')
		self.assert_can_add_address(user=user, address='FOO@example.com')
		self.assert_cannot_add_address(user=user, address='bar@example.com')
		self.assert_can_add_address(user=user, address='BAR@example.com')

		self.assert_cannot_add_address(user=user, address='foo@example.com', verified=True)
		self.assert_can_add_address(user=user, address='FOO@example.com', verified=True)
		self.assert_cannot_add_address(user=user, address='bar@example.com', verified=True)
		self.assert_can_add_address(user=user, address='BAR@example.com', verified=True)

		self.assert_can_add_address(user=admin, address='foobar@example.com')
		self.assert_can_add_address(user=admin, address='foobar@example.com', verified=True)

		self.assert_can_add_address(user=admin, address='foo@example.com')
		self.assert_can_add_address(user=admin, address='FOO@example.com')
		self.assert_can_add_address(user=admin, address='bar@example.com')
		self.assert_can_add_address(user=admin, address='BAR@example.com')

		self.assert_can_add_address(user=admin, address='foo@example.com', verified=True)
		self.assert_can_add_address(user=admin, address='FOO@example.com', verified=True)
		self.assert_can_add_address(user=admin, address='bar@example.com', verified=True)
		self.assert_can_add_address(user=admin, address='BAR@example.com', verified=True)

	def test_unique_constraints_strict(self):
		FeatureFlag.unique_email_addresses.enable()
		# The same user cannot add the same (normalized) address multiple times,
		# and different users cannot have the same verified (normalized) address
		user = self.get_user()
		admin = self.get_admin()
		db.session.add(UserEmail(user=user, address='foo@example.com'))
		db.session.add(UserEmail(user=user, address='bar@example.com', verified=True))
		db.session.commit()

		self.assert_can_add_address(user=user, address='foobar@example.com')
		self.assert_can_add_address(user=user, address='foobar@example.com', verified=True)

		self.assert_cannot_add_address(user=user, address='foo@example.com')
		self.assert_cannot_add_address(user=user, address='FOO@example.com')
		self.assert_cannot_add_address(user=user, address='bar@example.com')
		self.assert_cannot_add_address(user=user, address='BAR@example.com')

		self.assert_cannot_add_address(user=user, address='foo@example.com', verified=True)
		self.assert_cannot_add_address(user=user, address='FOO@example.com', verified=True)
		self.assert_cannot_add_address(user=user, address='bar@example.com', verified=True)
		self.assert_cannot_add_address(user=user, address='BAR@example.com', verified=True)

		self.assert_can_add_address(user=admin, address='foobar@example.com')
		self.assert_can_add_address(user=admin, address='foobar@example.com', verified=True)

		self.assert_can_add_address(user=admin, address='foo@example.com')
		self.assert_can_add_address(user=admin, address='FOO@example.com')
		self.assert_can_add_address(user=admin, address='bar@example.com')
		self.assert_can_add_address(user=admin, address='BAR@example.com')

		self.assert_can_add_address(user=admin, address='foo@example.com', verified=True)
		self.assert_can_add_address(user=admin, address='FOO@example.com', verified=True)
		self.assert_cannot_add_address(user=admin, address='bar@example.com', verified=True)
		self.assert_cannot_add_address(user=admin, address='BAR@example.com', verified=True)

class TestIDAllocator(ModelTestCase):
	def allocate_gids(self, *gids):
		for gid in gids:
			Group.unix_gid_allocator.allocate(gid)

	def fetch_gid_allocations(self):
		return [row[0] for row in db.session.execute(
			db.select([Group.unix_gid_allocator.allocation_table])
			.order_by(Group.unix_gid_allocator.allocation_table.c.id)
		).fetchall()]

	def test_empty(self):
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000)
		self.assertEqual(self.fetch_gid_allocations(), [20000])

	def test_first(self):
		self.allocate_gids(20000)
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20001)
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001])

	def test_out_of_range_before(self):
		self.allocate_gids(19998)
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000)
		self.assertEqual(self.fetch_gid_allocations(), [19998, 20000])

	def test_out_of_range_right_before(self):
		self.allocate_gids(19999)
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000)
		self.assertEqual(self.fetch_gid_allocations(), [19999, 20000])

	def test_out_of_range_after(self):
		self.allocate_gids(20006)
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000)
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20006])

	def test_gap_at_beginning(self):
		self.allocate_gids(20001)
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000)
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001])

	def test_multiple_gaps(self):
		self.allocate_gids(20000, 20001, 20003, 20005)
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20002)
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20005])
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20004)
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004, 20005])

	def test_last(self):
		self.allocate_gids(20000, 20001, 20002, 20003, 20004)
		self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20005)
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004, 20005])

	def test_overflow(self):
		self.allocate_gids(20000, 20001, 20002, 20003, 20004, 20005)
		with self.assertRaises(IDRangeExhaustedError):
			Group.unix_gid_allocator.auto(20000, 20005)
		self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004, 20005])

	def test_conflict(self):
		self.allocate_gids(20000)
		with self.assertRaises(IDAlreadyAllocatedError):
			self.allocate_gids(20000)
		self.assertEqual(self.fetch_gid_allocations(), [20000])

class TestGroup(ModelTestCase):
	def test_unix_gid_generation(self):
		self.app.config['GROUP_MIN_GID'] = 20000
		self.app.config['GROUP_MAX_GID'] = 49999
		group0 = Group(name='group0', description='group0')
		group1 = Group(name='group1', description='group1')
		group2 = Group(name='group2', description='group2')
		group3 = Group(name='group3', description='group3', unix_gid=20004)
		db.session.add_all([group0, group1, group2, group3])
		db.session.commit()
		self.assertEqual(group0.unix_gid, 20000)
		self.assertEqual(group1.unix_gid, 20001)
		self.assertEqual(group2.unix_gid, 20002)
		self.assertEqual(group3.unix_gid, 20004)
		db.session.delete(group2)
		db.session.commit()
		group4 = Group(name='group4', description='group4')
		group5 = Group(name='group5', description='group5')
		db.session.add_all([group4, group5])
		db.session.commit()
		self.assertEqual(group4.unix_gid, 20003)
		self.assertEqual(group5.unix_gid, 20005)

	def test_unix_gid_generation_conflict(self):
		self.app.config['GROUP_MIN_GID'] = 20000
		self.app.config['GROUP_MAX_GID'] = 49999
		group0 = Group(name='group0', description='group0', unix_gid=20023)
		db.session.add(group0)
		db.session.commit()
		with self.assertRaises(IDAlreadyAllocatedError):
			Group(name='group1', description='group1', unix_gid=20023)

	def test_unix_gid_generation_overflow(self):
		self.app.config['GROUP_MIN_GID'] = 20000
		self.app.config['GROUP_MAX_GID'] = 20001
		group0 = Group(name='group0', description='group0')
		group1 = Group(name='group1', description='group1')
		db.session.add_all([group0, group1])
		db.session.commit()
		self.assertEqual(group0.unix_gid, 20000)
		self.assertEqual(group1.unix_gid, 20001)
		db.session.commit()
		with self.assertRaises(sqlalchemy.exc.StatementError):
			group2 = Group(name='group2', description='group2')
			db.session.add(group2)
			db.session.commit()

tests/test_csrf.py

0 → 100644
+102 −0
Original line number Diff line number Diff line
import unittest

from flask import Flask, Blueprint, session, url_for

from uffd.csrf import bp as csrf_bp, csrf_protect

uid_counter = 0

class TestCSRF(unittest.TestCase):
	unprotected_ep = 'foo'
	protected_ep = 'bar'

	def setUp(self):
		self.app = Flask(__name__)
		self.app.testing = True
		self.app.config['SECRET_KEY'] = 'DEBUGKEY'
		self.app.register_blueprint(csrf_bp)

		@self.app.route('/', methods=['GET', 'POST'])
		def index():
			return 'SUCCESS', 200

		@self.app.route('/login', methods=['GET', 'POST'])
		def login():
			global uid_counter
			session['_csrf_token'] = 'secret_csrf_token%d'%uid_counter
			uid_counter += 1
			return 'Ok', 200

		@self.app.route('/logout', methods=['GET', 'POST'])
		def logout():
			session.clear()
			return 'Ok', 200

		@self.app.route('/foo', methods=['GET', 'POST'])
		def foo():
			return 'SUCCESS', 200

		@self.app.route('/bar', methods=['GET', 'POST'])
		@csrf_protect()
		def bar():
			return 'SUCCESS', 200
		
		self.bp = Blueprint('bp', __name__)

		@self.bp.route('/foo', methods=['GET', 'POST'])
		@csrf_protect(blueprint=self.bp) # This time on .foo and not on .bar!
		def foo():
			return 'SUCCESS', 200
		
		@self.bp.route('/bar', methods=['GET', 'POST'])
		def bar():
			return 'SUCCESS', 200

		self.app.register_blueprint(self.bp, url_prefix='/bp/')
		self.client = self.app.test_client()
		self.client.__enter__()
		# Just do some request so that we can use url_for
		self.client.get(path='/')

	def tearDown(self):
		self.client.__exit__(None, None, None)

	def set_token(self):
		self.client.get(path='/login')

	def clear_token(self):
		self.client.get(path='/logout')

	def test_notoken_unprotected(self):
		url = url_for(self.unprotected_ep)
		self.assertTrue('csrf' not in url)
		self.assertEqual(self.client.get(path=url).data, b'SUCCESS')

	def test_token_unprotected(self):
		self.set_token()
		self.test_notoken_unprotected()

	def test_notoken_protected(self):
		url = url_for(self.protected_ep)
		self.assertNotEqual(self.client.get(path=url).data, b'SUCCESS')

	def test_token_protected(self):
		self.set_token()
		url = url_for(self.protected_ep)
		self.assertEqual(self.client.get(path=url).data, b'SUCCESS')

	def test_wrong_token_protected(self):
		self.set_token()
		url = url_for(self.protected_ep)
		self.set_token()
		self.assertNotEqual(self.client.get(path=url).data, b'SUCCESS')
	
	def test_deleted_token_protected(self):
		self.set_token()
		url = url_for(self.protected_ep)
		self.clear_token()
		self.assertNotEqual(self.client.get(path=url).data, b'SUCCESS')
	
class TestBlueprintCSRF(TestCSRF):
	unprotected_ep = 'bp.bar'
	protected_ep = 'bp.foo'
+206 −0
Original line number Diff line number Diff line
import unittest

from uffd.password_hash import *

class TestPasswordHashRegistry(unittest.TestCase):
	def test(self):
		registry = PasswordHashRegistry()

		@registry.register
		class TestPasswordHash:
			METHOD_NAME = 'test'
			def __init__(self, value, **kwargs):
				self.value = value
				self.kwargs = kwargs

		@registry.register
		class Test2PasswordHash:
			METHOD_NAME = 'test2'

		result = registry.parse('{test}data', key='value')
		self.assertIsInstance(result, TestPasswordHash)
		self.assertEqual(result.value, '{test}data')
		self.assertEqual(result.kwargs, {'key': 'value'})
		with self.assertRaises(ValueError):
			registry.parse('{invalid}data')
		with self.assertRaises(ValueError):
			registry.parse('invalid')
		with self.assertRaises(ValueError):
			registry.parse('{invalid')

class TestPasswordHash(unittest.TestCase):
	def setUp(self):
		class TestPasswordHash(PasswordHash):
			@classmethod
			def from_password(cls, password):
				cls(build_value(cls.METHOD_NAME, password))

			def verify(self, password):
				return self.data == password

		class TestPasswordHash1(TestPasswordHash):
			METHOD_NAME = 'test1'

		class TestPasswordHash2(TestPasswordHash):
			METHOD_NAME = 'test2'

		self.TestPasswordHash1 = TestPasswordHash1
		self.TestPasswordHash2 = TestPasswordHash2

	def test(self):
		obj = self.TestPasswordHash1('{test1}data')
		self.assertEqual(obj.value, '{test1}data')
		self.assertEqual(obj.data, 'data')
		self.assertIs(obj.target_cls, self.TestPasswordHash1)
		self.assertFalse(obj.needs_rehash)

	def test_invalid(self):
		with self.assertRaises(ValueError):
			self.TestPasswordHash1('invalid')
		with self.assertRaises(ValueError):
			self.TestPasswordHash1('{invalid}data')
		with self.assertRaises(ValueError):
			self.TestPasswordHash1('{test2}data')

	def test_target_cls(self):
		obj = self.TestPasswordHash1('{test1}data', target_cls=self.TestPasswordHash1)
		self.assertEqual(obj.value, '{test1}data')
		self.assertEqual(obj.data, 'data')
		self.assertIs(obj.target_cls, self.TestPasswordHash1)
		self.assertFalse(obj.needs_rehash)
		obj = self.TestPasswordHash1('{test1}data', target_cls=self.TestPasswordHash2)
		self.assertEqual(obj.value, '{test1}data')
		self.assertEqual(obj.data, 'data')
		self.assertIs(obj.target_cls, self.TestPasswordHash2)
		self.assertTrue(obj.needs_rehash)
		obj = self.TestPasswordHash1('{test1}data', target_cls=PasswordHash)
		self.assertEqual(obj.value, '{test1}data')
		self.assertEqual(obj.data, 'data')
		self.assertIs(obj.target_cls, PasswordHash)
		self.assertFalse(obj.needs_rehash)

class TestPlaintextPasswordHash(unittest.TestCase):
	def test_verify(self):
		obj = PlaintextPasswordHash('{plain}password')
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

	def test_from_password(self):
		obj = PlaintextPasswordHash.from_password('password')
		self.assertEqual(obj.value, '{plain}password')
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

class TestHashlibPasswordHash(unittest.TestCase):
	def test_verify(self):
		obj = SHA512PasswordHash('{sha512}sQnzu7wkTrgkQZF+0G1hi5AI3Qmzvv0bXgc5THBqi7mAsdd4Xll27ASbRt9fEyavWi6m0QP9B8lThf+rDKy8hg==')
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

	def test_from_password(self):
		obj = SHA512PasswordHash.from_password('password')
		self.assertIsNotNone(obj.value)
		self.assertTrue(obj.value.startswith('{sha512}'))
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

class TestSaltedHashlibPasswordHash(unittest.TestCase):
	def test_verify(self):
		obj = SaltedSHA512PasswordHash('{ssha512}dOeDLmVpHJThhHeag10Hm2g4T7s3SBE6rGHcXUolXJHVufY4qT782rwZ/0XE6cuLcBZ0KpnwmUzRpAEtZBdv+JYEEtZQs/uC')
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

	def test_from_password(self):
		obj = SaltedSHA512PasswordHash.from_password('password')
		self.assertIsNotNone(obj.value)
		self.assertTrue(obj.value.startswith('{ssha512}'))
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

class TestCryptPasswordHash(unittest.TestCase):
	def test_verify(self):
		obj = CryptPasswordHash('{crypt}$5$UbTTMBH9NRurlQcX$bUiUTyedvmArlVt.62ZLRV80e2v3DjcBp/tSDkP2imD')
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

	def test_from_password(self):
		obj = CryptPasswordHash.from_password('password')
		self.assertIsNotNone(obj.value)
		self.assertTrue(obj.value.startswith('{crypt}'))
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

class TestArgon2PasswordHash(unittest.TestCase):
	def test_verify(self):
		obj = Argon2PasswordHash('{argon2}$argon2id$v=19$m=102400,t=2,p=8$Jc8LpCgPLjwlN/7efHLvwQ$ZqSg3CFb2/hBb3X8hOq4aw')
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))
		obj = Argon2PasswordHash('{argon2}$invalid$')
		self.assertFalse(obj.verify('password'))

	def test_from_password(self):
		obj = Argon2PasswordHash.from_password('password')
		self.assertIsNotNone(obj.value)
		self.assertTrue(obj.value.startswith('{argon2}'))
		self.assertTrue(obj.verify('password'))
		self.assertFalse(obj.verify('notpassword'))

	def test_needs_rehash(self):
		obj = Argon2PasswordHash('{argon2}$argon2id$v=19$m=102400,t=2,p=8$Jc8LpCgPLjwlN/7efHLvwQ$ZqSg3CFb2/hBb3X8hOq4aw')
		self.assertFalse(obj.needs_rehash)
		obj = Argon2PasswordHash('{argon2}$argon2id$v=19$m=102400,t=2,p=8$Jc8LpCgPLjwlN/7efHLvwQ$ZqSg3CFb2/hBb3X8hOq4aw', target_cls=PlaintextPasswordHash)
		self.assertTrue(obj.needs_rehash)
		obj = Argon2PasswordHash('{argon2}$argon2d$v=19$m=102400,t=2,p=8$kshPgLU1+h72l/Z8QWh8Ig$tYerKCe/5I2BCPKu8hCl2w')
		self.assertTrue(obj.needs_rehash)
		obj = Argon2PasswordHash('{argon2}$argon2id$v=19$m=102400,t=1,p=8$aa6i4vg/szKX5xHVGFaAeQ$v6j0ltuVqQaZlmuepaVJ1A')
		self.assertTrue(obj.needs_rehash)

class TestInvalidPasswordHash(unittest.TestCase):
	def test(self):
		obj = InvalidPasswordHash('test')
		self.assertEqual(obj.value, 'test')
		self.assertFalse(obj.verify('test'))
		self.assertTrue(obj.needs_rehash)
		self.assertFalse(obj)
		obj = InvalidPasswordHash(None)
		self.assertIsNone(obj.value)
		self.assertFalse(obj.verify('test'))
		self.assertTrue(obj.needs_rehash)
		self.assertFalse(obj)

class TestPasswordWrapper(unittest.TestCase):
	def setUp(self):
		class Test:
			password_hash = None
			password = PasswordHashAttribute('password_hash', PlaintextPasswordHash)

		self.test = Test()

	def test_get_none(self):
		self.test.password_hash = None
		obj = self.test.password
		self.assertIsInstance(obj, InvalidPasswordHash)
		self.assertEqual(obj.value, None)
		self.assertTrue(obj.needs_rehash)

	def test_get_valid(self):
		self.test.password_hash = '{plain}password'
		obj = self.test.password
		self.assertIsInstance(obj, PlaintextPasswordHash)
		self.assertEqual(obj.value, '{plain}password')
		self.assertFalse(obj.needs_rehash)

	def test_get_needs_rehash(self):
		self.test.password_hash = '{ssha512}dOeDLmVpHJThhHeag10Hm2g4T7s3SBE6rGHcXUolXJHVufY4qT782rwZ/0XE6cuLcBZ0KpnwmUzRpAEtZBdv+JYEEtZQs/uC'
		obj = self.test.password
		self.assertIsInstance(obj, SaltedSHA512PasswordHash)
		self.assertEqual(obj.value, '{ssha512}dOeDLmVpHJThhHeag10Hm2g4T7s3SBE6rGHcXUolXJHVufY4qT782rwZ/0XE6cuLcBZ0KpnwmUzRpAEtZBdv+JYEEtZQs/uC')
		self.assertTrue(obj.needs_rehash)

	def test_set(self):
		self.test.password = 'password'
		self.assertEqual(self.test.password_hash, '{plain}password')

	def test_set_none(self):
		self.test.password = None
		self.assertIsNone(self.test.password_hash)
+46 −0
Original line number Diff line number Diff line
from uffd.models.ratelimit import get_addrkey, format_delay, Ratelimit

from tests.utils import UffdTestCase

class TestRatelimit(UffdTestCase):
	def test_limiting(self):
		cases = [
			(1*60, 3),
			(1*60*60, 3),
			(1*60*60, 25),
		]
		for index, case in enumerate(cases):
			interval, limit = case
			key = str(index)
			ratelimit = Ratelimit('test', interval, limit)
			for i in range(limit):
				ratelimit.log(key)
			self.assertLessEqual(ratelimit.get_delay(key), interval)
			ratelimit.log(key)
			self.assertGreater(ratelimit.get_delay(key), interval)

	def test_addrkey(self):
		self.assertEqual(get_addrkey('192.168.0.1'), get_addrkey('192.168.0.99'))
		self.assertNotEqual(get_addrkey('192.168.0.1'), get_addrkey('192.168.1.1'))
		self.assertEqual(get_addrkey('fdee:707a:f38a:c369::'), get_addrkey('fdee:707a:f38a:ffff::'))
		self.assertNotEqual(get_addrkey('fdee:707a:f38a:c369::'), get_addrkey('fdee:707a:f38b:c369::'))
		cases = [
			'',
			'192.168.0.',
			':',
			'::',
			'192.168.0.1/24',
			'192.168.0.1/24',
			'host.example.com',
		]
		for case in cases:
			self.assertIsInstance(get_addrkey(case), str)

	def test_format_delay(self):
		self.assertIsInstance(format_delay(0), str)
		self.assertIsInstance(format_delay(1), str)
		self.assertIsInstance(format_delay(30), str)
		self.assertIsInstance(format_delay(60), str)
		self.assertIsInstance(format_delay(120), str)
		self.assertIsInstance(format_delay(3600), str)
		self.assertIsInstance(format_delay(4000), str)

tests/test_remailer.py

0 → 100644
+85 −0
Original line number Diff line number Diff line
from uffd.remailer import remailer

from tests.utils import UffdTestCase

USER_ID = 1234
SERVICE1_ID = 4223
SERVICE2_ID = 3242
ADDR_V1_S1 = 'v1-WzQyMjMsMTIzNF0.MeO6bHGTgIyPvvq2r3xriokLMCU@remailer.example.com'
ADDR_V1_S2 = 'v1-WzMyNDIsMTIzNF0.p2a_RkJc0oHBc9u4_S8G9METflA@remailer.example.com'
ADDR_V2_S1 = 'v2-lm2demrtfqytemzulu-ghr3u3drsoaizd567k3k67dlrkeqwmbf@remailer.example.com'
ADDR_V2_S2 = 'v2-lmztenbsfqytemzulu-u5tl6rscltjidqlt3o4p2lyg6targ7sq@remailer.example.com'

class TestRemailer(UffdTestCase):
	def test_is_remailer_domain(self):
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		self.assertTrue(remailer.is_remailer_domain('remailer.example.com'))
		self.assertTrue(remailer.is_remailer_domain('REMAILER.EXAMPLE.COM'))
		self.assertTrue(remailer.is_remailer_domain(' remailer.example.com '))
		self.assertFalse(remailer.is_remailer_domain('other.remailer.example.com'))
		self.assertFalse(remailer.is_remailer_domain('example.com'))
		self.app.config['REMAILER_OLD_DOMAINS'] = [' OTHER.remailer.example.com ']
		self.assertTrue(remailer.is_remailer_domain(' OTHER.remailer.example.com '))
		self.assertTrue(remailer.is_remailer_domain('remailer.example.com'))
		self.assertTrue(remailer.is_remailer_domain('other.remailer.example.com'))
		self.assertFalse(remailer.is_remailer_domain('example.com'))

	def test_build_v1_address(self):
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		self.assertEqual(remailer.build_v1_address(SERVICE1_ID, USER_ID), ADDR_V1_S1)
		self.assertEqual(remailer.build_v1_address(SERVICE2_ID, USER_ID), ADDR_V1_S2)
		long_addr = remailer.build_v1_address(1000, 1000000)
		self.assertLessEqual(len(long_addr.split('@')[0]), 64)
		self.assertLessEqual(len(long_addr), 256)
		self.app.config['REMAILER_OLD_DOMAINS'] = ['old.remailer.example.com']
		self.assertEqual(remailer.build_v1_address(SERVICE1_ID, USER_ID), ADDR_V1_S1)
		self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY']
		self.assertEqual(remailer.build_v1_address(SERVICE1_ID, USER_ID), ADDR_V1_S1)
		self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY'
		self.assertNotEqual(remailer.build_v1_address(SERVICE1_ID, USER_ID), ADDR_V1_S1)

	def test_build_v2_address(self):
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		self.assertEqual(remailer.build_v2_address(SERVICE1_ID, USER_ID), ADDR_V2_S1)
		self.assertEqual(remailer.build_v2_address(SERVICE2_ID, USER_ID), ADDR_V2_S2)
		long_addr = remailer.build_v2_address(1000, 1000000)
		self.assertLessEqual(len(long_addr.split('@')[0]), 64)
		self.assertLessEqual(len(long_addr), 256)
		self.app.config['REMAILER_OLD_DOMAINS'] = ['old.remailer.example.com']
		self.assertEqual(remailer.build_v2_address(SERVICE1_ID, USER_ID), ADDR_V2_S1)
		self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY']
		self.assertEqual(remailer.build_v2_address(SERVICE1_ID, USER_ID), ADDR_V2_S1)
		self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY'
		self.assertNotEqual(remailer.build_v2_address(SERVICE1_ID, USER_ID), ADDR_V2_S1)

	def test_parse_address(self):
		# REMAILER_DOMAIN behaviour
		self.app.config['REMAILER_DOMAIN'] = None
		self.assertIsNone(remailer.parse_address(ADDR_V1_S2))
		self.assertIsNone(remailer.parse_address(ADDR_V2_S2))
		self.assertIsNone(remailer.parse_address('foo@example.com'))
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		self.assertEqual(remailer.parse_address(ADDR_V1_S2), (SERVICE2_ID, USER_ID))
		self.assertEqual(remailer.parse_address(ADDR_V2_S2), (SERVICE2_ID, USER_ID))
		self.assertIsNone(remailer.parse_address('foo@example.com'))
		self.assertIsNone(remailer.parse_address('foo@remailer.example.com'))
		self.assertIsNone(remailer.parse_address('v1-foo@remailer.example.com'))
		self.assertIsNone(remailer.parse_address('v2-foo@remailer.example.com'))
		self.assertIsNone(remailer.parse_address('v2-foo-bar@remailer.example.com'))
		self.app.config['REMAILER_DOMAIN'] = 'new-remailer.example.com'
		self.assertIsNone(remailer.parse_address(ADDR_V1_S2))
		self.assertIsNone(remailer.parse_address(ADDR_V2_S2))
		self.app.config['REMAILER_OLD_DOMAINS'] = ['remailer.example.com']
		self.assertEqual(remailer.parse_address(ADDR_V1_S2), (SERVICE2_ID, USER_ID))
		self.assertEqual(remailer.parse_address(ADDR_V2_S2), (SERVICE2_ID, USER_ID))
		# REMAILER_SECRET_KEY behaviour
		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
		self.app.config['REMAILER_OLD_DOMAINS'] = []
		self.assertEqual(remailer.parse_address(ADDR_V1_S2), (SERVICE2_ID, USER_ID))
		self.assertEqual(remailer.parse_address(ADDR_V2_S2), (SERVICE2_ID, USER_ID))
		self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY']
		self.assertEqual(remailer.parse_address(ADDR_V1_S2), (SERVICE2_ID, USER_ID))
		self.assertEqual(remailer.parse_address(ADDR_V2_S2), (SERVICE2_ID, USER_ID))
		self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY'
		self.assertIsNone(remailer.parse_address(ADDR_V1_S2))
		self.assertIsNone(remailer.parse_address(ADDR_V2_S2))

tests/test_tasks.py

0 → 100644
+39 −0
Original line number Diff line number Diff line
import unittest

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

from uffd.tasks import CleanupTask

class TestCleanupTask(unittest.TestCase):
	def test(self):
		app = Flask(__name__)
		app.testing = True
		app.debug = True
		app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
		db = SQLAlchemy(app)
		cleanup_task = CleanupTask()

		@cleanup_task.delete_by_attribute('delete_me')
		class TestModel(db.Model):
			id = db.Column(db.Integer(), primary_key=True, autoincrement=True)
			delete_me = db.Column(db.Boolean(), default=False, nullable=False)

		with app.test_request_context():
			db.create_all()
			db.session.add(TestModel(delete_me=True))
			db.session.add(TestModel(delete_me=True))
			db.session.add(TestModel(delete_me=True))
			db.session.add(TestModel(delete_me=False))
			db.session.add(TestModel(delete_me=False))
			db.session.commit()
			db.session.expire_all()
			self.assertEqual(TestModel.query.count(), 5)

		with app.test_request_context():
			cleanup_task.run()
			db.session.commit()
			db.session.expire_all()

		with app.test_request_context():
			self.assertEqual(TestModel.query.count(), 2)

tests/test_utils.py

0 → 100644
+12 −0
Original line number Diff line number Diff line
from uffd.utils import nopad_b32decode, nopad_b32encode, nopad_urlsafe_b64decode, nopad_urlsafe_b64encode

from tests.utils import UffdTestCase

class TestUtils(UffdTestCase):
	def test_nopad_b32(self):
		for n in range(0, 32):
			self.assertEqual(b'X'*n, nopad_b32decode(nopad_b32encode(b'X'*n)))

	def test_nopad_b64(self):
		for n in range(0, 32):
			self.assertEqual(b'X'*n, nopad_urlsafe_b64decode(nopad_urlsafe_b64encode(b'X'*n)))

tests/utils.py

0 → 100644
+167 −0
Original line number Diff line number Diff line
import os
import unittest

from flask import url_for
import flask_migrate

from uffd import create_app, db
from uffd.models import User, Group, Mail

def dump(basename, resp):
	basename = basename.replace('.', '_').replace('/', '_')
	suffix = '.html'
	root = os.environ.get('DUMP_PAGES')
	if not root:
		return
	os.makedirs(root, exist_ok=True)
	path = os.path.join(root, basename+suffix)
	with open(path, 'wb') as f:
		f.write(resp.data)

def db_flush():
	db.session.rollback()
	db.session.expire_all()

class AppTestCase(unittest.TestCase):
	DISABLE_SQLITE_MEMORY_DB = False

	def setUp(self):
		config = {
			'TESTING': True,
			'DEBUG': True,
			'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
			'SECRET_KEY': 'DEBUGKEY',
			'MAIL_SKIP_SEND': True,
			'SELF_SIGNUP': True,
		}
		if self.DISABLE_SQLITE_MEMORY_DB:
			try:
				os.remove('/tmp/uffd-migration-test-db.sqlite3')
			except FileNotFoundError:
				pass
			config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/uffd-migration-test-db.sqlite3'
		if os.environ.get('TEST_WITH_MYSQL'):
			import MySQLdb
			conn = MySQLdb.connect(user='root', unix_socket='/var/run/mysqld/mysqld.sock')
			cur = conn.cursor()
			try:
				cur.execute('DROP DATABASE uffd_tests')
			except:
				pass
			cur.execute('CREATE DATABASE uffd_tests CHARACTER SET utf8mb4 COLLATE utf8mb4_nopad_bin')
			conn.close()
			config['SQLALCHEMY_DATABASE_URI'] = 'mysql+mysqldb:///uffd_tests?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4'
		self.app = create_app(config)
		self.setUpApp()

	def setUpApp(self):
		pass

	def tearDown(self):
		if self.DISABLE_SQLITE_MEMORY_DB:
			try:
				os.remove('/tmp/uffd-migration-test-db.sqlite3')
			except FileNotFoundError:
				pass

class MigrationTestCase(AppTestCase):
	DISABLE_SQLITE_MEMORY_DB = True

	REVISION = None

	def setUp(self):
		super().setUp()
		self.request_context = self.app.test_request_context()
		self.request_context.__enter__()
		if self.REVISION:
			flask_migrate.upgrade(revision=self.REVISION + '-1')

	def upgrade(self, revision='+1'):
		db.session.commit()
		flask_migrate.upgrade(revision=revision)

	def downgrade(self, revision='-1'):
		db.session.commit()
		flask_migrate.downgrade(revision=revision)

	def tearDown(self):
		db.session.rollback()
		self.request_context.__exit__(None, None, None)
		super().tearDown()

class ModelTestCase(AppTestCase):
	def setUp(self):
		super().setUp()
		self.request_context = self.app.test_request_context()
		self.request_context.__enter__()
		db.create_all()
		db.session.commit()

	def tearDown(self):
		db.session.rollback()
		self.request_context.__exit__(None, None, None)
		super().tearDown()

class UffdTestCase(AppTestCase):
	def setUp(self):
		super().setUp()
		self.client = self.app.test_client()
		self.client.__enter__()
		# Just do some request so that we can use url_for
		self.client.get(path='/')
		db.create_all()
		# This reflects the old LDAP example data
		users_group = Group(name='users', unix_gid=20001, description='Base group for all users')
		db.session.add(users_group)
		access_group = Group(name='uffd_access', unix_gid=20002, description='Access to Single-Sign-On and Selfservice')
		db.session.add(access_group)
		admin_group = Group(name='uffd_admin', unix_gid=20003, description='Admin access to uffd')
		db.session.add(admin_group)
		testuser = User(loginname='testuser', unix_uid=10000, password='userpassword', primary_email_address='test@example.com', displayname='Test User', groups=[users_group, access_group])
		db.session.add(testuser)
		testadmin = User(loginname='testadmin', unix_uid=10001, password='adminpassword', primary_email_address='admin@example.com', displayname='Test Admin', groups=[users_group, access_group, admin_group])
		db.session.add(testadmin)
		testmail = Mail(uid='test', receivers=['test1@example.com', 'test2@example.com'], destinations=['testuser@mail.example.com'])
		db.session.add(testmail)
		self.setUpDB()
		db.session.commit()

	def setUpDB(self):
		pass

	def tearDown(self):
		self.client.__exit__(None, None, None)
		super().tearDown()

	def get_user(self):
		return User.query.filter_by(loginname='testuser').one_or_none()

	def get_admin(self):
		return User.query.filter_by(loginname='testadmin').one_or_none()

	def get_admin_group(self):
		return Group.query.filter_by(name='uffd_admin').one_or_none()

	def get_access_group(self):
		return Group.query.filter_by(name='uffd_access').one_or_none()

	def get_users_group(self):
		return Group.query.filter_by(name='users').one_or_none()

	def get_mail(self):
		return Mail.query.filter_by(uid='test').one_or_none()

	def login_as(self, user, ref=None):
		# It is currently not possible to login while already logged in as another
		# user, so make sure that we are not logged in first
		self.client.get(path=url_for('session.logout'), follow_redirects=True)
		loginname = None
		password = None
		if user == 'user':
			loginname = 'testuser'
			password = 'userpassword'
		elif user == 'admin':
			loginname = 'testadmin'
			password = 'adminpassword'
		return self.client.post(path=url_for('session.login', ref=ref),
								data={'loginname': loginname, 'password': password}, follow_redirects=True)
+350 −0

File added.

Preview size limit exceeded, changes collapsed.