Merge remote-tracking branch 'origin/oidc'

This commit is contained in:
Encotric
2025-03-30 18:02:24 +02:00
22 changed files with 401 additions and 280 deletions

View File

@@ -4,10 +4,6 @@
# Username of docker login for logging in docker for pulling images (higher pull rate limit) # Username of docker login for logging in docker for pulling images (higher pull rate limit)
# ${{ secrets.Docker_Password }} # ${{ secrets.Docker_Password }}
# Password of docker login for logging in docker for pulling images (higher pull rate limit) # 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 name: build-test-deploy
@@ -37,14 +33,14 @@ on:
type: string type: string
deploy: deploy:
description: Deploy to container registry. Happens for all branches but staging. Use string true or false. description: Deploy to container registry. Happens for all branches but staging. Use string true or false.
default: true default: 'true'
required: false required: false
type: boolean type: string
release: release:
description: Tag and create the github release. Use string true or false. description: Tag and create the github release. Use string true or false.
default: false default: 'false'
required: false required: false
type: boolean type: string
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -71,14 +67,14 @@ on:
type: string type: string
deploy: deploy:
description: Deploy to container registry. Happens for all branches but staging. Use string true or false. description: Deploy to container registry. Happens for all branches but staging. Use string true or false.
default: true default: 'true'
required: false required: false
type: boolean type: string
release: release:
description: Tag and create the github release. Use string true or false. description: Tag and create the github release. Use string true or false.
default: false default: 'false'
required: false required: false
type: boolean type: string
env: env:
HCL_FILE: ./tests/build-ci.hcl HCL_FILE: ./tests/build-ci.hcl
@@ -87,7 +83,7 @@ jobs:
# This is used by the next build job. # This is used by the next build job.
targets: targets:
name: create targets name: create targets
runs-on: ubuntu-latest runs-on: ubuntu-24.04
outputs: outputs:
matrix: ${{ steps.targets.outputs.matrix }} matrix: ${{ steps.targets.outputs.matrix }}
steps: steps:
@@ -109,7 +105,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
target: ["base", "assets"] target: ["base", "assets"]
runs-on: ubuntu-latest runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -184,7 +180,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
target: ["base", "assets"] target: ["base", "assets"]
runs-on: self-hosted runs-on: ubuntu-24.04-arm
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -219,8 +215,8 @@ jobs:
PINNED_LABEL_VERSION: ${{ env.PINNED_MAILU_VERSION }} PINNED_LABEL_VERSION: ${{ env.PINNED_MAILU_VERSION }}
ARCH: linux/arm64/v8,linux/arm/v7 ARCH: linux/arm64/v8,linux/arm/v7
BUILDER: ${{ steps.uuid.outputs.uuid }} BUILDER: ${{ steps.uuid.outputs.uuid }}
DOCKER_LOGIN2: ${{ secrets.Docker_Login2 }} DOCKER_LOGIN: ${{ secrets.Docker_Login }}
DOCKER_PASSW2: ${{ secrets.Docker_Password2 }} DOCKER_PASSW: ${{ secrets.Docker_Password }}
BUILDX_NO_DEFAULT_ATTESTATIONS: 1 BUILDX_NO_DEFAULT_ATTESTATIONS: 1
uses: nick-fields/retry@v3 uses: nick-fields/retry@v3
with: with:
@@ -232,7 +228,7 @@ jobs:
set -euxo pipefail \ set -euxo pipefail \
; /usr/bin/docker info \ ; /usr/bin/docker info \
; echo "${{ github.token }}" | docker login --username "${{ github.repository_owner }}" --password-stdin ghcr.io \ ; 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 }} \ ; /usr/bin/docker buildx rm builder-${{ env.BUILDER }} \
|| echo "builder does not exist" \ || echo "builder does not exist" \
; /usr/bin/docker buildx create --name builder-${{ env.BUILDER }} --driver docker-container --use \ ; /usr/bin/docker buildx create --name builder-${{ env.BUILDER }} --driver docker-container --use \
@@ -261,7 +257,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
target: ${{ fromJson(needs.targets.outputs.matrix) }} target: ${{ fromJson(needs.targets.outputs.matrix) }}
runs-on: ubuntu-latest runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -339,7 +335,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
target: ${{ fromJson(needs.targets.outputs.matrix) }} target: ${{ fromJson(needs.targets.outputs.matrix) }}
runs-on: self-hosted runs-on: ubuntu-24.04-arm
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -374,8 +370,8 @@ jobs:
PINNED_LABEL_VERSION: ${{ env.PINNED_MAILU_VERSION }} PINNED_LABEL_VERSION: ${{ env.PINNED_MAILU_VERSION }}
ARCH: linux/arm64/v8,linux/arm/v7 ARCH: linux/arm64/v8,linux/arm/v7
BUILDER: ${{ steps.uuid.outputs.uuid }} BUILDER: ${{ steps.uuid.outputs.uuid }}
DOCKER_LOGIN2: ${{ secrets.Docker_Login2 }} DOCKER_LOGIN: ${{ secrets.Docker_Login }}
DOCKER_PASSW2: ${{ secrets.Docker_Password2 }} DOCKER_PASSW: ${{ secrets.Docker_Password }}
BUILDX_NO_DEFAULT_ATTESTATIONS: 1 BUILDX_NO_DEFAULT_ATTESTATIONS: 1
uses: nick-fields/retry@v3 uses: nick-fields/retry@v3
with: with:
@@ -387,7 +383,7 @@ jobs:
set -euxo pipefail \ set -euxo pipefail \
; /usr/bin/docker info \ ; /usr/bin/docker info \
; echo "${{ github.token }}" | docker login --username "${{ github.repository_owner }}" --password-stdin ghcr.io \ ; 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 }} \ ; /usr/bin/docker buildx rm builder-${{ env.BUILDER }} \
|| echo "builder does not exist" \ || echo "builder does not exist" \
; /usr/bin/docker buildx create --name builder-${{ env.BUILDER }} --driver docker-container --use \ ; /usr/bin/docker buildx create --name builder-${{ env.BUILDER }} --driver docker-container --use \
@@ -409,7 +405,7 @@ jobs:
tests: tests:
name: tests name: tests
if: contains(inputs.architecture, 'linux/amd64') if: contains(inputs.architecture, 'linux/amd64')
runs-on: ubuntu-latest runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
packages: read packages: read
@@ -466,7 +462,7 @@ jobs:
name: Deploy images name: Deploy images
# Deploying is not required for staging # Deploying is not required for staging
if: inputs.deploy == 'true' if: inputs.deploy == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-24.04
needs: needs:
- build - build
- build-arm - 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. #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: tag-release:
if: inputs.release == 'true' if: inputs.release == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-24.04
needs: needs:
- deploy - deploy
steps: steps:

