Merge remote-tracking branch 'upstream/master' into update_deps

master
Alexander Graf 3 years ago
commit 13e6793c9f

@ -39,7 +39,7 @@ RUN set -eu \
&& pip install -r requirements.txt \ && pip install -r requirements.txt \
&& apk del --no-cache build-dep && apk del --no-cache build-dep
COPY --from=assets static ./mailu/ui/static COPY --from=assets static ./mailu/static
COPY mailu ./mailu COPY mailu ./mailu
COPY migrations ./migrations COPY migrations ./migrations
COPY start.py /start.py COPY start.py /start.py
@ -53,4 +53,4 @@ ENV FLASK_APP mailu
CMD /start.py CMD /start.py
HEALTHCHECK CMD curl -f -L http://localhost/ui/login?next=ui.index || exit 1 HEALTHCHECK CMD curl -f -L http://localhost/sso/login?next=ui.index || exit 1

@ -43,7 +43,7 @@ $('document').ready(function() {
var infinity = $(this).data('infinity'); var infinity = $(this).data('infinity');
var step = $(this).attr('step'); var step = $(this).attr('step');
$(this).on('input', function() { $(this).on('input', function() {
value_element.text((infinity && this.value == 0) ? '∞' : this.value/step); value_element.text((infinity && this.value == 0) ? '∞' : (this.value/step).toFixed(2));
}).trigger('input'); }).trigger('input');
} }
}); });

@ -11,7 +11,7 @@ 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
""" """
app = flask.Flask(__name__) app = flask.Flask(__name__, static_folder='static', static_url_path='/static')
app.cli.add_command(manage.mailu) app.cli.add_command(manage.mailu)
# Bootstrap is used for error display and flash messages # Bootstrap is used for error display and flash messages
@ -58,10 +58,10 @@ def create_app_from_config(config):
) )
# Import views # Import views
from mailu import ui, internal from mailu import ui, internal, sso
app.register_blueprint(ui.ui, url_prefix='/ui') app.register_blueprint(ui.ui, url_prefix=app.config['WEB_ADMIN'])
app.register_blueprint(internal.internal, url_prefix='/internal') app.register_blueprint(internal.internal, url_prefix='/internal')
app.register_blueprint(sso.sso, url_prefix='/sso')
return app return app
@ -70,3 +70,4 @@ def create_app():
""" """
config = configuration.ConfigManager() config = configuration.ConfigManager()
return create_app_from_config(config) return create_app_from_config(config)

@ -58,6 +58,7 @@ DEFAULT_CONFIG = {
# Web settings # Web settings
'SITENAME': 'Mailu', 'SITENAME': 'Mailu',
'WEBSITE': 'https://mailu.io', 'WEBSITE': 'https://mailu.io',
'ADMIN' : 'none',
'WEB_ADMIN': '/admin', 'WEB_ADMIN': '/admin',
'WEB_WEBMAIL': '/webmail', 'WEB_WEBMAIL': '/webmail',
'WEBMAIL': 'none', 'WEBMAIL': 'none',

@ -78,12 +78,6 @@ def handle_authentication(headers):
# Authenticated user # Authenticated user
elif method == "plain": elif method == "plain":
is_valid_user = False is_valid_user = False
if headers["Auth-Port"] == '25':
return {
"Auth-Status": "AUTH not supported",
"Auth-Error-Code": "502 5.5.1",
"Auth-Wait": 0
}
# According to RFC2616 section 3.7.1 and PEP 3333, HTTP headers should # According to RFC2616 section 3.7.1 and PEP 3333, HTTP headers should
# be ASCII and are generally considered ISO8859-1. However when passing # be ASCII and are generally considered ISO8859-1. However when passing
# the password, nginx does not transcode the input UTF string, thus # the password, nginx does not transcode the input UTF string, thus

@ -11,6 +11,13 @@ def nginx_authentication():
""" Main authentication endpoint for Nginx email server """ Main authentication endpoint for Nginx email server
""" """
client_ip = flask.request.headers["Client-Ip"] client_ip = flask.request.headers["Client-Ip"]
headers = flask.request.headers
if headers["Auth-Port"] == '25' and headers['Auth-Method'] == 'plain':
response = flask.Response()
response.headers['Auth-Status'] = 'AUTH not supported'
response.headers['Auth-Error-Code'] = '502 5.5.1'
utils.limiter.rate_limit_ip(client_ip)
return response
if utils.limiter.should_rate_limit_ip(client_ip): if utils.limiter.should_rate_limit_ip(client_ip):
status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit') status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit')
response = flask.Response() response = flask.Response()
@ -41,7 +48,7 @@ def nginx_authentication():
elif is_valid_user: elif is_valid_user:
utils.limiter.rate_limit_user(username, client_ip) utils.limiter.rate_limit_user(username, client_ip)
else: else:
rate_limit_ip(client_ip) utils.limiter.rate_limit_ip(client_ip)
return response return response
@internal.route("/auth/admin") @internal.route("/auth/admin")

