mirror of
https://github.com/optim-enterprises-bv/Mailu-OIDC.git
synced 2025-10-29 09:12:41 +00:00
Merge remote-tracking branch 'origin/oidc'
This commit is contained in:
48
.github/workflows/build_test_deploy.yml
vendored
48
.github/workflows/build_test_deploy.yml
vendored
@@ -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:
|
||||
|
||||
23
.github/workflows/multiarch.yml
vendored
23
.github/workflows/multiarch.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -180,4 +180,4 @@ def autoconfig_apple():
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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())
|
||||
|
||||
277
core/admin/mailu/oidc.py
Normal file
277
core/admin/mailu/oidc.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<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">
|
||||
<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>
|
||||
</li>
|
||||
{%- endif %}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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*
|
||||
@@ -1,4 +1,4 @@
|
||||
docker==7.1.0
|
||||
colorama==0.4.6
|
||||
managesieve==0.7.1
|
||||
managesieve==0.8
|
||||
requests==2.32.3
|
||||
|
||||
1
towncrier/newsfragments/3690.bugfix
Normal file
1
towncrier/newsfragments/3690.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Ensure the apple mobileconfig is served with the appropriate content-type
|
||||
1
towncrier/newsfragments/3696.bugfix
Normal file
1
towncrier/newsfragments/3696.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Include error messages for LMTP
|
||||
3
towncrier/newsfragments/58.misc
Normal file
3
towncrier/newsfragments/58.misc
Normal 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
|
||||
1
towncrier/newsfragments/59.bugfix
Normal file
1
towncrier/newsfragments/59.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Fix #54
|
||||
Reference in New Issue
Block a user