From e2d4e3eb2ef3499da2e994541e9b10ed074b6db3 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 19 Nov 2022 17:59:31 +0100 Subject: [PATCH 1/4] Implement header authentication via external proxy --- core/admin/mailu/configuration.py | 4 ++++ core/admin/mailu/sso/views/base.py | 35 ++++++++++++++++++++++++++++ docs/configuration.rst | 12 ++++++++++ towncrier/newsfragments/1972.feature | 1 + 4 files changed, 52 insertions(+) create mode 100644 towncrier/newsfragments/1972.feature diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index d282bfc2..d447e570 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -80,6 +80,9 @@ DEFAULT_CONFIG = { 'TLS_PERMISSIVE': True, 'TZ': 'Etc/UTC', 'DEFAULT_SPAM_THRESHOLD': 80, + 'PROXY_AUTH_WHITELIST': '', + 'PROXY_AUTH_HEADER': 'X-Auth-Email', + 'PROXY_AUTH_CREATE': False, # Host settings 'HOST_IMAP': 'imap', 'HOST_LMTP': 'imap:2525', @@ -171,6 +174,7 @@ class ConfigManager: self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAME'] = hostnames[0] self.config['DEFAULT_SPAM_THRESHOLD'] = int(self.config['DEFAULT_SPAM_THRESHOLD']) + self.config['PROXY_AUTH_WHITELIST'] = set(ipaddress.ip_network(cidr, False) for cidr in (cidr.strip() for cidr in self.config['PROXY_AUTH_WHITELIST'].split(',')) if cidr) # update the app config app.config.update(self.config) diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index 6fa9403f..600f62f4 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -6,6 +6,8 @@ from mailu.ui import access from flask import current_app as app import flask import flask_login +import secrets +import ipaddress @sso.route('/login', methods=['GET', 'POST']) def login(): @@ -57,3 +59,36 @@ def logout(): flask.session.destroy() return flask.redirect(flask.url_for('.login')) + +@sso.route('/proxy', methods=['GET']) +@sso.route('/proxy/', methods=['GET']) +def proxy(target='webmail'): + ip = ipaddress.ip_address(flask.request.remote_addr) + if not any(ip in cidr for cidr in app.config['PROXY_AUTH_WHITELIST']): + return flask.abort(500, '%s is not on PROXY_AUTH_WHITELIST' % flask.request.remote_addr) + + email = flask.request.headers.get(app.config['PROXY_AUTH_HEADER'], None) + if not email: + return flask.abort(500, 'No %s header' % app.config['PROXY_AUTH_HEADER']) + + user = models.User.get(email) + if user: + flask.session.regenerate() + flask_login.login_user(user) + return flask.redirect(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL']) + elif app.config['PROXY_AUTH_CREATE']: + localpart, desireddomain = email.split('@') + domain = models.Domain.query.get(desireddomain) or flask.abort(500, 'You don\'t exist. Go away! (domain=%s)' % desireddomain) + if not domain.max_users == -1 and len(domain.users) >= domain.max_users: + flask.current_app.logger.warning('Too many users for domain %s' % domain) + return flask.abort(500, 'Too many users in (domain=%s)' % domain) + user = models.User(localpart=localpart, domain=domain) + user.set_password(secrets.token_urlsafe()) + models.db.session.add(user) + models.db.session.commit() + user.send_welcome() + client_ip = flask.request.headers.get('X-Real-IP', 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(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL']) + else: + return flask.abort(500, 'You don\'t exist. Go away! (%s)' % email) diff --git a/docs/configuration.rst b/docs/configuration.rst index 1a40bf65..b5affad6 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -362,3 +362,15 @@ It can be configured with the following option: When ``POSTFIX_LOG_FILE`` is enabled, the logrotate program will automatically rotate the logs every week and keep 52 logs. To override the logrotate configuration, create the file logrotate.conf with the desired configuration in the :ref:`Postfix overrides folder`. + + +Header authentication using an external proxy +--------------------------------------------- + +The ``PROXY_AUTH_WHITELIST`` (default: unset/disabled) option allows you to configure a comma separated list of CIDRs of proxies to trust for authentication. This list is separate from ``REAL_IP_FROM`` and any entry in ``PROXY_AUTH_WHITELIST`` should also appear in ``REAL_IP_FROM``. + +Use ``PROXY_AUTH_HEADER`` (default: 'X-Auth-Email') to customize which HTTP header the email address of the user to authenticate as should be and ``PROXY_AUTH_CREATE`` (default: False) to control whether non-existing accounts should be auto-created. Please note that Mailu doesn't currently support creating new users for non-existing domains; you do need to create all the domains that may be used manually. + +Once configured, any request to /sso/proxy will be redirected to the webmail and /sso/proxy/admin to the admin panel. Please check issue `1972` for more details. + +.. _`1972`: https://github.com/Mailu/Mailu/issues/1972 diff --git a/towncrier/newsfragments/1972.feature b/towncrier/newsfragments/1972.feature new file mode 100644 index 00000000..4efe45c9 --- /dev/null +++ b/towncrier/newsfragments/1972.feature @@ -0,0 +1 @@ +Implement Header authentication via external proxy From 546884d10cc5ba995044891051a4c3278f7d686d Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 24 Nov 2022 09:31:27 +0100 Subject: [PATCH 2/4] ghost's requested changes --- core/admin/mailu/sso/views/base.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index 600f62f4..b7fe9c97 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -67,7 +67,7 @@ def proxy(target='webmail'): if not any(ip in cidr for cidr in app.config['PROXY_AUTH_WHITELIST']): return flask.abort(500, '%s is not on PROXY_AUTH_WHITELIST' % flask.request.remote_addr) - email = flask.request.headers.get(app.config['PROXY_AUTH_HEADER'], None) + email = flask.request.headers.get(app.config['PROXY_AUTH_HEADER']) if not email: return flask.abort(500, 'No %s header' % app.config['PROXY_AUTH_HEADER']) @@ -76,8 +76,13 @@ def proxy(target='webmail'): flask.session.regenerate() flask_login.login_user(user) return flask.redirect(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL']) - elif app.config['PROXY_AUTH_CREATE']: - localpart, desireddomain = email.split('@') + + if not app.config['PROXY_AUTH_CREATE']: + return flask.abort(500, 'You don\'t exist. Go away! (%s)' % email) + + client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) + try: + localpart, desireddomain = email.rsplit('@') domain = models.Domain.query.get(desireddomain) or flask.abort(500, 'You don\'t exist. Go away! (domain=%s)' % desireddomain) if not domain.max_users == -1 and len(domain.users) >= domain.max_users: flask.current_app.logger.warning('Too many users for domain %s' % domain) @@ -87,8 +92,8 @@ def proxy(target='webmail'): models.db.session.add(user) models.db.session.commit() user.send_welcome() - client_ip = flask.request.headers.get('X-Real-IP', 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(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL']) - else: + except Exception as e: + flask.current_app.logger.error('Error creating a new user via proxy for %s from %s: %s' % (email, client_ip, str(e)), e) return flask.abort(500, 'You don\'t exist. Go away! (%s)' % email) From 12117cef376147c8499c10f7e0c8bc52d911b298 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 24 Nov 2022 12:16:25 +0100 Subject: [PATCH 3/4] Reduce the scope of the try: except --- core/admin/mailu/sso/views/base.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index b7fe9c97..67f2319a 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -83,17 +83,17 @@ def proxy(target='webmail'): client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) try: localpart, desireddomain = email.rsplit('@') - domain = models.Domain.query.get(desireddomain) or flask.abort(500, 'You don\'t exist. Go away! (domain=%s)' % desireddomain) - if not domain.max_users == -1 and len(domain.users) >= domain.max_users: - flask.current_app.logger.warning('Too many users for domain %s' % domain) - return flask.abort(500, 'Too many users in (domain=%s)' % domain) - user = models.User(localpart=localpart, domain=domain) - user.set_password(secrets.token_urlsafe()) - models.db.session.add(user) - models.db.session.commit() - 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(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL']) except Exception as e: flask.current_app.logger.error('Error creating a new user via proxy for %s from %s: %s' % (email, client_ip, str(e)), e) return flask.abort(500, 'You don\'t exist. Go away! (%s)' % email) + domain = models.Domain.query.get(desireddomain) or flask.abort(500, 'You don\'t exist. Go away! (domain=%s)' % desireddomain) + if not domain.max_users == -1 and len(domain.users) >= domain.max_users: + flask.current_app.logger.warning('Too many users for domain %s' % domain) + return flask.abort(500, 'Too many users in (domain=%s)' % domain) + user = models.User(localpart=localpart, domain=domain) + user.set_password(secrets.token_urlsafe()) + models.db.session.add(user) + models.db.session.commit() + 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(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL']) From e927426dfafd31d407d30acee71c24b0a88ac906 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Fri, 25 Nov 2022 09:37:05 +0100 Subject: [PATCH 4/4] Turns out that php81-ctype is required by roundcube see https://github.com/roundcube/roundcubemail/issues/7049 --- webmails/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webmails/Dockerfile b/webmails/Dockerfile index 77bc65f3..19b739c9 100644 --- a/webmails/Dockerfile +++ b/webmails/Dockerfile @@ -12,7 +12,7 @@ RUN set -euxo pipefail \ ; apk add --no-cache \ nginx gpg gpg-agent \ php81 php81-fpm php81-mbstring php81-zip php81-xml php81-simplexml php81-pecl-apcu \ - php81-dom php81-curl php81-exif gd php81-gd php81-iconv php81-intl php81-openssl \ + php81-dom php81-curl php81-exif gd php81-gd php81-iconv php81-intl php81-openssl php81-ctype \ php81-pdo_sqlite php81-pdo_mysql php81-pdo_pgsql php81-pdo php81-sodium libsodium php81-tidy php81-pecl-uuid \ php81-pspell php81-pecl-imagick php81-opcache php81-session php81-sockets php81-fileinfo \ aspell-uk aspell-ru aspell-fr aspell-de aspell-en \