@ -0,0 +1,5 @@
from flask import Blueprint
sso = Blueprint('sso', __name__, static_folder=None, template_folder='templates')
from mailu.sso.views import *

@ -0,0 +1,11 @@
from wtforms import validators, fields
from flask_babel import lazy_gettext as _
import flask_wtf
class LoginForm(flask_wtf.FlaskForm):
class Meta:
csrf = False
email = fields.StringField(_('E-mail'), [validators.Email(), validators.DataRequired()])
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
submitAdmin = fields.SubmitField(_('Sign in'))
submitWebmail = fields.SubmitField(_('Sign in'))

@ -0,0 +1,86 @@
{%- import "macros.html" as macros %}
{%- import "bootstrap/utils.html" as utils %}
<!doctype html>
<html lang="{{ session['language'] }}" data-static="/static/">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% trans %}Admin page for{% endtrans %} {{ config["SITENAME"] }}">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Mailu-Admin | {{ config["SITENAME"] }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head>
<body class="hold-transition sidebar-mini layout-fixed">
<div class="wrapper">
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars" title="{% trans %}toggle sidebar{% endtrans %}" aria-expanded="false"></i><span class="sr-only">{% trans %}toggle sidebar{% endtrans %}</span></a>
</li>
<li class="nav-item">
{%- for page, url in path %}
{%- if loop.index > 1 %}
<i class="fas fa-greater-than text-xs text-gray" aria-hidden="true"></i>
{%- endif %}
{%- if url %}
<a class="nav-link d-inline-block" href="{{ url }}" role="button">{{ page }}</a>
{%- else %}
<span class="nav-link d-inline-block">{{ page }}</span>
{%- endif %}
{%- endfor %}
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item dropdown">
<a class="nav-link" data-toggle="dropdown" href="#" aria-expanded="false">
<i class="fas fa-language text-xl" aria-hidden="true" title="{% trans %}change language{% endtrans %}"></i><span class="sr-only">Language</span>
<span class="badge badge-primary navbar-badge">{{ session['language'] }}</span></a>
<div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages">
{%- for locale in config.translations.values() %}
<a class="dropdown-item{% if locale|string() == session['language'] %} active{% endif %}" href="{{ url_for('sso.set_language', language=locale) }}">{{ locale.get_language_name().title() }}</a>
{%- endfor %}
</div>
</li>
</ul>
</nav>
<aside class="main-sidebar sidebar-dark-primary nav-compact elevation-4">
<a class="brand-link bg-mailu-logo"{% if config["LOGO_BACKGROUND"] %} style="background-color:{{ config["LOGO_BACKGROUND"] }}!important;"{% endif %}>
<img src="{{ config["LOGO_URL"] if config["LOGO_URL"] else '/static/mailu.png' }}" width="33" height="33" alt="Mailu" class="brand-image mailu-logo img-circle elevation-3">
<span class="brand-text font-weight-light">{{ config["SITENAME"] }}</span>
</a>
{%- include "sidebar_sso.html" %}
</aside>
<div class="content-wrapper text-sm">
<section class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0">{%- block title %}{%- endblock %}</h1>
<small>{% block subtitle %}{% endblock %}</small>
</div>
<div class="col-sm-6">
{%- block main_action %}{%- endblock %}
</div>
</div>
</div>
</section>
<div class="content">
{{ utils.flashed_messages(container=False, default_category='success') }}
{%- block content %}{%- endblock %}
</div>
</div>
<footer class="main-footer">
Built with <i class="fa fa-heart text-danger" aria-hidden="true"></i><span class="sr-only">love</span>
using <a href="https://flask.palletsprojects.com/">Flask</a>
and <a href="https://adminlte.io/themes/v3/index3.html">AdminLTE</a>.
<span class="fa-pull-right">
<i class="fa fa-code-branch" aria-hidden="true"></i><span class="sr-only">fork</span>
on <a href="https://github.com/Mailu/Mailu">Github</a>
</span>
</footer>
</div>
<script src="{{ url_for('static', filename='vendor.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

