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..71bf2433
--- /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["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 106e2522..40314046 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,34 +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())
-
- 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'
@@ -69,6 +42,36 @@ 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)
+
+ 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
@@ -82,10 +85,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()
@@ -104,7 +107,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
@@ -132,11 +135,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
@@ -218,3 +221,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 @@
- {% trans %}Update password{% endtrans %}
+ {% trans %}Update password{% endtrans %}{% if current_user.is_oidc_user %} {% endif %}
{%- endif %}
diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py
index f23cc50d..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['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