View File

@@ -4,6 +4,9 @@ on:
branches: branches:
- master - master
- oidc - oidc
push:
branches:
- '2024.06'
merge_group: merge_group:
concurrency: ci-multiarch-${{ github.ref }} concurrency: ci-multiarch-${{ github.ref }}
@@ -83,29 +86,15 @@ jobs:
- derive-variables - derive-variables
uses: ./.github/workflows/build_test_deploy.yml uses: ./.github/workflows/build_test_deploy.yml
with: with:
# linux/arm64/v8,linux/arm/v7 will be added when GitHub hosted arm64 runners are available by the end of 2024 architecture: 'linux/amd64,linux/arm64/v8,linux/arm/v7'
# https://github.blog/news-insights/product-news/arm64-on-github-actions-powering-faster-more-efficient-build-systems/
architecture: 'linux/amd64'
mailu_version: ${{needs.derive-variables.outputs.MAILU_VERSION}} mailu_version: ${{needs.derive-variables.outputs.MAILU_VERSION}}
pinned_mailu_version: ${{needs.derive-variables.outputs.PINNED_MAILU_VERSION}} pinned_mailu_version: ${{needs.derive-variables.outputs.PINNED_MAILU_VERSION}}
docker_org: ${{needs.derive-variables.outputs.DOCKER_ORG}} docker_org: ${{needs.derive-variables.outputs.DOCKER_ORG}}
branch: ${{needs.derive-variables.outputs.BRANCH}} branch: ${{needs.derive-variables.outputs.BRANCH}}
deploy: ${{needs.derive-variables.outputs.DEPLOY == 'true'}} deploy: ${{needs.derive-variables.outputs.DEPLOY}}
release: ${{needs.derive-variables.outputs.RELEASE == 'true'}} release: ${{needs.derive-variables.outputs.RELEASE}}
secrets: inherit 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: # Code block that is used as one liner for the step:
# Derive PINNED_MAILU_VERSION and DEPLOY/RELEASE for normal release x.y # Derive PINNED_MAILU_VERSION and DEPLOY/RELEASE for normal release x.y