@ -0,0 +1,11 @@
{%- extends "base_sso.html" %}
{%- block content %}
{%- call macros.card() %}
<form class="form" method="post" role="form">
{{ macros.form_field(form.email) }}
{{ macros.form_field(form.pw) }}
{{ macros.form_fields(fields, label=False, class="btn btn-default", spacing=False) }}
</form>
{%- endcall %}
{%- endblock %}

@ -0,0 +1,5 @@
{%- extends "form_sso.html" %}
{%- block title %}
{% trans %}Sign in{% endtrans %}
{%- endblock %}

@ -0,0 +1,55 @@
<div class="sidebar text-sm">
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" role="menu">
<li class="nav-header text-uppercase text-primary" role="none">{% trans %}Go to{% endtrans %}</li>
{%- if config['ADMIN'] %}
<li class="nav-item">
<a href="{{ url_for('ui.client') }}" class="nav-link">
<i class="nav-icon fa fa-laptop"></i>
<p class="text">{% trans %}Client setup{% endtrans %}</p>
</a>
</li>
{%- endif %}
<li class="nav-item" role="none">
<a href="{{ config["WEBSITE"] }}" target="_blank" class="nav-link" role="menuitem" rel="noreferrer">
<i class="nav-icon fa fa-globe"></i>
<p>{% trans %}Website{% endtrans %} <i class="fas fa-external-link-alt text-xs"></i></p>
</a>
</li>
<li class="nav-item" role="none">
<a href="https://mailu.io" target="_blank" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-life-ring"></i>
<p class="text">{% trans %}Help{% endtrans %} <i class="fas fa-external-link-alt text-xs"></i></p>
</a>
</li>
{#-
Domain self-registration is only available when
- Admin is available
- Domain Self-registration is enabled
- The current user is not logged on
#}
{%- if config['DOMAIN_REGISTRATION'] and not current_user.is_authenticated and config['ADMIN'] %}
<li class="nav-item" role="none">
<a href="{{ url_for('ui.domain_signup') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-plus-square"></i>
<p class="text">{% trans %}Register a domain{% endtrans %}</p>
</a>
</li>
{%- endif %}
{#-
User self-registration is only available when
- Admin is available
- Self-registration is enabled
- The current user is not logged on
#}
{%- if not current_user.is_authenticated and signup_domains and config['ADMIN'] %}
<li class="nav-item" role="none">
<a href="{{ url_for('ui.user_signup') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-user-plus"></i>
<p class="text">{% trans %}Sign up{% endtrans %}</p>
</a>
</li>
{%- endif %}
</ul>
</nav>
</div>

@ -0,0 +1,3 @@
__all__ = [
'base', 'languages'
]

@ -0,0 +1,56 @@
from werkzeug.utils import redirect
from mailu import models, utils
from mailu.sso import sso, forms
from mailu.ui import access
from flask import current_app as app
import flask
import flask_login
@sso.route('/login', methods=['GET', 'POST'])
def login():
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
form = forms.LoginForm()
form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin'
form.submitWebmail.label.text = form.submitWebmail.label.text + ' Webmail'
fields = []
if str(app.config["ADMIN"]).upper() != "FALSE":
fields.append(form.submitAdmin)
if str(app.config["WEBMAIL"]).upper() != "NONE":
fields.append(form.submitWebmail)
if form.validate_on_submit():
if form.submitAdmin.data:
destination = app.config['WEB_ADMIN']
elif form.submitWebmail.data:
destination = app.config['WEB_WEBMAIL']
device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit'))
username = form.email.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)
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)
user = models.User.login(username, form.pw.data)
if user:
flask.session.regenerate()
flask_login.login_user(user)
response = flask.redirect(destination)
response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'))
flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.')
return response
else:
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')
return flask.render_template('login.html', form=form, fields=fields)
@sso.route('/logout', methods=['GET'])
@access.authenticated
def logout():
flask_login.logout_user()
flask.session.destroy()
return flask.redirect(flask.url_for('.login'))

