From 906a051925766058feec60c0b768e274ccd7c862 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sat, 6 Feb 2021 17:23:05 +0100 Subject: [PATCH 1/8] Make rainloop use internal auth --- core/admin/mailu/internal/nginx.py | 19 ++++++++++----- core/admin/mailu/internal/views/auth.py | 12 ++++++++++ core/admin/mailu/models.py | 10 ++++++++ core/admin/mailu/ui/views/base.py | 3 +++ core/nginx/conf/nginx.conf | 20 ++++++++++++++++ webmails/rainloop/Dockerfile | 1 + webmails/rainloop/application.ini | 2 ++ webmails/rainloop/sso.php | 31 +++++++++++++++++++++++++ webmails/rainloop/start.py | 1 + 9 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 webmails/rainloop/sso.php diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 1e0b16c2..a41543d3 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -7,7 +7,6 @@ import ipaddress import socket import tenacity - SUPPORTED_AUTH_METHODS = ["none", "plain"] @@ -49,11 +48,19 @@ def handle_authentication(headers): user = models.User.query.get(user_email) status = False if user: - for token in user.tokens: - if (token.check_password(password) and - (not token.ip or token.ip == ip)): - status = True - if user.check_password(password): + # webmails + if len(password) == 64 and ip == app.config['WEBMAIL_ADDRESS']: + if user.verify_temp_token(password): + status = True + + # All tokens are 32 characters hex lowercase + if len(password) == 32: + for token in user.tokens: + if (token.check_password(password) and + (not token.ip or token.ip == ip)): + status = True + break + if not status and user.check_password(password): status = True if status: if protocol == "imap" and not user.enable_imap: diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 825dba56..338141d8 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -43,6 +43,18 @@ def admin_authentication(): return "" return flask.abort(403) +@internal.route("/auth/user") +def user_authentication(): + """ Fails if the user is not authenticated. + """ + if (not flask_login.current_user.is_anonymous + and flask_login.current_user.enabled): + response = flask.Response() + response.headers["X-User"] = flask_login.current_user.get_id() + response.headers["X-User-Token"] = models.User.get_temp_token(flask_login.current_user.get_id()) + return response + return flask.abort(403) + @internal.route("/auth/basic") def basic_authentication(): diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index bbc00f2d..6230e252 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -12,6 +12,7 @@ import re import time import os import glob +import hmac import smtplib import idna import dns @@ -425,6 +426,15 @@ class User(Base, Email): user = cls.query.get(email) return user if (user and user.enabled and user.check_password(password)) else None + @classmethod + def get_temp_token(cls, email): + user = cls.query.get(email) + return hmac.new(bytearray(app.secret_key,'utf-8'), bytearray("{}|{}".format(datetime.utcnow().strftime("%Y%m%d"), email), 'utf-8'), 'sha256').hexdigest() if (user and user.enabled) else None + + def verify_temp_token(self, token): + return hmac.compare_digest(b''.fromhex(self.get_temp_token(self.email)), b''.fromhex(token)) + + class Alias(Base, Email): """ An alias is an email address that redirects to some destination. diff --git a/core/admin/mailu/ui/views/base.py b/core/admin/mailu/ui/views/base.py index 7501a883..1aff7db1 100644 --- a/core/admin/mailu/ui/views/base.py +++ b/core/admin/mailu/ui/views/base.py @@ -47,6 +47,9 @@ def announcement(): flask.flash('Your announcement was sent', 'success') return flask.render_template('announcement.html', form=form) +@ui.route('/webmail', methods=['GET']) +def webmail(): + return flask.redirect(app.config['WEB_WEBMAIL']) @ui.route('/client', methods=['GET']) def client(): diff --git a/core/nginx/conf/nginx.conf b/core/nginx/conf/nginx.conf index df598c94..a7a9e134 100644 --- a/core/nginx/conf/nginx.conf +++ b/core/nginx/conf/nginx.conf @@ -133,7 +133,27 @@ http { {% endif %} include /etc/nginx/proxy.conf; client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }}; + auth_request /internal/auth/user; proxy_pass http://$webmail; + error_page 403 @webmail_login; + } + location {{ WEB_WEBMAIL }}/sso.php { + {% if WEB_WEBMAIL != '/' %} + rewrite ^({{ WEB_WEBMAIL }})$ $1/ permanent; + rewrite ^{{ WEB_WEBMAIL }}/(.*) /$1 break; + {% endif %} + include /etc/nginx/proxy.conf; + client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }}; + auth_request /internal/auth/user; + auth_request_set $user $upstream_http_x_user; + auth_request_set $token $upstream_http_x_user_token; + proxy_set_header X-Remote-User $user; + proxy_set_header X-Remote-User-Token $token; + proxy_pass http://$webmail; + error_page 403 @webmail_login; + } + location @webmail_login { + return 302 {{ WEB_ADMIN }}/ui/login?next=ui.webmail; } {% endif %} diff --git a/webmails/rainloop/Dockerfile b/webmails/rainloop/Dockerfile index c67c7496..640d7176 100644 --- a/webmails/rainloop/Dockerfile +++ b/webmails/rainloop/Dockerfile @@ -35,6 +35,7 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists COPY include.php /var/www/html/include.php +COPY sso.php /var/www/html/sso.php COPY php.ini /php.ini COPY application.ini /application.ini diff --git a/webmails/rainloop/application.ini b/webmails/rainloop/application.ini index 5bd9943d..bc953af1 100644 --- a/webmails/rainloop/application.ini +++ b/webmails/rainloop/application.ini @@ -7,6 +7,8 @@ attachment_size_limit = {{ MAX_FILESIZE }} allow_admin_panel = Off [labs] +custom_login_link='sso.php' +custom_logout_link='{{ WEB_ADMIN }}/ui/logout' allow_gravatar = Off [contacts] diff --git a/webmails/rainloop/sso.php b/webmails/rainloop/sso.php new file mode 100644 index 00000000..2415f45c --- /dev/null +++ b/webmails/rainloop/sso.php @@ -0,0 +1,31 @@ + Date: Sat, 6 Feb 2021 18:14:58 +0100 Subject: [PATCH 2/8] Make roundcube use internal auth --- webmails/roundcube/Dockerfile | 1 + webmails/roundcube/config.inc.php | 4 ++- webmails/roundcube/mailu.php | 59 +++++++++++++++++++++++++++++++ webmails/roundcube/start.py | 2 ++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 webmails/roundcube/mailu.php diff --git a/webmails/roundcube/Dockerfile b/webmails/roundcube/Dockerfile index 79b911b0..da355cc3 100644 --- a/webmails/roundcube/Dockerfile +++ b/webmails/roundcube/Dockerfile @@ -46,6 +46,7 @@ RUN apt-get update && apt-get install -y \ COPY php.ini /php.ini COPY config.inc.php /var/www/html/config/ +COPY mailu.php /var/www/html/plugins/mailu/mailu.php COPY start.py /start.py EXPOSE 80/tcp diff --git a/webmails/roundcube/config.inc.php b/webmails/roundcube/config.inc.php index eb40047a..bb1a5e84 100644 --- a/webmails/roundcube/config.inc.php +++ b/webmails/roundcube/config.inc.php @@ -17,7 +17,8 @@ $config['plugins'] = array( 'markasjunk', 'managesieve', 'enigma', - 'carddav' + 'carddav', + 'mailu' ); $front = getenv('FRONT_ADDRESS') ? getenv('FRONT_ADDRESS') : 'front'; @@ -37,6 +38,7 @@ $config['managesieve_usetls'] = false; // Customization settings $config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : ''; +$config['sso_logout_url'] = getenv('WEB_ADMIN').'/ui/logout'; $config['product_name'] = 'Mailu Webmail'; // We access the IMAP and SMTP servers locally with internal names, SSL diff --git a/webmails/roundcube/mailu.php b/webmails/roundcube/mailu.php new file mode 100644 index 00000000..bb4d65e9 --- /dev/null +++ b/webmails/roundcube/mailu.php @@ -0,0 +1,59 @@ +add_hook('startup', array($this, 'startup')); + $this->add_hook('authenticate', array($this, 'authenticate')); + $this->add_hook('login_after', array($this, 'login')); + $this->add_hook('login_failed', array($this, 'login_failed')); + $this->add_hook('logout_after', array($this, 'logout')); + } + + function startup($args) + { + if (empty($_SESSION['user_id'])) { + $args['action'] = 'login'; + } + + return $args; + } + + function authenticate($args) + { + if (!in_array('HTTP_X_REMOTE_USER', $_SERVER) || !in_array('HTTP_X_REMOTE_USER_TOKEN', $_SERVER)) { + header('HTTP/1.0 403 Forbidden'); + die(); + } + $args['user'] = $_SERVER['HTTP_X_REMOTE_USER']; + $args['pass'] = $_SERVER['HTTP_X_REMOTE_USER_TOKEN']; + + $args['cookiecheck'] = false; + $args['valid'] = true; + + return $args; + } + + function logout($args) { + // Redirect to global SSO logout path. + $this->load_config(); + + $sso_logout_url = rcmail::get_instance()->config->get('sso_logout_url'); + header("Location: " . $sso_logout_url, true); + exit; + } + + function login($args) + { + header('Location: index.php'); + exit(); + } + function login_failed($args) + { + header('Location: sso.php'); + exit(); + } + +} diff --git a/webmails/roundcube/start.py b/webmails/roundcube/start.py index 649f3324..9ce383c8 100755 --- a/webmails/roundcube/start.py +++ b/webmails/roundcube/start.py @@ -39,6 +39,8 @@ conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini") os.system("mkdir -p /data/gpg /var/www/html/logs") os.system("touch /var/www/html/logs/errors.log") os.system("chown -R www-data:www-data /var/www/html/logs") +os.system("chmod -R a+rX /var/www/html/") +os.system("ln -s /var/www/html/index.php /var/www/html/sso.php") try: print("Initializing database") From 99c7420f923a8b2c44860d25c196fcf2c00280e4 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sun, 7 Feb 2021 17:51:19 +0100 Subject: [PATCH 3/8] towncrier --- towncrier/newsfragments/783.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 towncrier/newsfragments/783.feature diff --git a/towncrier/newsfragments/783.feature b/towncrier/newsfragments/783.feature new file mode 100644 index 00000000..fcafceef --- /dev/null +++ b/towncrier/newsfragments/783.feature @@ -0,0 +1 @@ +Centralize the authentication of webmails behind the admin interface From ef637f51b7f620c88dff403cf1fdb4a74ccbe663 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sun, 7 Feb 2021 17:58:19 +0100 Subject: [PATCH 4/8] derive the SSO keys from a KDF --- core/admin/mailu/__init__.py | 3 +++ core/admin/mailu/models.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index 4de3e580..b37b4493 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -3,6 +3,7 @@ import flask_bootstrap from mailu import utils, debug, models, manage, configuration +import hmac def create_app_from_config(config): """ Create a new application based on the given configuration @@ -24,6 +25,8 @@ def create_app_from_config(config): utils.proxy.init_app(app) utils.migrate.init_app(app, models.db) + app.temp_token_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('WEBMAIL_TEMP_TOKEN_KEY', 'utf-8'), 'sha256').digest() + # Initialize debugging tools if app.config.get("DEBUG"): debug.toolbar.init_app(app) diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index 6230e252..c3f79b92 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -429,10 +429,10 @@ class User(Base, Email): @classmethod def get_temp_token(cls, email): user = cls.query.get(email) - return hmac.new(bytearray(app.secret_key,'utf-8'), bytearray("{}|{}".format(datetime.utcnow().strftime("%Y%m%d"), email), 'utf-8'), 'sha256').hexdigest() if (user and user.enabled) else None + return hmac.new(app.temp_token_key, bytearray("{}|{}".format(datetime.utcnow().strftime("%Y%m%d"), email), 'utf-8'), 'sha256').hexdigest() if (user and user.enabled) else None def verify_temp_token(self, token): - return hmac.compare_digest(b''.fromhex(self.get_temp_token(self.email)), b''.fromhex(token)) + return hmac.compare_digest(self.get_temp_token(self.email), token) From b49554bec1156c69c5f24c5ca62cbb9a818ac8a7 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Sun, 7 Feb 2021 18:08:58 +0100 Subject: [PATCH 5/8] merge artifact --- core/admin/mailu/internal/nginx.py | 2 +- core/admin/mailu/ui/views/base.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index a41543d3..b2ee3fba 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -54,7 +54,7 @@ def handle_authentication(headers): status = True # All tokens are 32 characters hex lowercase - if len(password) == 32: + if not status and len(password) == 32: for token in user.tokens: if (token.check_password(password) and (not token.ip or token.ip == ip)): diff --git a/core/admin/mailu/ui/views/base.py b/core/admin/mailu/ui/views/base.py index 1aff7db1..457fd7fb 100644 --- a/core/admin/mailu/ui/views/base.py +++ b/core/admin/mailu/ui/views/base.py @@ -1,6 +1,7 @@ from mailu import models from mailu.ui import ui, forms, access +from flask import current_app as app import flask import flask_login From 80f939cf1ac3fe9863825de1360ee7c5d67926f6 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 8 Feb 2021 10:16:03 +0100 Subject: [PATCH 6/8] Revert to the old behaviour when ADMIN=false --- core/nginx/conf/nginx.conf | 10 +++++++--- webmails/rainloop/application.ini | 4 +++- webmails/roundcube/config.inc.php | 10 ++++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/core/nginx/conf/nginx.conf b/core/nginx/conf/nginx.conf index a7a9e134..81f1ac0d 100644 --- a/core/nginx/conf/nginx.conf +++ b/core/nginx/conf/nginx.conf @@ -133,10 +133,12 @@ http { {% endif %} include /etc/nginx/proxy.conf; client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }}; - auth_request /internal/auth/user; proxy_pass http://$webmail; + {% if ADMIN == 'true' %} + auth_request /internal/auth/user; error_page 403 @webmail_login; } + location {{ WEB_WEBMAIL }}/sso.php { {% if WEB_WEBMAIL != '/' %} rewrite ^({{ WEB_WEBMAIL }})$ $1/ permanent; @@ -152,11 +154,13 @@ http { proxy_pass http://$webmail; error_page 403 @webmail_login; } + location @webmail_login { return 302 {{ WEB_ADMIN }}/ui/login?next=ui.webmail; } - {% endif %} - + {% else %} + } + {% endif %}{% endif %} {% if ADMIN == 'true' %} location {{ WEB_ADMIN }} { return 301 {{ WEB_ADMIN }}/ui; diff --git a/webmails/rainloop/application.ini b/webmails/rainloop/application.ini index bc953af1..0504f174 100644 --- a/webmails/rainloop/application.ini +++ b/webmails/rainloop/application.ini @@ -7,9 +7,11 @@ attachment_size_limit = {{ MAX_FILESIZE }} allow_admin_panel = Off [labs] +allow_gravatar = Off +{% if ADMIN == "true" %} custom_login_link='sso.php' custom_logout_link='{{ WEB_ADMIN }}/ui/logout' -allow_gravatar = Off +{% endif %} [contacts] enable = On diff --git a/webmails/roundcube/config.inc.php b/webmails/roundcube/config.inc.php index bb1a5e84..d8028db3 100644 --- a/webmails/roundcube/config.inc.php +++ b/webmails/roundcube/config.inc.php @@ -17,8 +17,7 @@ $config['plugins'] = array( 'markasjunk', 'managesieve', 'enigma', - 'carddav', - 'mailu' + 'carddav' ); $front = getenv('FRONT_ADDRESS') ? getenv('FRONT_ADDRESS') : 'front'; @@ -37,8 +36,11 @@ $config['managesieve_host'] = $imap; $config['managesieve_usetls'] = false; // Customization settings -$config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : ''; -$config['sso_logout_url'] = getenv('WEB_ADMIN').'/ui/logout'; +if (filter_var(getenv('ADMIN'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) { + array_push($config['plugins'], 'mailu'); + $config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : ''; + $config['sso_logout_url'] = getenv('WEB_ADMIN').'/ui/logout'; +} $config['product_name'] = 'Mailu Webmail'; // We access the IMAP and SMTP servers locally with internal names, SSL From 0917a6817f845c01a2247d74f111abc2ef54e51a Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 8 Feb 2021 10:17:43 +0100 Subject: [PATCH 7/8] Set ADMIN=false to ensure that the tests pass --- tests/compose/rainloop/mailu.env | 2 +- tests/compose/roundcube/mailu.env | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/compose/rainloop/mailu.env b/tests/compose/rainloop/mailu.env index 9c31c8bb..47b0b934 100644 --- a/tests/compose/rainloop/mailu.env +++ b/tests/compose/rainloop/mailu.env @@ -51,7 +51,7 @@ DISABLE_STATISTICS=False ################################### # Expose the admin interface (value: true, false) -ADMIN=true +ADMIN=false # Choose which webmail to run if any (values: roundcube, rainloop, none) WEBMAIL=rainloop diff --git a/tests/compose/roundcube/mailu.env b/tests/compose/roundcube/mailu.env index dc503268..887cebbd 100644 --- a/tests/compose/roundcube/mailu.env +++ b/tests/compose/roundcube/mailu.env @@ -51,7 +51,7 @@ DISABLE_STATISTICS=False ################################### # Expose the admin interface (value: true, false) -ADMIN=true +ADMIN=false # Choose which webmail to run if any (values: roundcube, rainloop, none) WEBMAIL=roundcube From e8f70c12dcdce1064ea063cc5ddcae28a5221613 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 8 Feb 2021 10:22:25 +0100 Subject: [PATCH 8/8] avoid a warning --- webmails/roundcube/start.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webmails/roundcube/start.py b/webmails/roundcube/start.py index 9ce383c8..45b2aa76 100755 --- a/webmails/roundcube/start.py +++ b/webmails/roundcube/start.py @@ -40,7 +40,7 @@ os.system("mkdir -p /data/gpg /var/www/html/logs") os.system("touch /var/www/html/logs/errors.log") os.system("chown -R www-data:www-data /var/www/html/logs") os.system("chmod -R a+rX /var/www/html/") -os.system("ln -s /var/www/html/index.php /var/www/html/sso.php") +os.system("ln -sf /var/www/html/index.php /var/www/html/sso.php") try: print("Initializing database")