View File

@@ -27,7 +27,7 @@ def api_token_authorization(func):
abort(401, 'A valid Authorization header is mandatory') 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): 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) 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') abort(403, 'Invalid API token')
flask.current_app.logger.info(f'Valid API token provided by {client_ip}.') flask.current_app.logger.info(f'Valid API token provided by {client_ip}.')
return func(*args, **kwds) return func(*args, **kwds)

View File

@@ -157,7 +157,7 @@ class ConfigManager:
self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2' 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_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 self.config['SESSION_COOKIE_HTTPONLY'] = True
if self.config['SESSION_COOKIE_SECURE'] is None: if self.config['SESSION_COOKIE_SECURE'] is None:
self.config['SESSION_COOKIE_SECURE'] = self.config['TLS_FLAVOR'] != 'notls' self.config['SESSION_COOKIE_SECURE'] = self.config['TLS_FLAVOR'] != 'notls'

View File

@@ -14,6 +14,7 @@ STATUSES = {
"imap": "AUTHENTICATIONFAILED", "imap": "AUTHENTICATIONFAILED",
"smtp": "535 5.7.8", "smtp": "535 5.7.8",
"submission": "535 5.7.8", "submission": "535 5.7.8",
"lmtp": "535 5.7.8",
"pop3": "-ERR Authentication failed", "pop3": "-ERR Authentication failed",
"sieve": "AuthFailed" "sieve": "AuthFailed"
}), }),
@@ -21,6 +22,7 @@ STATUSES = {
"imap": "PRIVACYREQUIRED", "imap": "PRIVACYREQUIRED",
"smtp": "530 5.7.0", "smtp": "530 5.7.0",
"submission": "530 5.7.0", "submission": "530 5.7.0",
"lmtp": "530 5.7.0",
"pop3": "-ERR Authentication canceled.", "pop3": "-ERR Authentication canceled.",
"sieve": "ENCRYPT-NEEDED" "sieve": "ENCRYPT-NEEDED"
}), }),
@@ -28,6 +30,7 @@ STATUSES = {
"imap": "LIMIT", "imap": "LIMIT",
"smtp": "451 4.3.2", "smtp": "451 4.3.2",
"submission": "451 4.3.2", "submission": "451 4.3.2",
"lmtp": "451 4.3.2",
"pop3": "-ERR [LOGIN-DELAY] Retry later", "pop3": "-ERR [LOGIN-DELAY] Retry later",
"sieve": "AuthFailed" "sieve": "AuthFailed"
}), }),
@@ -102,13 +105,13 @@ def handle_authentication(headers):
password = urllib.parse.unquote(headers["Auth-Pass"]) password = urllib.parse.unquote(headers["Auth-Pass"])
ip = urllib.parse.unquote(headers["Client-Ip"]) ip = urllib.parse.unquote(headers["Client-Ip"])
except: 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: else:
try: try:
user = models.User.query.get(user_email) if '@' in user_email else None user = models.User.query.get(user_email) if '@' in user_email else None
except sqlalchemy.exc.StatementError as exc: except sqlalchemy.exc.StatementError as exc:
exc = str(exc).split('\n', 1)[0] 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: else:
is_valid_user = user is not None is_valid_user = user is not None
ip = urllib.parse.unquote(headers["Client-Ip"]) ip = urllib.parse.unquote(headers["Client-Ip"])

View File

@@ -103,7 +103,7 @@ def basic_authentication():
user = models.User.query.get(user_email) if '@' in user_email else None user = models.User.query.get(user_email) if '@' in user_email else None
except sqlalchemy.exc.StatementError as exc: except sqlalchemy.exc.StatementError as exc:
exc = str(exc).split('\n', 1)[0] 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: 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): 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() response = flask.Response()

View File

@@ -180,4 +180,4 @@ def autoconfig_apple():
<integer>1</integer> <integer>1</integer>
</dict> </dict>
</plist>\r\n''' </plist>\r\n'''
return flask.Response(xml, mimetype='text/xml', status=200) return flask.Response(xml, content_type='application/x-apple-aspen-config', status=200)

View File