@ -0,0 +1,7 @@
from mailu.sso import sso
import flask
@sso.route('/language/<language>', methods=['POST'])
def set_language(language=None):
flask.session['language'] = language
return flask.Response(status=200)

@ -1,6 +1,6 @@
from flask import Blueprint from flask import Blueprint
ui = Blueprint('ui', __name__, static_folder='static', template_folder='templates') ui = Blueprint('ui', __name__, static_folder=None, template_folder='templates')
from mailu.ui.views import * from mailu.ui.views import *

@ -44,15 +44,6 @@ class MultipleEmailAddressesVerify(object):
class ConfirmationForm(flask_wtf.FlaskForm): class ConfirmationForm(flask_wtf.FlaskForm):
submit = fields.SubmitField(_('Confirm')) submit = fields.SubmitField(_('Confirm'))
class LoginForm(flask_wtf.FlaskForm):
class Meta:
csrf = False
email = fields.StringField(_('E-mail'), [validators.Email()])
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
submit = fields.SubmitField(_('Sign in'))
class DomainForm(flask_wtf.FlaskForm): class DomainForm(flask_wtf.FlaskForm):
name = fields.StringField(_('Domain name'), [validators.DataRequired()]) name = fields.StringField(_('Domain name'), [validators.DataRequired()])
max_users = fields_.IntegerField(_('Maximum user count'), [validators.NumberRange(min=-1)], default=10) max_users = fields_.IntegerField(_('Maximum user count'), [validators.NumberRange(min=-1)], default=10)

@ -1,15 +1,15 @@
{%- import "macros.html" as macros %} {%- import "macros.html" as macros %}
{%- import "bootstrap/utils.html" as utils %} {%- import "bootstrap/utils.html" as utils %}
<!doctype html> <!doctype html>
<html lang="{{ session['language'] }}" data-static="{{ url_for('.static', filename='') }}"> <html lang="{{ session['language'] }}" data-static="/static/">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% trans %}Admin page for{% endtrans %} {{ config["SITENAME"] }}"> <meta name="description" content="{% trans %}Admin page for{% endtrans %} {{ config["SITENAME"] }}">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Mailu-Admin | {{ config["SITENAME"] }}</title> <title>Mailu-Admin | {{ config["SITENAME"] }}</title>
<link rel="stylesheet" href="{{ url_for('.static', filename='vendor.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor.css') }}">
<link rel="stylesheet" href="{{ url_for('.static', filename='app.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head> </head>
<body class="hold-transition sidebar-mini layout-fixed"> <body class="hold-transition sidebar-mini layout-fixed">
<div class="wrapper"> <div class="wrapper">
@ -38,7 +38,7 @@
<span class="badge badge-primary navbar-badge">{{ session['language'] }}</span></a> <span class="badge badge-primary navbar-badge">{{ session['language'] }}</span></a>
<div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages"> <div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages">
{%- for locale in config.translations.values() %} {%- for locale in config.translations.values() %}
<a class="dropdown-item{% if locale.language == session['language'] %} active{% endif %}" href="{{ url_for('.set_language', language=locale.language) }}">{{ locale.get_language_name().title() }}</a> <a class="dropdown-item{% if locale|string() == session['language'] %} active{% endif %}" href="{{ url_for('.set_language', language=locale) }}">{{ locale.get_language_name().title() }}</a>
{%- endfor %} {%- endfor %}
</div> </div>
</li> </li>
@ -46,7 +46,7 @@
</nav> </nav>
<aside class="main-sidebar sidebar-dark-primary nav-compact elevation-4"> <aside class="main-sidebar sidebar-dark-primary nav-compact elevation-4">
<a href="{{ url_for('.domain_list' if current_user.manager_of or current_user.global_admin else '.user_settings') }}" class="brand-link bg-mailu-logo"{% if config["LOGO_BACKGROUND"] %} style="background-color:{{ config["LOGO_BACKGROUND"] }}!important;"{% endif %}> <a href="{{ url_for('.domain_list' if current_user.manager_of or current_user.global_admin else '.user_settings') }}" class="brand-link bg-mailu-logo"{% if config["LOGO_BACKGROUND"] %} style="background-color:{{ config["LOGO_BACKGROUND"] }}!important;"{% endif %}>
<img src="{{ config["LOGO_URL"] if config["LOGO_URL"] else url_for('.static', filename='mailu.png') }}" width="33" height="33" alt="Mailu" class="brand-image mailu-logo img-circle elevation-3"> <img src="{{ config["LOGO_URL"] if config["LOGO_URL"] else url_for('static', filename='mailu.png') }}" width="33" height="33" alt="Mailu" class="brand-image mailu-logo img-circle elevation-3">
<span class="brand-text font-weight-light">{{ config["SITENAME"] }}</span> <span class="brand-text font-weight-light">{{ config["SITENAME"] }}</span>
</a> </a>
{%- include "sidebar.html" %} {%- include "sidebar.html" %}
@ -80,7 +80,7 @@
</span> </span>
</footer> </footer>
</div> </div>
<script src="{{ url_for('.static', filename='vendor.js') }}"></script> <script src="{{ url_for('static', filename='vendor.js') }}"></script>
<script src="{{ url_for('.static', filename='app.js') }}"></script> <script src="{{ url_for('static', filename='app.js') }}"></script>
</body> </body>
</html> </html>

