1754: centralize Webmail authentication behind the admin panel (SSO) r=mergify[bot] a=nextgens

## What type of PR?

Enhancement: it centralizes the authentication of webmails to the admin interface.

## What does this PR do?

It implements the glue required for webmails to do SSO using the admin interface.
One of the main advantages of centralizing things this way is that it reduces significantly the attack surface available to an unauthenticated attacker (no webmail access until there is a valid Flask session).

Others include the ability to implement 2FA down the line and rate-limit things as required.

### Related issue(s)
- #783

## Prerequistes
Before we can consider review and merge, please make sure the following list is done and checked.
If an entry in not applicable, you can check it or remove it from the list.

- [x] In case of feature or enhancement: documentation updated accordingly
- [x] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/guide.html#changelog) entry file.


Co-authored-by: Florent Daigniere <nextgens@freenetproject.org>
master
bors[bot] 3 years ago committed by GitHub
commit fc1a663da2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -6,6 +6,7 @@ from simplekv.memory.redisstore import RedisStore
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
@ -28,6 +29,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)

@ -7,7 +7,6 @@ import ipaddress
import socket
import tenacity
SUPPORTED_AUTH_METHODS = ["none", "plain"]
@ -26,8 +25,12 @@ def check_credentials(user, password, ip, protocol=None):
if not user or not user.enabled or (protocol == "imap" and not user.enable_imap) or (protocol == "pop3" and not user.enable_pop):
return False
is_ok = False
# webmails
if len(password) == 64 and ip == app.config['WEBMAIL_ADDRESS']:
if user.verify_temp_token(password):
is_ok = True
# All tokens are 32 characters hex lowercase
if len(password) == 32:
if not is_ok and len(password) == 32:
for token in user.tokens:
if (token.check_password(password) and
(not token.ip or token.ip == ip)):

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

@ -11,6 +11,7 @@ import sqlalchemy
import time
import os
import glob
import hmac
import smtplib
import idna
import dns
@ -458,6 +459,15 @@ in clear-text regardless of the presence of the cache.
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(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(self.get_temp_token(self.email), token)
class Alias(Base, Email):
""" An alias is an email address that redirects to some destination.

@ -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
@ -49,6 +50,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():

@ -136,9 +136,33 @@ http {
include /etc/nginx/proxy.conf;
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
proxy_pass http://$webmail;
{% if ADMIN == 'true' %}
auth_request /internal/auth/user;
error_page 403 @webmail_login;
}
{% endif %}
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;
}
{% else %}
}
{% endif %}{% endif %}
{% if ADMIN == 'true' %}
location {{ WEB_ADMIN }} {
return 301 {{ WEB_ADMIN }}/ui;

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

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

@ -0,0 +1 @@
Centralize the authentication of webmails behind the admin interface

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

@ -8,6 +8,10 @@ allow_admin_panel = Off
[labs]
allow_gravatar = Off
{% if ADMIN == "true" %}
custom_login_link='sso.php'
custom_logout_link='{{ WEB_ADMIN }}/ui/logout'
{% endif %}
[contacts]
enable = On

@ -0,0 +1,31 @@
<?php
$_ENV['RAINLOOP_INCLUDE_AS_API'] = true;
if (!defined('APP_VERSION')) {
$version = file_get_contents('/data/VERSION');
if ($version) {
define('APP_VERSION', $version);
define('APP_INDEX_ROOT_FILE', __FILE__);
define('APP_INDEX_ROOT_PATH', str_replace('\\', '/', rtrim(dirname(__FILE__), '\\/').'/'));
}
}
if (file_exists(APP_INDEX_ROOT_PATH.'rainloop/v/'.APP_VERSION.'/include.php')) {
include APP_INDEX_ROOT_PATH.'rainloop/v/'.APP_VERSION.'/include.php';
} else {
echo '[105] Missing version directory';
exit(105);
}
// Retrieve email and password
if (in_array('HTTP_X_REMOTE_USER', $_SERVER) && in_array('HTTP_X_REMOTE_USER_TOKEN', $_SERVER)) {
$email = $_SERVER['HTTP_X_REMOTE_USER'];
$password = $_SERVER['HTTP_X_REMOTE_USER_TOKEN'];
$ssoHash = \RainLoop\Api::GetUserSsoHash($email, $password);
// redirect to webmail sso url
header('Location: index.php?sso&hash='.$ssoHash);
}
else {
header('HTTP/1.0 403 Forbidden');
}

@ -24,6 +24,7 @@ conf.jinja("/application.ini", os.environ, "/data/_data_/_default_/configs/appli
conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/rainloop.ini")
os.system("chown -R www-data:www-data /data")
os.system("chmod -R a+rX /var/www/html/")
os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])

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

@ -36,7 +36,11 @@ $config['managesieve_host'] = $imap;
$config['managesieve_usetls'] = false;
// Customization settings
$config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : '';
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

@ -0,0 +1,59 @@
<?php
class mailu extends rcube_plugin
{
function init()
{
$this->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();
}
}

@ -37,6 +37,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 -sf /var/www/html/index.php /var/www/html/sso.php")
try:
print("Initializing database")

Loading…
Cancel
Save