@@ -49,7 +49,7 @@ class LimitWraperFactory(object):
client_network = utils.extract_network_from_ip(ip) client_network = utils.extract_network_from_ip(ip)
is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network) is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network)
if is_rate_limited: 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 return is_rate_limited
def rate_limit_ip(self, ip, username=None): 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') 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) 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: 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 return is_rate_limited
def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=''): 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) limiter.hit(device_cookie if device_cookie_name == username else username)
self.rate_limit_ip(ip, username) self.rate_limit_ip(ip, username)
""" Device cookies as described on: def parse_device_cookie(self, cookie: str):
https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies """ Device cookies as described on:
""" https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies
def parse_device_cookie(self, cookie): """
try: try:
login, nonce, _ = cookie.split('$') login, nonce, _ = cookie.split('$')
if hmac.compare_digest(cookie, self.device_cookie(login, nonce)): if hmac.compare_digest(cookie, self.device_cookie(login, nonce)):
@@ -90,11 +90,11 @@ class LimitWraperFactory(object):
pass pass
return None, None 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): 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: if not nonce:
nonce = secrets.token_urlsafe(9) 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') sig = str(base64.urlsafe_b64encode(hmac.new(app.device_cookie_key, bytearray(f'device_cookie|{username}|{nonce}', 'utf-8'), 'sha256').digest()[20:]), 'utf-8')

View File

@@ -560,12 +560,12 @@ class User(Base, Email):
def is_authenticated(self): def is_authenticated(self):
if 'oidc_token' not in session: if 'oidc_token' not in session:
return self._is_authenticated 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: if token is None:
flask_login.logout_user() flask_login.logout_user()
session.destroy() session.destroy()
return False return False
session['openid_token'] = token session['oidc_token'] = token
return True return True
# [OIDC] is_authenticated property setter # [OIDC] is_authenticated property setter
@@ -635,7 +635,7 @@ class User(Base, Email):
return False return False
# [OIDC] Check if the user is authenticated with OIDC # [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() return self.is_authenticated()
cache_result = self._credential_cache.get(self.get_id()) cache_result = self._credential_cache.get(self.get_id())

277
core/admin/mailu/oidc.py Normal file
View File

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

View File

@@ -13,7 +13,7 @@ from urllib.parse import urlparse, urljoin, unquote
@sso.route('/login', methods=['GET', 'POST']) @sso.route('/login', methods=['GET', 'POST'])
def login(): 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() return _proxy()
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
@@ -31,34 +31,7 @@ def login():
fields = [] fields = []
# [OIDC] Add the OIDC login flow if 'url' in flask.request.args and 'homepage' not in flask.request.url:
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:
fields.append(form.submitAdmin) fields.append(form.submitAdmin)
else: else:
form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin' form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin'
@@ -69,6 +42,36 @@ def login():
fields.append(form.submitAdmin) fields.append(form.submitAdmin)
fields = [fields] 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 form.validate_on_submit():
if destination := _has_usable_redirect(): if destination := _has_usable_redirect():
pass pass
@@ -82,10 +85,10 @@ def login():
if not utils.is_app_token(form.pw.data): if not utils.is_app_token(form.pw.data):
if username != device_cookie_username and utils.limiter.should_rate_limit_ip(client_ip): 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') 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): 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') 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) user = models.User.login(username, form.pw.data)
if user: if user:
flask.session.regenerate() 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.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') flask.flash(_('Wrong e-mail or password'), 'error')
# [OIDC] Forward the OIDC data to the login template # [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']) @sso.route('/pw_change', methods=['GET', 'POST'])
@access.authenticated @access.authenticated
@@ -132,11 +135,11 @@ def pw_change():
user.change_pw_next_login = False user.change_pw_next_login = False
models.db.session.commit() 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}') 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) return flask.redirect(destination)
flask.flash(_("The current password is incorrect!"), "error") 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']) @sso.route('/logout', methods=['GET'])
@access.authenticated @access.authenticated
@@ -218,3 +221,13 @@ def _proxy():
user.send_welcome() user.send_welcome()
flask.current_app.logger.info(f'Login succeeded by proxy created user: {user} from {client_ip} through {flask.request.remote_addr}.') 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) 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)

View File