@ -1,18 +0,0 @@
{%- extends "form.html" %}
{%- block title %}
{% trans %}Sign in{% endtrans %}
{%- endblock %}
{%- block subtitle %}
{% trans %}to access the administration tools{% endtrans %}
{%- endblock %}
{%- block content %}
{% if config["SESSION_COOKIE_SECURE"] %}
<div id="login_needs_https" class="alert alert-danger d-none" role="alert">
{% trans %}The login form has been disabled as <b>SESSION_COOKIE_SECURE</b> is on but you are accessing Mailu over HTTP.{% endtrans %}
</div>
{% endif %}
{{ super() }}
{%- endblock %}

@ -18,8 +18,12 @@
{%- endif %} {%- endif %}
{%- endmacro %} {%- endmacro %}
{%- macro form_fields(fields, prepend='', append='', label=True) %} {%- macro form_fields(fields, prepend='', append='', label=True, spacing=True) %}
{%- if spacing %}
{%- set width = (12 / fields|length)|int %} {%- set width = (12 / fields|length)|int %}
{%- else %}
{%- set width = 0 %}
{% endif %}
<div class="form-group"> <div class="form-group">
<div class="row"> <div class="row">
{%- for field in fields %} {%- for field in fields %}

@ -92,7 +92,7 @@
{%- endif %} {%- endif %}
<li class="nav-header text-uppercase text-primary" role="none">{% trans %}Go to{% endtrans %}</li> <li class="nav-header text-uppercase text-primary" role="none">{% trans %}Go to{% endtrans %}</li>
{%- if config["WEBMAIL"] != "none" %} {%- if config["WEBMAIL"] != "none" and current_user.is_authenticated %}
<li class="nav-item" role="none"> <li class="nav-item" role="none">
<a href="{{ config["WEB_WEBMAIL"] }}" target="_blank" class="nav-link" role="menuitem"> <a href="{{ config["WEB_WEBMAIL"] }}" target="_blank" class="nav-link" role="menuitem">
<i class="nav-icon far fa-envelope"></i> <i class="nav-icon far fa-envelope"></i>
@ -130,14 +130,14 @@
{%- endif %} {%- endif %}
{%- if current_user.is_authenticated %} {%- if current_user.is_authenticated %}
<li class="nav-item" role="none"> <li class="nav-item" role="none">
<a href="{{ url_for('.logout') }}" class="nav-link" role="menuitem"> <a href="{{ url_for('sso.logout') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-sign-out-alt"></i> <i class="nav-icon fas fa-sign-out-alt"></i>
<p>{% trans %}Sign out{% endtrans %}</p> <p>{% trans %}Sign out{% endtrans %}</p>
</a> </a>
</li> </li>
{%- else %} {% else %}
<li class="nav-item" role="none"> <li class="nav-item" role="none">
<a href="{{ url_for('.login') }}" class="nav-link" role="menuitem"> <a href="{{ url_for('sso.login') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-sign-in-alt"></i> <i class="nav-icon fas fa-sign-in-alt"></i>
<p>{% trans %}Sign in{% endtrans %}</p> <p>{% trans %}Sign in{% endtrans %}</p>
</a> </a>

