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)
# ${{ 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:

View File

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

View File

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

View File

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

View File

@@ -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"])

View File

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

View File

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

View File

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

View File

@@ -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
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'])
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
docker==7.1.0
colorama==0.4.6
managesieve==0.7.1
managesieve==0.8
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