From 55e95a24cf5edf09510322dbaaa26b304f21ffd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 29 Mar 2025 00:32:17 +0100 Subject: [PATCH 1/5] allow configure username claim --- core/admin/mailu/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index f23cc50d..fa255ce8 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -205,7 +205,7 @@ class OicClient: return None, None, None, None user_response = self.client.do_user_info_request( access_token=response['access_token']) - return user_response['email'], user_response['sub'], response["id_token"], response + return user_response[app.config.get('OIDC_USERNAME_CLAIM', 'email')], user_response['sub'], response["id_token"], response def get_token(self, username, password): From 2a46097f04bb6d3fb4cd021dea92e918f0995f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 29 Mar 2025 00:32:51 +0100 Subject: [PATCH 2/5] allow configure domain if username is not an email address --- core/admin/mailu/sso/views/base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index 106e2522..7c26a469 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -41,7 +41,10 @@ def login(): flask.flash('Wrong e-mail or password', 'error') # TODO: Check if this is the correct way to handle this return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url()) - + + if '@' not in username: + username = username + '@' + app.config.get('OIDC_USER_DOMAIN', app.config['DOMAIN']) + user = models.User.get(username) if user is None: user = models.User.create(username) From d6de68e91b5900e8945cc2c76e98e18d0eff5f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=91cze=20Bence?= Date: Sat, 29 Mar 2025 00:33:09 +0100 Subject: [PATCH 3/5] document the new config variables --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f3c8161e..671a4443 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ properties are needed in `mailu.env`: | `OIDC_VERIFY_SSL` | Disable TLS certificate verification for the OIDC client | `True` \| `False` | | `OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED` | If enabled, OIDC users will have an button to get redirect to their OIDC provider to change their password | `True` \| `False` | | `OIDC_CHANGE_PASSWORD_REDIRECT_URL` | Defaults to provider issuer url appended by `/.well-known/change-password`. | [https://`host`/pw-change]() | +| `OIDC_USERNAME_CLAIM` | The OIDC claim used as the username. If the selected claim contains an email address, it will be used as is. If it is not an email (e.g., `sub`), the email address will be constructed as `@`. Defaults to `email`. | `email` \| `sub` +| `OIDC_USER_DOMAIN` | The domain used when constructing an email from a non-email username (e.g., when `OIDC_USERNAME_CLAIM=sub`). Ignored if `OIDC_USERNAME_CLAIM` is already an email. Defaults to the value of `DOMAIN`. | `example.com` Here is a snippet for easy copy paste: From c7b1571f8a1c1e48a5ff18b392bb32e0ccf8b520 Mon Sep 17 00:00:00 2001 From: Encotric Date: Sun, 30 Mar 2025 18:11:07 +0200 Subject: [PATCH 4/5] Merge branch 'master' into pr/pbence/64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pőcze Bence --- .github/workflows/build_test_deploy.yml | 48 ++- .github/workflows/multiarch.yml | 23 +- core/admin/mailu/api/common.py | 2 +- core/admin/mailu/configuration.py | 2 +- core/admin/mailu/internal/nginx.py | 7 +- core/admin/mailu/internal/views/auth.py | 2 +- core/admin/mailu/internal/views/autoconfig.py | 2 +- core/admin/mailu/limiter.py | 20 +- core/admin/mailu/models.py | 6 +- core/admin/mailu/oidc.py | 277 ++++++++++++++++++ core/admin/mailu/sso/views/base.py | 87 +++--- core/admin/mailu/ui/access.py | 2 +- core/admin/mailu/ui/templates/sidebar.html | 2 +- core/admin/mailu/utils.py | 193 +----------- core/base/libs/socrate/socrate/system.py | 2 +- tests/compose/core/05_connectivity.py | 2 +- tests/compose/filters/eicar.com.txt | 2 +- tests/requirements.txt | 2 +- towncrier/newsfragments/3690.bugfix | 1 + towncrier/newsfragments/3696.bugfix | 1 + towncrier/newsfragments/58.misc | 3 + towncrier/newsfragments/59.bugfix | 1 + 22 files changed, 404 insertions(+), 283 deletions(-) create mode 100644 core/admin/mailu/oidc.py create mode 100644 towncrier/newsfragments/3690.bugfix create mode 100644 towncrier/newsfragments/3696.bugfix create mode 100644 towncrier/newsfragments/58.misc create mode 100644 towncrier/newsfragments/59.bugfix diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 8eb30cd5..ae49f9ba 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -4,10 +4,6 @@ # Username of docker login for logging in docker for pulling images (higher pull rate limit) # ${{ secrets.Docker_Password }} # Password of docker login for logging in docker for pulling images (higher pull rate limit) -# ${{ secrets.Docker_Login2 }} -# Second Username of docker login for logging in docker for pulling images (higher pull rate limit) -# ${{ secrets.Docker_Password2 }} -# Second Password of docker login for logging in docker for pulling images (higher pull rate limit) ################################################ name: build-test-deploy @@ -37,14 +33,14 @@ on: type: string deploy: description: Deploy to container registry. Happens for all branches but staging. Use string true or false. - default: true + default: 'true' required: false - type: boolean + type: string release: description: Tag and create the github release. Use string true or false. - default: false + default: 'false' required: false - type: boolean + type: string workflow_dispatch: inputs: @@ -71,14 +67,14 @@ on: type: string deploy: description: Deploy to container registry. Happens for all branches but staging. Use string true or false. - default: true + default: 'true' required: false - type: boolean + type: string release: description: Tag and create the github release. Use string true or false. - default: false + default: 'false' required: false - type: boolean + type: string env: HCL_FILE: ./tests/build-ci.hcl @@ -87,7 +83,7 @@ jobs: # This is used by the next build job. targets: name: create targets - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: matrix: ${{ steps.targets.outputs.matrix }} steps: @@ -109,7 +105,7 @@ jobs: fail-fast: false matrix: target: ["base", "assets"] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read packages: write @@ -184,7 +180,7 @@ jobs: fail-fast: false matrix: target: ["base", "assets"] - runs-on: self-hosted + runs-on: ubuntu-24.04-arm permissions: contents: read packages: write @@ -219,8 +215,8 @@ jobs: PINNED_LABEL_VERSION: ${{ env.PINNED_MAILU_VERSION }} ARCH: linux/arm64/v8,linux/arm/v7 BUILDER: ${{ steps.uuid.outputs.uuid }} - DOCKER_LOGIN2: ${{ secrets.Docker_Login2 }} - DOCKER_PASSW2: ${{ secrets.Docker_Password2 }} + DOCKER_LOGIN: ${{ secrets.Docker_Login }} + DOCKER_PASSW: ${{ secrets.Docker_Password }} BUILDX_NO_DEFAULT_ATTESTATIONS: 1 uses: nick-fields/retry@v3 with: @@ -232,7 +228,7 @@ jobs: set -euxo pipefail \ ; /usr/bin/docker info \ ; echo "${{ github.token }}" | docker login --username "${{ github.repository_owner }}" --password-stdin ghcr.io \ - ; echo "$DOCKER_PASSW2" | docker login --username "$DOCKER_LOGIN2" --password-stdin \ + ; echo "$DOCKER_PASSW" | docker login --username "$DOCKER_LOGIN" --password-stdin \ ; /usr/bin/docker buildx rm builder-${{ env.BUILDER }} \ || echo "builder does not exist" \ ; /usr/bin/docker buildx create --name builder-${{ env.BUILDER }} --driver docker-container --use \ @@ -261,7 +257,7 @@ jobs: fail-fast: false matrix: target: ${{ fromJson(needs.targets.outputs.matrix) }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read packages: write @@ -339,7 +335,7 @@ jobs: fail-fast: false matrix: target: ${{ fromJson(needs.targets.outputs.matrix) }} - runs-on: self-hosted + runs-on: ubuntu-24.04-arm permissions: contents: read packages: write @@ -374,8 +370,8 @@ jobs: PINNED_LABEL_VERSION: ${{ env.PINNED_MAILU_VERSION }} ARCH: linux/arm64/v8,linux/arm/v7 BUILDER: ${{ steps.uuid.outputs.uuid }} - DOCKER_LOGIN2: ${{ secrets.Docker_Login2 }} - DOCKER_PASSW2: ${{ secrets.Docker_Password2 }} + DOCKER_LOGIN: ${{ secrets.Docker_Login }} + DOCKER_PASSW: ${{ secrets.Docker_Password }} BUILDX_NO_DEFAULT_ATTESTATIONS: 1 uses: nick-fields/retry@v3 with: @@ -387,7 +383,7 @@ jobs: set -euxo pipefail \ ; /usr/bin/docker info \ ; echo "${{ github.token }}" | docker login --username "${{ github.repository_owner }}" --password-stdin ghcr.io \ - ; echo "$DOCKER_PASSW2" | docker login --username "$DOCKER_LOGIN2" --password-stdin \ + ; echo "$DOCKER_PASSW" | docker login --username "$DOCKER_LOGIN" --password-stdin \ ; /usr/bin/docker buildx rm builder-${{ env.BUILDER }} \ || echo "builder does not exist" \ ; /usr/bin/docker buildx create --name builder-${{ env.BUILDER }} --driver docker-container --use \ @@ -409,7 +405,7 @@ jobs: tests: name: tests if: contains(inputs.architecture, 'linux/amd64') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read packages: read @@ -466,7 +462,7 @@ jobs: name: Deploy images # Deploying is not required for staging if: inputs.deploy == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: - build - build-arm @@ -535,7 +531,7 @@ jobs: #This job creates a tagged release. A tag is created for the pinned version x.y.z. The GH release refers to this tag. tag-release: if: inputs.release == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: - deploy steps: diff --git a/.github/workflows/multiarch.yml b/.github/workflows/multiarch.yml index 4d8f8a2c..3fd85f0a 100644 --- a/.github/workflows/multiarch.yml +++ b/.github/workflows/multiarch.yml @@ -4,6 +4,9 @@ on: branches: - master - oidc + push: + branches: + - '2024.06' merge_group: concurrency: ci-multiarch-${{ github.ref }} @@ -83,29 +86,15 @@ jobs: - derive-variables uses: ./.github/workflows/build_test_deploy.yml with: - # linux/arm64/v8,linux/arm/v7 will be added when GitHub hosted arm64 runners are available by the end of 2024 - # https://github.blog/news-insights/product-news/arm64-on-github-actions-powering-faster-more-efficient-build-systems/ - architecture: 'linux/amd64' + architecture: 'linux/amd64,linux/arm64/v8,linux/arm/v7' mailu_version: ${{needs.derive-variables.outputs.MAILU_VERSION}} pinned_mailu_version: ${{needs.derive-variables.outputs.PINNED_MAILU_VERSION}} docker_org: ${{needs.derive-variables.outputs.DOCKER_ORG}} branch: ${{needs.derive-variables.outputs.BRANCH}} - deploy: ${{needs.derive-variables.outputs.DEPLOY == 'true'}} - release: ${{needs.derive-variables.outputs.RELEASE == 'true'}} + deploy: ${{needs.derive-variables.outputs.DEPLOY}} + release: ${{needs.derive-variables.outputs.RELEASE}} secrets: inherit - # This job is watched by bors. It only complets if building,testing and deploy worked. - ci-success: - name: CI-Done - #Returns true when none of the **previous** steps have failed or have been canceled. - if: success() - needs: - - build-test-deploy - runs-on: ubuntu-latest - steps: - - name: CI/CD succeeded. - run: exit 0 - ################################################ # Code block that is used as one liner for the step: # Derive PINNED_MAILU_VERSION and DEPLOY/RELEASE for normal release x.y diff --git a/core/admin/mailu/api/common.py b/core/admin/mailu/api/common.py index 6dc75a88..429d806c 100644 --- a/core/admin/mailu/api/common.py +++ b/core/admin/mailu/api/common.py @@ -27,7 +27,7 @@ def api_token_authorization(func): abort(401, 'A valid Authorization header is mandatory') if len(v1.api_token) < 4 or not hmac.compare_digest(request.headers.get('Authorization').removeprefix('Bearer '), v1.api_token): utils.limiter.rate_limit_ip(client_ip) - flask.current_app.logger.warn(f'Invalid API token provided by {client_ip}.') + flask.current_app.logger.warning(f'Invalid API token provided by {client_ip}.') abort(403, 'Invalid API token') flask.current_app.logger.info(f'Valid API token provided by {client_ip}.') return func(*args, **kwds) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 99fec9a0..fdcf4819 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -157,7 +157,7 @@ class ConfigManager: self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2' self.config['SESSION_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/3' - self.config['SESSION_COOKIE_SAMESITE'] = 'Strict' + self.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # TODO: enhance security here self.config['SESSION_COOKIE_HTTPONLY'] = True if self.config['SESSION_COOKIE_SECURE'] is None: self.config['SESSION_COOKIE_SECURE'] = self.config['TLS_FLAVOR'] != 'notls' diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index b9cbe879..e286381f 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -14,6 +14,7 @@ STATUSES = { "imap": "AUTHENTICATIONFAILED", "smtp": "535 5.7.8", "submission": "535 5.7.8", + "lmtp": "535 5.7.8", "pop3": "-ERR Authentication failed", "sieve": "AuthFailed" }), @@ -21,6 +22,7 @@ STATUSES = { "imap": "PRIVACYREQUIRED", "smtp": "530 5.7.0", "submission": "530 5.7.0", + "lmtp": "530 5.7.0", "pop3": "-ERR Authentication canceled.", "sieve": "ENCRYPT-NEEDED" }), @@ -28,6 +30,7 @@ STATUSES = { "imap": "LIMIT", "smtp": "451 4.3.2", "submission": "451 4.3.2", + "lmtp": "451 4.3.2", "pop3": "-ERR [LOGIN-DELAY] Retry later", "sieve": "AuthFailed" }), @@ -102,13 +105,13 @@ def handle_authentication(headers): password = urllib.parse.unquote(headers["Auth-Pass"]) ip = urllib.parse.unquote(headers["Client-Ip"]) except: - app.logger.warn(f'Received undecodable user/password from front: {headers.get("Auth-User", "")!r}') + app.logger.warning(f'Received undecodable user/password from front: {headers.get("Auth-User", "")!r}') else: try: user = models.User.query.get(user_email) if '@' in user_email else None except sqlalchemy.exc.StatementError as exc: exc = str(exc).split('\n', 1)[0] - app.logger.warn(f'Invalid user {user_email!r}: {exc}') + app.logger.warning(f'Invalid user {user_email!r}: {exc}') else: is_valid_user = user is not None ip = urllib.parse.unquote(headers["Client-Ip"]) diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index c74bcc9e..c619366f 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -103,7 +103,7 @@ def basic_authentication(): user = models.User.query.get(user_email) if '@' in user_email else None except sqlalchemy.exc.StatementError as exc: exc = str(exc).split('\n', 1)[0] - app.logger.warn(f'Invalid user {user_email!r}: {exc}') + app.logger.warning(f'Invalid user {user_email!r}: {exc}') else: if user is not None and nginx.check_credentials(user, password.decode('utf-8'), client_ip, "web", flask.request.headers.get('X-Real-Port', None), user_email): response = flask.Response() diff --git a/core/admin/mailu/internal/views/autoconfig.py b/core/admin/mailu/internal/views/autoconfig.py index bf116f4b..46d6531f 100644 --- a/core/admin/mailu/internal/views/autoconfig.py +++ b/core/admin/mailu/internal/views/autoconfig.py @@ -180,4 +180,4 @@ def autoconfig_apple(): 1 \r\n''' - return flask.Response(xml, mimetype='text/xml', status=200) + return flask.Response(xml, content_type='application/x-apple-aspen-config', status=200) diff --git a/core/admin/mailu/limiter.py b/core/admin/mailu/limiter.py index 6fc078c1..e22b39a2 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -49,7 +49,7 @@ class LimitWraperFactory(object): client_network = utils.extract_network_from_ip(ip) is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network) if is_rate_limited: - app.logger.warn(f'Authentication attempt from {ip} has been rate-limited.') + app.logger.warning(f'Authentication attempt from {ip} has been rate-limited.') return is_rate_limited def rate_limit_ip(self, ip, username=None): @@ -65,7 +65,7 @@ class LimitWraperFactory(object): limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username) if is_rate_limited: - app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.') + app.logger.warning(f'Authentication attempt from {ip} for {username} has been rate-limited.') return is_rate_limited def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=''): @@ -78,10 +78,10 @@ class LimitWraperFactory(object): limiter.hit(device_cookie if device_cookie_name == username else username) self.rate_limit_ip(ip, username) - """ Device cookies as described on: - https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies - """ - def parse_device_cookie(self, cookie): + def parse_device_cookie(self, cookie: str): + """ Device cookies as described on: + https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies + """ try: login, nonce, _ = cookie.split('$') if hmac.compare_digest(cookie, self.device_cookie(login, nonce)): @@ -90,11 +90,11 @@ class LimitWraperFactory(object): pass return None, None - """ Device cookies don't require strong crypto: - 72bits of nonce, 96bits of signature is more than enough - and these values avoid padding in most cases - """ def device_cookie(self, username, nonce=None): + """ Device cookies don't require strong crypto: + 72bits of nonce, 96bits of signature is more than enough + and these values avoid padding in most cases + """ if not nonce: nonce = secrets.token_urlsafe(9) sig = str(base64.urlsafe_b64encode(hmac.new(app.device_cookie_key, bytearray(f'device_cookie|{username}|{nonce}', 'utf-8'), 'sha256').digest()[20:]), 'utf-8') diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index 88b8bd46..19ad1fb1 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -560,12 +560,12 @@ class User(Base, Email): def is_authenticated(self): if 'oidc_token' not in session: return self._is_authenticated - token = utils.oic_client.check_validity(self.openid_token) + token = utils.oic_client.check_validity(self.oidc_token) if token is None: flask_login.logout_user() session.destroy() return False - session['openid_token'] = token + session['oidc_token'] = token return True # [OIDC] is_authenticated property setter @@ -635,7 +635,7 @@ class User(Base, Email): return False # [OIDC] Check if the user is authenticated with OIDC - if utils.oic_client.is_enabled() and 'openid_token' in session: + if utils.oic_client.is_enabled() and 'oidc_token' in session: return self.is_authenticated() cache_result = self._credential_cache.get(self.get_id()) diff --git a/core/admin/mailu/oidc.py b/core/admin/mailu/oidc.py new file mode 100644 index 00000000..df3214c1 --- /dev/null +++ b/core/admin/mailu/oidc.py @@ -0,0 +1,277 @@ +"""OIDC Client""" + +from time import time +from typing import Optional +import flask + +# [OIDC] Import the OIDC related modules +from oic.oic import Client +from oic.exception import PyoidcError +from oic.extension.client import Client as ExtensionClient +from oic.utils.authn.client import CLIENT_AUTHN_METHOD +from oic.utils.settings import OicClientSettings +from oic import rndstr +from oic.exception import MessageException, NotForMe +from oic.oauth2.message import ( + AccessTokenResponse, + ErrorResponse, +) +from oic.oic.message import ( + AuthorizationResponse, + RegistrationResponse, + BackChannelLogoutRequest, + OpenIDSchema, + UserInfoErrorResponse, +) +from oic.oauth2.grant import Token + +# [OIDC] Client class +class OicClient: + """OpenID Connect Client""" + + ready: bool = False + app: Optional[flask.Flask] = None + client: Optional[Client] = None + extension_client: Optional[ExtensionClient] = None + registration_response: Optional[RegistrationResponse] = None + enable_change_password_redirect: bool = True + change_password_url: Optional[str] = None + allowed_hostnames: list[str] = [] + + def receive_provider_info(self): + self.app.logger.info("[OIDC] Getting provider config..") + try: + self.client.provider_config(self.app.config["OIDC_PROVIDER_INFO_URL"]) + self.extension_client.provider_config(self.app.config["OIDC_PROVIDER_INFO_URL"]) + + self.change_password_url = self.app.config["OIDC_CHANGE_PASSWORD_REDIRECT_URL"] or ( + self.client.issuer + "/.well-known/change-password" + ) + + redirect_uris = [f"{hostname}/sso/login" for hostname in self.allowed_hostnames] + + client_reg = RegistrationResponse( + client_id=self.app.config["OIDC_CLIENT_ID"], + client_secret=self.app.config["OIDC_CLIENT_SECRET"], + redirect_uris=redirect_uris, + ) + + self.client.store_registration_info(client_reg) + self.extension_client.store_registration_info(client_reg) + + self.ready = True + except Exception as e: + self.app.logger.warning(f"[OIDC] Error getting provider config: {e}") + self.app.logger.warning("[OIDC] Retrying with the next request..") + return self.ready + + def init_app(self, app: flask.Flask): + """Initialize OIDC client""" + + self.app = app + self.allowed_hostnames = [host.strip() for host in self.app.config['HOSTNAMES'].split(',')] + + settings = OicClientSettings(verify_ssl=app.config["OIDC_VERIFY_SSL"]) + + self.enable_change_password_redirect = ( + app.config["OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED"] or False + ) + + self.client = Client(client_authn_method=CLIENT_AUTHN_METHOD, settings=settings) + self.extension_client = ExtensionClient( + client_authn_method=CLIENT_AUTHN_METHOD, settings=settings + ) + + def get_redirect_url(self) -> Optional[str]: + """Get the redirect URL""" + if not self.is_enabled(): + return None + + if not self.ready and self.receive_provider_info() == False: + return None + + flask.session["state"] = rndstr() + flask.session["nonce"] = rndstr() + + redirect_uri = flask.request.host_url + "sso/login" + + if self.app.config["OIDC_REDIRECT_URL"]: + redirect_uri = self.app.config["OIDC_REDIRECT_URL"] + elif flask.request.host not in self.allowed_hostnames: + return None + + args = { + "client_id": self.client.client_id, + "response_type": ["code"], + "scope": ["openid", "email"], + "nonce": flask.session["nonce"], + "redirect_uri": redirect_uri, + "state": flask.session["state"], + } + + auth_req = self.client.construct_AuthorizationRequest(request_args=args) + login_url = auth_req.request(self.client.authorization_endpoint) + return login_url + + def _get_authorization_code(self, query: str) -> Optional[AuthorizationResponse]: + """ + Get the authorization response and check if it is valid + + :param query: The query string + :return: The authorization code if valid, None otherwise + """ + + auth_response = self.client.parse_response( + AuthorizationResponse, info=query, sformat="urlencoded" + ) + + if not isinstance(auth_response, AuthorizationResponse): + self.app.logger.debug(f"[OIDC] Error response in authorization: {auth_response}") + raise PyoidcError("Error response in authorization") + + if "state" not in flask.session: + self.app.logger.warning("[OIDC] No state in session") + raise PyoidcError("No state in session") + + if flask.session["state"] != auth_response["state"]: + self.app.logger.warning( + f"[OIDC] State mismatch: expected {flask.session['state']}, got {auth_response['state']}" + ) + raise PyoidcError("State mismatch") + + return auth_response["code"] + + def _get_id_and_access_tokens(self, auth_response_code: str): + """ + Get the id and access tokens + + :param auth_response_code: The authorization response code + :return: The token response if valid, None otherwise + """ + + token_response = self.client.do_access_token_request( + state=flask.session["state"], + request_args={"code": auth_response_code}, + authn_method="client_secret_basic", + ) + + if not isinstance(token_response, AccessTokenResponse): + self.app.logger.warning( + f"[OIDC] No access token or invalid response: {token_response}" + ) + raise PyoidcError("No access token or invalid response") + + if "id_token" not in token_response: + self.app.logger.warning("[OIDC] No id token in response") + raise PyoidcError("No id token in response") + + if token_response["id_token"]["nonce"] != flask.session["nonce"]: + self.app.logger.warning("[OIDC] Nonce mismatch") + raise PyoidcError("Nonce mismatch") + + if "access_token" not in token_response: + self.app.logger.warning("[OIDC] No access token or invalid response") + raise PyoidcError("No access token or invalid response") + + return token_response + + def exchange_code( + self, query: str + ) -> tuple[str, str, str, AccessTokenResponse] | tuple[None, None, None, None]: + """Exchange the code for the token""" + + if not self.ready and self.receive_provider_info() == False: + return None, None, None, None + + auth_response_code = self._get_authorization_code(query) + if not auth_response_code: + raise PyoidcError("Error response in authorization") + + token_response = self._get_id_and_access_tokens(auth_response_code) + if not token_response: + raise PyoidcError("Error response in token") + + user_info_response = self.get_user_info(token_response) + if not isinstance(user_info_response, OpenIDSchema): + self.app.logger.debug("[OIDC] Error response in user info") + raise PyoidcError("Error response in user info") + + return ( + user_info_response[self.app.config.get('OIDC_USERNAME_CLAIM', 'email')], + user_info_response['sub'], + token_response["id_token"], + token_response + ) + + def get_user_info( + self, token: AccessTokenResponse + ) -> OpenIDSchema | UserInfoErrorResponse | ErrorResponse: + """Get user info from the token""" + return self.client.do_user_info_request(token=token["access_token"]) + + def check_validity( + self, token: AccessTokenResponse + ) -> Optional[AccessTokenResponse]: + """Check if the token is still valid""" + + # Assume the token is valid if it has an expiration time and it has not expired + if "exp" in token["id_token"] and token["id_token"]["exp"] > time(): + return token + return self.refresh_token(token) + + def refresh_token( + self, token: AccessTokenResponse + ) -> Optional[AccessTokenResponse]: + """Refresh the token""" + try: + args = {"refresh_token": token["refresh_token"]} + response = self.client.do_access_token_refresh( + request_args=args, token=Token(token) + ) + if isinstance(response, AccessTokenResponse): + return response + + except Exception as e: + flask.current_app.logger.error(f"Error refreshing token: {e}") + return None + + def backchannel_logout(self, body): + """ + Backchannel logout. See https://openid.net/specs/openid-connect-backchannel-1_0.html + """ + + # TODO: Finish backchannel logout implementation + req = BackChannelLogoutRequest().from_dict(body) + + kwargs = { + "aud": self.client.client_id, + "iss": self.client.issuer, + "keyjar": self.client.keyjar, + } + + try: + req.verify(**kwargs) + except (MessageException, ValueError, NotForMe) as err: + self.app.logger.error(err) + return None + + sub = req["logout_token"]["sub"] + + if sub is not None and sub != "": + return sub + + return True + + def is_enabled(self): + """Check if OIDC is enabled""" + + if self.app is not None and self.app.config["OIDC_ENABLED"]: + return True + return False + + def change_password(self) -> Optional[str]: + """Get the URL for changing password if the redirect is enabled""" + + if self.enable_change_password_redirect: + return self.change_password_url + return None diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index 7c26a469..f8f7e986 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -13,7 +13,7 @@ from urllib.parse import urlparse, urljoin, unquote @sso.route('/login', methods=['GET', 'POST']) def login(): - if flask.request.headers.get(app.config['PROXY_AUTH_HEADER']) and not 'noproxyauth' in flask.request.url: + if flask.request.headers.get(app.config['PROXY_AUTH_HEADER']) and 'noproxyauth' not in flask.request.url: return _proxy() client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) @@ -31,37 +31,7 @@ def login(): fields = [] - # [OIDC] Add the OIDC login flow - if 'code' in flask.request.args: - username, sub, id_token, token_response = utils.oic_client.exchange_code(flask.request.query_string.decode()) - - if username is None: - utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip) - flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.') - flask.flash('Wrong e-mail or password', 'error') - # TODO: Check if this is the correct way to handle this - return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url()) - - if '@' not in username: - username = username + '@' + app.config.get('OIDC_USER_DOMAIN', app.config['DOMAIN']) - - user = models.User.get(username) - if user is None: - user = models.User.create(username) - - flask.session['openid_token'] = token_response - flask.session['openid_sub'] = sub - flask.session['openid_id_token'] = id_token - flask.session.regenerate() - - flask_login.login_user(user) - - response = redirect(app.config['WEB_ADMIN']) - response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True) - flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') - return response - - if 'url' in flask.request.args and not 'homepage' in flask.request.url: + if 'url' in flask.request.args and 'homepage' not in flask.request.url: fields.append(form.submitAdmin) else: form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin' @@ -72,6 +42,39 @@ def login(): fields.append(form.submitAdmin) fields = [fields] + # [OIDC] Add the OIDC login flow + if 'code' in flask.request.args: + try: + username, sub, id_token, token_response = utils.oic_client.exchange_code(flask.request.query_string.decode()) + + if username is None: + utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip) + flask.current_app.logger.warning(f'Login failed for {username} from {client_ip}.') + flask.flash('Wrong e-mail or password', 'error') + return render_oidc_template(form, fields) + + if '@' not in username: + username = username + '@' + app.config.get('OIDC_USER_DOMAIN', app.config['DOMAIN']) + + user = models.User.get(username) + if user is None: + user = models.User.create(username) + + flask.session.regenerate() + + flask.session['oidc_token'] = token_response + flask.session['oidc_sub'] = sub + flask.session['oidc_id_token'] = id_token + + flask_login.login_user(user) + + response = redirect(flask.session['redirect_to'] if 'redirect_to' in flask.session else app.config['WEB_ADMIN']) + response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True) + flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') + return response + except Exception as e: + flask.flash(str(e), 'error') + if form.validate_on_submit(): if destination := _has_usable_redirect(): pass @@ -85,10 +88,10 @@ def login(): if not utils.is_app_token(form.pw.data): if username != device_cookie_username and utils.limiter.should_rate_limit_ip(client_ip): flask.flash(_('Too many attempts from your IP (rate-limit)'), 'error') - return flask.render_template('login.html', form=form, fields=fields) + return render_oidc_template(form, fields) if utils.limiter.should_rate_limit_user(username, client_ip, device_cookie, device_cookie_username): flask.flash(_('Too many attempts for this user (rate-limit)'), 'error') - return flask.render_template('login.html', form=form, fields=fields) + return render_oidc_template(form, fields) user = models.User.login(username, form.pw.data) if user: flask.session.regenerate() @@ -107,7 +110,7 @@ def login(): flask.current_app.logger.info(f'Login attempt for: {username}/sso/{flask.request.headers.get("X-Forwarded-Proto")} from: {client_ip}/{client_port}: failed: badauth: {utils.truncated_pw_hash(form.pw.data)}') flask.flash(_('Wrong e-mail or password'), 'error') # [OIDC] Forward the OIDC data to the login template - return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url()) + return render_oidc_template(form, fields) @sso.route('/pw_change', methods=['GET', 'POST']) @access.authenticated @@ -135,11 +138,11 @@ def pw_change(): user.change_pw_next_login = False models.db.session.commit() flask.current_app.logger.info(f'Forced password change by {user} from: {client_ip}/{client_port}: success: password: {form.pwned.data}') - destination = flask.session.pop('redir_to', None) or app.config['WEB_ADMIN'] + destination = flask.session.pop('redirect_to', None) or app.config['WEB_ADMIN'] return flask.redirect(destination) flask.flash(_("The current password is incorrect!"), "error") - return flask.render_template('pw_change.html', form=form) + return flask.render_template('pw_change.html', form=form, fields=[]) @sso.route('/logout', methods=['GET']) @access.authenticated @@ -221,3 +224,13 @@ def _proxy(): user.send_welcome() flask.current_app.logger.info(f'Login succeeded by proxy created user: {user} from {client_ip} through {flask.request.remote_addr}.') return flask.redirect(url) + +def render_oidc_template(form, fields): + redirect_url = utils.oic_client.get_redirect_url() + if 'url' in flask.request.args: + flask.session['redirect_to'] = flask.request.args.get('url') + + oidc_enabled = utils.oic_client.is_enabled() + if redirect_url is None: + oidc_enabled = False + return flask.render_template('login.html', form=form, fields=fields, openId=oidc_enabled, openIdEndpoint=redirect_url) \ No newline at end of file diff --git a/core/admin/mailu/ui/access.py b/core/admin/mailu/ui/access.py index 6a923c8e..aa3a7d11 100644 --- a/core/admin/mailu/ui/access.py +++ b/core/admin/mailu/ui/access.py @@ -90,7 +90,7 @@ def owner(args, kwargs, model, key): def authenticated(args, kwargs): """ The view is only available to logged in users. """ - return True + return flask_login.current_user.is_authenticated diff --git a/core/admin/mailu/ui/templates/sidebar.html b/core/admin/mailu/ui/templates/sidebar.html index 089b7cfe..f52a07c2 100644 --- a/core/admin/mailu/ui/templates/sidebar.html +++ b/core/admin/mailu/ui/templates/sidebar.html @@ -24,7 +24,7 @@ {%- endif %} diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index fa255ce8..59c839f9 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -1,10 +1,14 @@ """ Mailu admin app utilities """ -try: - import cPickle as pickle -except ImportError: - import pickle +from datetime import datetime, timedelta +import hmac +import ipaddress +from multiprocessing import Value +import pickle +import secrets +import string +import time import dns.resolver import dns.exception @@ -13,38 +17,20 @@ import dns.rdtypes import dns.rdatatype import dns.rdataclass -import hmac -import secrets -import string -import time - -from multiprocessing import Value -from mailu import limiter -from flask import current_app as app - import flask +from flask import current_app as app +from flask.sessions import SessionMixin, SessionInterface import flask_login import flask_migrate import flask_babel -import ipaddress + import redis -from datetime import datetime, timedelta -from flask.sessions import SessionMixin, SessionInterface from itsdangerous.encoding import want_bytes from werkzeug.datastructures import CallbackDict from werkzeug.middleware.proxy_fix import ProxyFix -# [OIDC] Import the OIDC related modules -from oic.oic import Client -from oic.extension.client import Client as ExtensionClient -from oic.utils.authn.client import CLIENT_AUTHN_METHOD -from oic.utils.settings import OicClientSettings -from oic import rndstr -from oic.exception import MessageException, NotForMe -from oic.oauth2.message import ROPCAccessTokenRequest, AccessTokenResponse -from oic.oic.message import AuthorizationResponse, RegistrationResponse, EndSessionRequest, BackChannelLogoutRequest -from oic.oauth2.grant import Token +from mailu import limiter, oidc # Login configuration login = flask_login.LoginManager() @@ -137,157 +123,8 @@ class PrefixMiddleware(object): proxy = PrefixMiddleware() -# [OIDC] Client class -class OicClient: - "Redirects user to OpenID Provider if configured" - - def __init__(self): - self.app = None - self.client = None - self.extension_client = None - self.registration_response = None - self.change_password_redirect_enabled = True, - self.change_password_url = None - - def init_app(self, app): - self.app = app - - settings = OicClientSettings() - - settings.verify_ssl = app.config['OIDC_VERIFY_SSL'] - - self.change_password_redirect_enabled = app.config['OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED'] - - self.client = Client(client_authn_method=CLIENT_AUTHN_METHOD,settings=settings) - self.client.provider_config(app.config['OIDC_PROVIDER_INFO_URL']) - - self.extension_client = ExtensionClient(client_authn_method=CLIENT_AUTHN_METHOD,settings=settings) - self.extension_client.provider_config(app.config['OIDC_PROVIDER_INFO_URL']) - self.change_password_url = app.config['OIDC_CHANGE_PASSWORD_REDIRECT_URL'] or (self.client.issuer + '/.well-known/change-password') - self.redirect_url = app.config['OIDC_REDIRECT_URL'] or ("https://" + self.app.config['HOSTNAME']) - info = {"client_id": app.config['OIDC_CLIENT_ID'], "client_secret": app.config['OIDC_CLIENT_SECRET'], "redirect_uris": [ self.redirect_url + "/sso/login" ]} - client_reg = RegistrationResponse(**info) - self.client.store_registration_info(client_reg) - self.extension_client.store_registration_info(client_reg) - - def get_redirect_url(self): - if not self.is_enabled(): - return None - flask.session["state"] = rndstr() - flask.session["nonce"] = rndstr() - args = { - "client_id": self.client.client_id, - "response_type": ["code"], - "scope": ["openid", "email"], - "nonce": flask.session["nonce"], - "redirect_uri": self.redirect_url + "/sso/login", - "state": flask.session["state"] - } - - auth_req = self.client.construct_AuthorizationRequest(request_args=args) - login_url = auth_req.request(self.client.authorization_endpoint) - return login_url - - def exchange_code(self, query): - aresp = self.client.parse_response(AuthorizationResponse, info=query, sformat="urlencoded") - if not ("state" in flask.session and aresp["state"] == flask.session["state"]): - return None, None, None, None - args = { - "code": aresp["code"] - } - response = self.client.do_access_token_request(state=aresp["state"], - request_args=args, - authn_method="client_secret_basic") - - if "id_token" not in response or response["id_token"]["nonce"] != flask.session["nonce"]: - return None, None, None, None - if 'access_token' not in response or not isinstance(response, AccessTokenResponse): - return None, None, None, None - user_response = self.client.do_user_info_request( - access_token=response['access_token']) - return user_response[app.config.get('OIDC_USERNAME_CLAIM', 'email')], user_response['sub'], response["id_token"], response - - - def get_token(self, username, password): - args = { - "username": username, - "password": password, - "client_id": self.extension_client.client_id, - "client_secret": self.extension_client.client_secret, - "grant_type": "password" - } - url, body, ht_args, csi = self.extension_client.request_info(ROPCAccessTokenRequest, - request_args=args, method="POST") - response = self.extension_client.request_and_return(url, AccessTokenResponse, "POST", body, "json", "", ht_args) - if isinstance(response, AccessTokenResponse): - return response - return None - - - def get_user_info(self, token): - return self.client.do_user_info_request( - access_token=token['access_token']) - - def check_validity(self, token): - if 'exp' in token['id_token'] and token['id_token']['exp'] > time.time(): - return token - else: - return self.refresh_token(token) - - def refresh_token(self, token): - try: - args = { - "refresh_token": token['refresh_token'] - } - response = self.client.do_access_token_refresh(request_args=args, token=Token(token)) - if isinstance(response, AccessTokenResponse): - return response - else: - return None - except Exception as e: - print(e) - return None - - def logout(self, id_token): - state = rndstr() - flask.session['state'] = state - - args = { - "state": state, - "id_token_hint": id_token, - "post_logout_redirect_uri": self.redirect_url + "/sso/logout", - "client_id": self.client.client_id - } - - request = self.client.construct_EndSessionRequest(request_args=args) - uri, body, h_args, cis = self.client.uri_and_body(EndSessionRequest, method="GET", request_args=args, cis=request) - return uri - - def backchannel_logout(self, body): - req = BackChannelLogoutRequest().from_dict(body) - - kwargs = {"aud": self.client.client_id, "iss": self.client.issuer, "keyjar": self.client.keyjar} - - try: - req.verify(**kwargs) - except (MessageException, ValueError, NotForMe) as err: - self.app.logger.error(err) - return False - - sub = req["logout_token"]["sub"] - - if sub is not None and sub != '': - MailuSessionExtension.prune_sessions(None, None, self.app, sub) - - return True - - def is_enabled(self): - return self.app is not None and self.app.config['OIDC_ENABLED'] - - def change_password(self): - return self.change_password_url if self.change_password_redirect_enabled else None - -oic_client = OicClient() +# OIDC client +oic_client = oidc.OicClient() # Data migrate migrate = flask_migrate.Migrate() @@ -629,7 +466,7 @@ class MailuSessionExtension: if key not in keep and not key.startswith(b'token-'): # [OIDC] Prune sessions by sub session = MailuSession(key, app) if sub is not None else None - if sub is None or ('openid_sub' in session and session['openid_sub'] == sub): + if sub is None or ('oidc_sub' in session and session['oidc_sub'] == sub): app.session_store.delete(key) count += 1 diff --git a/core/base/libs/socrate/socrate/system.py b/core/base/libs/socrate/socrate/system.py index d92caf0c..38e4b7ba 100644 --- a/core/base/libs/socrate/socrate/system.py +++ b/core/base/libs/socrate/socrate/system.py @@ -19,7 +19,7 @@ def resolve_hostname(hostname): try: return sorted(socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE), key=lambda s:s[0])[0][4][0] except Exception as e: - log.warn("Unable to lookup '%s': %s",hostname,e) + log.warning("Unable to lookup '%s': %s",hostname,e) raise e def _coerce_value(value): diff --git a/tests/compose/core/05_connectivity.py b/tests/compose/core/05_connectivity.py index 44bb4553..0a180cda 100755 --- a/tests/compose/core/05_connectivity.py +++ b/tests/compose/core/05_connectivity.py @@ -119,7 +119,7 @@ def test_managesieve(server, username, password): except managesieve.MANAGESIEVE.abort: pass - m=managesieve.MANAGESIEVE(server, use_tls=True) + m=managesieve.MANAGESIEVE(server, use_tls=True, tls_verify=False) if m.login('', username, 'wrongpass') != 'NO': print(f'Authenticating to sieve://{username}:{password}@{server}:4190/ with wrong creds has worked!') sys.exit(108) diff --git a/tests/compose/filters/eicar.com.txt b/tests/compose/filters/eicar.com.txt index 704cac85..a2463df6 100644 --- a/tests/compose/filters/eicar.com.txt +++ b/tests/compose/filters/eicar.com.txt @@ -1 +1 @@ -X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* +X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt index 105c8cdd..1674d367 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,4 +1,4 @@ docker==7.1.0 colorama==0.4.6 -managesieve==0.7.1 +managesieve==0.8 requests==2.32.3 diff --git a/towncrier/newsfragments/3690.bugfix b/towncrier/newsfragments/3690.bugfix new file mode 100644 index 00000000..bcbd3dbf --- /dev/null +++ b/towncrier/newsfragments/3690.bugfix @@ -0,0 +1 @@ +Ensure the apple mobileconfig is served with the appropriate content-type diff --git a/towncrier/newsfragments/3696.bugfix b/towncrier/newsfragments/3696.bugfix new file mode 100644 index 00000000..2ecea51b --- /dev/null +++ b/towncrier/newsfragments/3696.bugfix @@ -0,0 +1 @@ +Include error messages for LMTP diff --git a/towncrier/newsfragments/58.misc b/towncrier/newsfragments/58.misc new file mode 100644 index 00000000..5be81470 --- /dev/null +++ b/towncrier/newsfragments/58.misc @@ -0,0 +1,3 @@ +Refactor OIDC client implementation +Add lazy initialization for the OIDC provider info request (happens now with the first request to the admin panel) +Fix access token parameter naming for `oic` library function; might be a solution for #50 diff --git a/towncrier/newsfragments/59.bugfix b/towncrier/newsfragments/59.bugfix new file mode 100644 index 00000000..64ea3872 --- /dev/null +++ b/towncrier/newsfragments/59.bugfix @@ -0,0 +1 @@ +Fix #54 \ No newline at end of file From eb6c3e47c8cf7d2c090675533165774f451c59dc Mon Sep 17 00:00:00 2001 From: Encotric Date: Sun, 30 Mar 2025 19:07:25 +0200 Subject: [PATCH 5/5] fix: add default config values --- core/admin/mailu/configuration.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index fdcf4819..66a097b2 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -58,6 +58,8 @@ DEFAULT_CONFIG = { 'OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED': True, 'OIDC_CHANGE_PASSWORD_REDIRECT_URL': None, 'OIDC_REDIRECT_URL': None, + 'OIDC_USERNAME_CLAIM': 'email', + 'OIDC_USER_DOMAIN': None, # Mail settings 'DMARC_RUA': None, 'DMARC_RUF': None,