@ -11,45 +11,6 @@ import flask_login
def index(): def index():
return flask.redirect(flask.url_for('.user_settings')) return flask.redirect(flask.url_for('.user_settings'))
@ui.route('/login', methods=['GET', 'POST'])
def login():
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
form = forms.LoginForm()
if form.validate_on_submit():
device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit'))
username = form.email.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)
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)
user = models.User.login(username, form.pw.data)
if user:
flask.session.regenerate()
flask_login.login_user(user)
endpoint = flask.request.args.get('next', '.index')
response = flask.redirect(flask.url_for(endpoint)
or flask.url_for('.index'))
response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('ui.login'))
flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.')
return response
else:
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')
return flask.render_template('login.html', form=form)
@ui.route('/logout', methods=['GET'])
@access.authenticated
def logout():
flask_login.logout_user()
flask.session.destroy()
return flask.redirect(flask.url_for('.index'))
@ui.route('/announcement', methods=['GET', 'POST']) @ui.route('/announcement', methods=['GET', 'POST'])
@access.global_admin @access.global_admin
def announcement(): def announcement():
@ -72,7 +33,6 @@ def webmail():
def client(): def client():
return flask.render_template('client.html') return flask.render_template('client.html')
@ui.route('/antispam', methods=['GET']) @ui.route('/webui_antispam', methods=['GET'])
def antispam(): def antispam():
return flask.render_template('antispam.html') return flask.render_template('antispam.html')

@ -36,13 +36,13 @@ from werkzeug.middleware.proxy_fix import ProxyFix
# Login configuration # Login configuration
login = flask_login.LoginManager() login = flask_login.LoginManager()
login.login_view = "ui.login" login.login_view = "sso.login"
@login.unauthorized_handler @login.unauthorized_handler
def handle_needs_login(): def handle_needs_login():
""" redirect unauthorized requests to login page """ """ redirect unauthorized requests to login page """
return flask.redirect( return flask.redirect(
flask.url_for('ui.login', next=flask.request.endpoint) flask.url_for('sso.login')
) )
# DNS stub configured to do DNSSEC enabled queries # DNS stub configured to do DNSSEC enabled queries
@ -107,9 +107,6 @@ class PrefixMiddleware(object):
self.app = None self.app = None
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
prefix = environ.get('HTTP_X_FORWARDED_PREFIX', '')
if prefix:
environ['SCRIPT_NAME'] = prefix
return self.app(environ, start_response) return self.app(environ, start_response)
def init_app(self, app): def init_app(self, app):

