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)
def parse_device_cookie(self, cookie: str):
""" Device cookies as described on: """ Device cookies as described on:
https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies 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
def device_cookie(self, username, nonce=None):
""" Device cookies don't require strong crypto: """ Device cookies don't require strong crypto:
72bits of nonce, 96bits of signature is more than enough 72bits of nonce, 96bits of signature is more than enough
and these values avoid padding in most cases and these values avoid padding in most cases
""" """
def device_cookie(self, username, nonce=None):
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
from multiprocessing import Value
import pickle 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