@@ -90,7 +90,7 @@ def owner(args, kwargs, model, key):
def authenticated(args, kwargs): def authenticated(args, kwargs):
""" The view is only available to logged in users. """ The view is only available to logged in users.
""" """
return True return flask_login.current_user.is_authenticated

View File

@@ -24,7 +24,7 @@
<li class="nav-item" role="none"> <li class="nav-item" role="none">
<a href="{{ oic_client.change_password() if current_user.is_oidc_user else url_for('.user_password_change') }}" class="nav-link" role="menuitem"> <a href="{{ oic_client.change_password() if current_user.is_oidc_user else url_for('.user_password_change') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-lock"></i> <i class="nav-icon fa fa-lock"></i>
<p>{% trans %}Update password{% endtrans %}</p> <p>{% trans %}Update password{% endtrans %}{% if current_user.is_oidc_user %} <i class="fas fa-external-link-alt text-xs"></i>{% endif %}</p>
</a> </a>
</li> </li>
{%- endif %} {%- endif %}

View File

@@ -1,10 +1,14 @@
""" Mailu admin app utilities """ Mailu admin app utilities
""" """
try: from datetime import datetime, timedelta
import cPickle as pickle import hmac
except ImportError: import ipaddress
import pickle from multiprocessing import Value
import pickle
import secrets
import string
import time
import dns.resolver import dns.resolver
import dns.exception import dns.exception
@@ -13,38 +17,20 @@ import dns.rdtypes
import dns.rdatatype import dns.rdatatype
import dns.rdataclass 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 import flask
from flask import current_app as app
from flask.sessions import SessionMixin, SessionInterface
import flask_login import flask_login
import flask_migrate import flask_migrate
import flask_babel import flask_babel
import ipaddress
import redis import redis
from datetime import datetime, timedelta
from flask.sessions import SessionMixin, SessionInterface
from itsdangerous.encoding import want_bytes from itsdangerous.encoding import want_bytes
from werkzeug.datastructures import CallbackDict from werkzeug.datastructures import CallbackDict
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
# [OIDC] Import the OIDC related modules from mailu import limiter, oidc
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
# Login configuration # Login configuration
login = flask_login.LoginManager() login = flask_login.LoginManager()
@@ -137,157 +123,8 @@ class PrefixMiddleware(object):
proxy = PrefixMiddleware() proxy = PrefixMiddleware()
# [OIDC] Client class # OIDC client
class OicClient: oic_client = oidc.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()
# Data migrate # Data migrate
migrate = flask_migrate.Migrate() migrate = flask_migrate.Migrate()
@@ -629,7 +466,7 @@ class MailuSessionExtension:
if key not in keep and not key.startswith(b'token-'): if key not in keep and not key.startswith(b'token-'):
# [OIDC] Prune sessions by sub # [OIDC] Prune sessions by sub
session = MailuSession(key, app) if sub is not None else None 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) app.session_store.delete(key)
count += 1 count += 1

View File

@@ -19,7 +19,7 @@ def resolve_hostname(hostname):
try: 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] 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: except Exception as e:
log.warn("Unable to lookup '%s': %s",hostname,e) log.warning("Unable to lookup '%s': %s",hostname,e)
raise e raise e
def _coerce_value(value): def _coerce_value(value):

View File

@@ -119,7 +119,7 @@ def test_managesieve(server, username, password):
except managesieve.MANAGESIEVE.abort: except managesieve.MANAGESIEVE.abort:
pass pass
m=managesieve.MANAGESIEVE(server, use_tls=True) m=managesieve.MANAGESIEVE(server, use_tls=True, tls_verify=False)
if m.login('', username, 'wrongpass') != 'NO': if m.login('', username, 'wrongpass') != 'NO':
print(f'Authenticating to sieve://{username}:{password}@{server}:4190/ with wrong creds has worked!') print(f'Authenticating to sieve://{username}:{password}@{server}:4190/ with wrong creds has worked!')
sys.exit(108) sys.exit(108)

View File

@@ -1,4 +1,4 @@
docker==7.1.0 docker==7.1.0
colorama==0.4.6 colorama==0.4.6
managesieve==0.7.1 managesieve==0.8
requests==2.32.3 requests==2.32.3

View File

@@ -0,0 +1 @@
Ensure the apple mobileconfig is served with the appropriate content-type

View File

@@ -0,0 +1 @@
Include error messages for LMTP

View File

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

View File

@@ -0,0 +1 @@
Fix #54