@ -16,7 +16,7 @@ COPY conf /conf
COPY static /static COPY static /static
COPY *.py / COPY *.py /
RUN gzip -k9 /static/*.ico /static/*.txt RUN gzip -k9 /static/*.ico /static/*.txt; chmod a+rX -R /static
EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp 10025/tcp 10143/tcp EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp 10025/tcp 10143/tcp
VOLUME ["/certs"] VOLUME ["/certs"]

@ -1,7 +1,7 @@
# Basic configuration # Basic configuration
user nginx; user nginx;
worker_processes auto; worker_processes auto;
error_log /dev/stderr info; error_log /dev/stderr notice;
pid /var/run/nginx.pid; pid /var/run/nginx.pid;
load_module "modules/ngx_mail_module.so"; load_module "modules/ngx_mail_module.so";
@ -13,7 +13,6 @@ http {
# Standard HTTP configuration with slight hardening # Standard HTTP configuration with slight hardening
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
access_log /dev/stdout;
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
server_tokens off; server_tokens off;
@ -38,6 +37,13 @@ http {
~*\.(ico|css|js|gif|jpeg|jpg|png|woff2?|ttf|otf|svg|tiff|eot|webp)$ 97d; ~*\.(ico|css|js|gif|jpeg|jpg|png|woff2?|ttf|otf|svg|tiff|eot|webp)$ 97d;
} }
map $request_uri $loggable {
/health 0;
/auth/email 0;
default 1;
}
access_log /dev/stdout combined if=$loggable;
# compression # compression
gzip on; gzip on;
gzip_static on; gzip_static on;
@ -128,6 +134,13 @@ http {
include /overrides/*.conf; include /overrides/*.conf;
# Actual logic # Actual logic
{% if ADMIN == 'true' or WEBMAIL != 'none' %}
location ~ ^/(sso|static) {
include /etc/nginx/proxy.conf;
proxy_pass http://$admin;
}
{% endif %}
{% if WEB_WEBMAIL != '/' and WEBROOT_REDIRECT != 'none' %} {% if WEB_WEBMAIL != '/' and WEBROOT_REDIRECT != 'none' %}
location / { location / {
expires $expires; expires $expires;
@ -147,10 +160,9 @@ http {
{% endif %} {% endif %}
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;
{% if ADMIN == 'true' %}
auth_request /internal/auth/user; auth_request /internal/auth/user;
error_page 403 @webmail_login; error_page 403 @webmail_login;
proxy_pass http://$webmail;
} }
location {{ WEB_WEBMAIL }}/sso.php { location {{ WEB_WEBMAIL }}/sso.php {
@ -165,25 +177,17 @@ http {
auth_request_set $token $upstream_http_x_user_token; auth_request_set $token $upstream_http_x_user_token;
proxy_set_header X-Remote-User $user; proxy_set_header X-Remote-User $user;
proxy_set_header X-Remote-User-Token $token; proxy_set_header X-Remote-User-Token $token;
proxy_pass http://$webmail;
error_page 403 @webmail_login; error_page 403 @webmail_login;
proxy_pass http://$webmail;
} }
location @webmail_login { location @webmail_login {
return 302 {{ WEB_ADMIN }}/ui/login?next=ui.webmail; return 302 /sso/login;
} }
{% else %} {% endif %}
}
{% endif %}{% endif %}
{% if ADMIN == 'true' %} {% if ADMIN == 'true' %}
location {{ WEB_ADMIN }} { location {{ WEB_ADMIN }} {
return 301 {{ WEB_ADMIN }}/ui;
}
location ~ {{ WEB_ADMIN }}/(ui|static) {
rewrite ^{{ WEB_ADMIN }}/(.*) /$1 break;
include /etc/nginx/proxy.conf; include /etc/nginx/proxy.conf;
proxy_set_header X-Forwarded-Prefix {{ WEB_ADMIN }};
proxy_pass http://$admin; proxy_pass http://$admin;
expires $expires; expires $expires;
} }
@ -248,6 +252,7 @@ mail {
auth_http http://127.0.0.1:8000/auth/email; auth_http http://127.0.0.1:8000/auth/email;
proxy_pass_error_message on; proxy_pass_error_message on;
resolver {{ RESOLVER }} ipv6=off valid=30s; resolver {{ RESOLVER }} ipv6=off valid=30s;
error_log /dev/stderr info;
{% if TLS and not TLS_ERROR %} {% if TLS and not TLS_ERROR %}
include /etc/nginx/tls.conf; include /etc/nginx/tls.conf;

@ -17,7 +17,7 @@ queue_directory = /queue
message_size_limit = {{ MESSAGE_SIZE_LIMIT }} message_size_limit = {{ MESSAGE_SIZE_LIMIT }}
# Relayed networks # Relayed networks
mynetworks = 127.0.0.1/32 [::1]/128 {{ SUBNET }} {{ RELAYNETS }} mynetworks = 127.0.0.1/32 [::1]/128 {{ SUBNET }} {{ RELAYNETS.split(",") | join(' ') }}
# Empty alias list to override the configuration variable and disable NIS # Empty alias list to override the configuration variable and disable NIS
alias_maps = alias_maps =

@ -0,0 +1,3 @@
{% if RELAYNETS %}
local_networks = [{{ RELAYNETS }}];
{% endif %}

@ -0,0 +1,4 @@
apply {
# see https://github.com/Mailu/Mailu/issues/1705
RCVD_NO_TLS_LAST = 0;
}

@ -73,14 +73,14 @@ The ``MESSAGE_RATELIMIT`` is the limit of messages a single user can send. This
meant to fight outbound spam in case of compromised or malicious account on the meant to fight outbound spam in case of compromised or malicious account on the
server. server.
The ``RELAYNETS`` are network addresses for which mail is relayed for free with The ``RELAYNETS`` (default: unset) is a comma delimited list of network addresses
no authentication required. This should be used with great care. If you want other for which mail is relayed for with no authentication required. This should be
Docker services' outbound mail to be relayed, you can set this to ``172.16.0.0/12`` used with great care as misconfigurations may turn your Mailu instance into an
to include **all** Docker networks. The default is to leave this empty. open-relay!
The ``RELAYHOST`` is an optional address of a mail server relaying all outgoing The ``RELAYHOST`` is an optional address to use as a smarthost for all outgoing
mail in following format: ``[HOST]:PORT``. mail in following format: ``[HOST]:PORT``. ``RELAYUSER`` and ``RELAYPASSWORD``
``RELAYUSER`` and ``RELAYPASSWORD`` can be used when authentication is needed. can be used when authentication is required.
By default postfix uses "opportunistic TLS" for outbound mail. This can be changed By default postfix uses "opportunistic TLS" for outbound mail. This can be changed
by setting ``OUTBOUND_TLS_LEVEL`` to ``encrypt`` or ``secure``. This setting is by setting ``OUTBOUND_TLS_LEVEL`` to ``encrypt`` or ``secure``. This setting is

@ -47,19 +47,15 @@ Then on your own frontend, point to these local ports. In practice, you only nee
} }
} }
Because the admin interface is served as ``/admin`` and the Webmail as ``/webmail`` you may also want to use a single virtual host and serve other applications (still Nginx): Because the admin interface is served as ``/admin``, the Webmail as ``/webmail``, the single sign on page as ``/sso``, webdav as ``/webdav`` and the static files endpoint as ``/static``, you may also want to use a single virtual host and serve other applications (still Nginx):
.. code-block:: nginx .. code-block:: nginx
server { server {
# [...] here goes your standard configuration # [...] here goes your standard configuration
location /webmail { location ~ ^/(admin|sso|static|webdav|webmail)/ {
proxy_pass https://localhost:8443/webmail; proxy_pass https://localhost:8443;
}
location /admin {
proxy_pass https://localhost:8443/admin;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
} }

@ -90,7 +90,6 @@ services:
env_file: {{ env }} env_file: {{ env }}
volumes: volumes:
- "{{ root }}/filter:/var/lib/rspamd" - "{{ root }}/filter:/var/lib/rspamd"
- "{{ root }}/dkim:/dkim:ro"
- "{{ root }}/overrides/rspamd:/etc/rspamd/override.d:ro" - "{{ root }}/overrides/rspamd:/etc/rspamd/override.d:ro"
depends_on: depends_on:
- front - front

@ -74,7 +74,6 @@ services:
env_file: {{ env }} env_file: {{ env }}
volumes: volumes:
- "{{ root }}/filter:/var/lib/rspamd" - "{{ root }}/filter:/var/lib/rspamd"
- "{{ root }}/dkim:/dkim:ro"
- "{{ root }}/overrides/rspamd:/etc/rspamd/override.d:ro" - "{{ root }}/overrides/rspamd:/etc/rspamd/override.d:ro"
deploy: deploy:
replicas: 1 replicas: 1

@ -0,0 +1 @@
Ensure that RCVD_NO_TLS_LAST doesn't add to the spam score (as TLS usage can't be determined)

@ -0,0 +1,10 @@
Improved the SSO page. Warning! The new endpoints /sso and /static are introduced.
These endpoints are now used for handling sign on requests and shared static files.
You may want to update your reverse proxy to proxy /sso and /static to Mailu (to the front service).
The example section of using a reverse proxy is updated with this information.
- New SSO page is used for logging in Admin or Webmail.
- Made SSO page available separately. SSO page can now be used without Admin accessible (ADMIN=false).
- Introduced stub /static which is used by all sites for accessing static files.
- Removed the /admin/ prefix to reduce complexity of routing with Mailu. Admin is accessible directly via /admin instead of /admin/ui
Note: Failed logon attempts are logged in the logs of admin. You can watch this with fail2ban.

@ -0,0 +1 @@
RELAYNETS should be a comma separated list of networks

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

@ -37,11 +37,11 @@ $config['managesieve_usetls'] = false;
// Customization settings // Customization settings
if (filter_var(getenv('ADMIN'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) { 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['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';
array_push($config['plugins'], 'mailu');
$config['sso_logout_url'] = '/sso/logout';
// We access the IMAP and SMTP servers locally with internal names, SSL // We access the IMAP and SMTP servers locally with internal names, SSL
// will obviously fail but this sounds better than allowing insecure login // will obviously fail but this sounds better than allowing insecure login

Loading…
Cancel
Save