mirror of
https://github.com/Mailu/Mailu.git
synced 2025-06-10 23:47:59 +02:00
Merge #1754
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>
This commit is contained in:
commit
fc1a663da2
@ -6,6 +6,7 @@ from simplekv.memory.redisstore import RedisStore
|
|||||||
|
|
||||||
from mailu import utils, debug, models, manage, configuration
|
from mailu import utils, debug, models, manage, configuration
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
|
||||||
def create_app_from_config(config):
|
def create_app_from_config(config):
|
||||||
""" Create a new application based on the given configuration
|
""" 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.proxy.init_app(app)
|
||||||
utils.migrate.init_app(app, models.db)
|
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
|
# Initialize debugging tools
|
||||||
if app.config.get("DEBUG"):
|
if app.config.get("DEBUG"):
|
||||||
debug.toolbar.init_app(app)
|
debug.toolbar.init_app(app)
|
||||||
|
@ -7,7 +7,6 @@ import ipaddress
|
|||||||
import socket
|
import socket
|
||||||
import tenacity
|
import tenacity
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_AUTH_METHODS = ["none", "plain"]
|
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):
|
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
|
return False
|
||||||
is_ok = 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
|
# All tokens are 32 characters hex lowercase
|
||||||
if len(password) == 32:
|
if not is_ok and len(password) == 32:
|
||||||
for token in user.tokens:
|
for token in user.tokens:
|
||||||
if (token.check_password(password) and
|
if (token.check_password(password) and
|
||||||
(not token.ip or token.ip == ip)):
|
(not token.ip or token.ip == ip)):
|
||||||
|
@ -43,6 +43,18 @@ def admin_authentication():
|
|||||||
return ""
|
return ""
|
||||||
return flask.abort(403)
|
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")
|
@internal.route("/auth/basic")
|
||||||
def basic_authentication():
|
def basic_authentication():
|
||||||
|
@ -11,6 +11,7 @@ import sqlalchemy
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import glob
|
import glob
|
||||||
|
import hmac
|
||||||
import smtplib
|
import smtplib
|
||||||
import idna
|
import idna
|
||||||
import dns
|
import dns
|
||||||
@ -458,6 +459,15 @@ in clear-text regardless of the presence of the cache.
|
|||||||
user = cls.query.get(email)
|
user = cls.query.get(email)
|
||||||
return user if (user and user.enabled and user.check_password(password)) else None
|
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):
|
class Alias(Base, Email):
|
||||||
""" An alias is an email address that redirects to some destination.
|
""" An alias is an email address that redirects to some destination.
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from mailu import models
|
from mailu import models
|
||||||
from mailu.ui import ui, forms, access
|
from mailu.ui import ui, forms, access
|
||||||
|
|
||||||
|
from flask import current_app as app
|
||||||
import flask
|
import flask
|
||||||
import flask_login
|
import flask_login
|
||||||
|
|
||||||
@ -49,6 +50,9 @@ def announcement():
|
|||||||
flask.flash('Your announcement was sent', 'success')
|
flask.flash('Your announcement was sent', 'success')
|
||||||
return flask.render_template('announcement.html', form=form)
|
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'])
|
@ui.route('/client', methods=['GET'])
|
||||||
def client():
|
def client():
|
||||||
|
@ -136,9 +136,33 @@ http {
|
|||||||
include /etc/nginx/proxy.conf;
|
include /etc/nginx/proxy.conf;
|
||||||
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
|
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
|
||||||
proxy_pass http://$webmail;
|
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' %}
|
{% if ADMIN == 'true' %}
|
||||||
location {{ WEB_ADMIN }} {
|
location {{ WEB_ADMIN }} {
|
||||||
return 301 {{ WEB_ADMIN }}/ui;
|
return 301 {{ WEB_ADMIN }}/ui;
|
||||||
|
@ -51,7 +51,7 @@ DISABLE_STATISTICS=False
|
|||||||
###################################
|
###################################
|
||||||
|
|
||||||
# Expose the admin interface (value: true, false)
|
# Expose the admin interface (value: true, false)
|
||||||
ADMIN=true
|
ADMIN=false
|
||||||
|
|
||||||
# Choose which webmail to run if any (values: roundcube, rainloop, none)
|
# Choose which webmail to run if any (values: roundcube, rainloop, none)
|
||||||
WEBMAIL=rainloop
|
WEBMAIL=rainloop
|
||||||
|
@ -51,7 +51,7 @@ DISABLE_STATISTICS=False
|
|||||||
###################################
|
###################################
|
||||||
|
|
||||||
# Expose the admin interface (value: true, false)
|
# Expose the admin interface (value: true, false)
|
||||||
ADMIN=true
|
ADMIN=false
|
||||||
|
|
||||||
# Choose which webmail to run if any (values: roundcube, rainloop, none)
|
# Choose which webmail to run if any (values: roundcube, rainloop, none)
|
||||||
WEBMAIL=roundcube
|
WEBMAIL=roundcube
|
||||||
|
1
towncrier/newsfragments/783.feature
Normal file
1
towncrier/newsfragments/783.feature
Normal file
@ -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
|
&& rm -rf /var/lib/apt/lists
|
||||||
|
|
||||||
COPY include.php /var/www/html/include.php
|
COPY include.php /var/www/html/include.php
|
||||||
|
COPY sso.php /var/www/html/sso.php
|
||||||
COPY php.ini /php.ini
|
COPY php.ini /php.ini
|
||||||
|
|
||||||
COPY application.ini /application.ini
|
COPY application.ini /application.ini
|
||||||
|
@ -8,6 +8,10 @@ allow_admin_panel = Off
|
|||||||
|
|
||||||
[labs]
|
[labs]
|
||||||
allow_gravatar = Off
|
allow_gravatar = Off
|
||||||
|
{% if ADMIN == "true" %}
|
||||||
|
custom_login_link='sso.php'
|
||||||
|
custom_logout_link='{{ WEB_ADMIN }}/ui/logout'
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
[contacts]
|
[contacts]
|
||||||
enable = On
|
enable = On
|
||||||
|
31
webmails/rainloop/sso.php
Normal file
31
webmails/rainloop/sso.php
Normal file
@ -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")
|
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("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"])
|
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 php.ini /php.ini
|
||||||
COPY config.inc.php /var/www/html/config/
|
COPY config.inc.php /var/www/html/config/
|
||||||
|
COPY mailu.php /var/www/html/plugins/mailu/mailu.php
|
||||||
COPY start.py /start.py
|
COPY start.py /start.py
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
EXPOSE 80/tcp
|
||||||
|
@ -36,7 +36,11 @@ $config['managesieve_host'] = $imap;
|
|||||||
$config['managesieve_usetls'] = false;
|
$config['managesieve_usetls'] = false;
|
||||||
|
|
||||||
// Customization settings
|
// 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';
|
$config['product_name'] = 'Mailu Webmail';
|
||||||
|
|
||||||
// We access the IMAP and SMTP servers locally with internal names, SSL
|
// We access the IMAP and SMTP servers locally with internal names, SSL
|
||||||
|
59
webmails/roundcube/mailu.php
Normal file
59
webmails/roundcube/mailu.php
Normal file
@ -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("mkdir -p /data/gpg /var/www/html/logs")
|
||||||
os.system("touch /var/www/html/logs/errors.log")
|
os.system("touch /var/www/html/logs/errors.log")
|
||||||
os.system("chown -R www-data:www-data /var/www/html/logs")
|
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:
|
try:
|
||||||
print("Initializing database")
|
print("Initializing database")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user