diff --git a/core/admin/Dockerfile b/core/admin/Dockerfile index 8adf2ca5..5c1227e5 100644 --- a/core/admin/Dockerfile +++ b/core/admin/Dockerfile @@ -39,7 +39,7 @@ RUN set -eu \ && pip install -r requirements.txt \ && apk del --no-cache build-dep -COPY --from=assets static ./mailu/ui/static +COPY --from=assets static ./mailu/static COPY mailu ./mailu COPY migrations ./migrations COPY start.py /start.py @@ -53,4 +53,4 @@ ENV FLASK_APP mailu 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 diff --git a/core/admin/assets/app.js b/core/admin/assets/app.js index 5df8052c..54602d1f 100644 --- a/core/admin/assets/app.js +++ b/core/admin/assets/app.js @@ -43,7 +43,7 @@ $('document').ready(function() { var infinity = $(this).data('infinity'); var step = $(this).attr('step'); $(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'); } }); diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index 7fb04380..e4024e47 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -11,7 +11,7 @@ import hmac def create_app_from_config(config): """ 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) # Bootstrap is used for error display and flash messages @@ -58,10 +58,10 @@ def create_app_from_config(config): ) # Import views - from mailu import ui, internal - app.register_blueprint(ui.ui, url_prefix='/ui') + from mailu import ui, internal, sso + app.register_blueprint(ui.ui, url_prefix=app.config['WEB_ADMIN']) app.register_blueprint(internal.internal, url_prefix='/internal') - + app.register_blueprint(sso.sso, url_prefix='/sso') return app @@ -70,3 +70,4 @@ def create_app(): """ config = configuration.ConfigManager() return create_app_from_config(config) + diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index d33efa12..9829f798 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -58,6 +58,7 @@ DEFAULT_CONFIG = { # Web settings 'SITENAME': 'Mailu', 'WEBSITE': 'https://mailu.io', + 'ADMIN' : 'none', 'WEB_ADMIN': '/admin', 'WEB_WEBMAIL': '/webmail', 'WEBMAIL': 'none', diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 04d6d94e..027db935 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -78,12 +78,6 @@ def handle_authentication(headers): # Authenticated user elif method == "plain": 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 # be ASCII and are generally considered ISO8859-1. However when passing # the password, nginx does not transcode the input UTF string, thus diff --git a/core/admin/mailu/internal/templates/default.sieve b/core/admin/mailu/internal/templates/default.sieve index 5e995611..a29e7aba 100644 --- a/core/admin/mailu/internal/templates/default.sieve +++ b/core/admin/mailu/internal/templates/default.sieve @@ -19,7 +19,7 @@ if header :index 2 :matches "Received" "from * by * for <*>; *" } {% if user.spam_enabled %} -if spamtest :percent :value "gt" :comparator "i;ascii-numeric" "{{ user.spam_threshold }}" +if spamtest :percent :value "gt" :comparator "i;ascii-numeric" "{{ user.spam_threshold }}" { setflag "\\seen"; fileinto :create "Junk"; @@ -32,6 +32,6 @@ if exists "X-Virus" { stop; } -{% if user.reply_active %} +{% if user.reply_active %} vacation :days 1 {% if user.displayed_name != "" %}:from "{{ user.displayed_name }} <{{ user.email }}>"{% endif %} :subject "{{ user.reply_subject }}" "{{ user.reply_body }}"; {% endif %} diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index c5cd9e28..344be78b 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -11,6 +11,13 @@ def nginx_authentication(): """ Main authentication endpoint for Nginx email server """ 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): status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit') response = flask.Response() @@ -41,7 +48,7 @@ def nginx_authentication(): elif is_valid_user: utils.limiter.rate_limit_user(username, client_ip) else: - rate_limit_ip(client_ip) + utils.limiter.rate_limit_ip(client_ip) return response @internal.route("/auth/admin") diff --git a/core/admin/mailu/manage.py b/core/admin/mailu/manage.py index 54f4b826..937c9f49 100644 --- a/core/admin/mailu/manage.py +++ b/core/admin/mailu/manage.py @@ -119,7 +119,7 @@ def password(localpart, domain_name, password): """ Change the password of an user """ email = f'{localpart}@{domain_name}' - user = models.User.query.get(email) + user = models.User.query.get(email) if user: user.set_password(password) else: diff --git a/core/admin/mailu/sso/__init__.py b/core/admin/mailu/sso/__init__.py new file mode 100644 index 00000000..98b5abd0 --- /dev/null +++ b/core/admin/mailu/sso/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +sso = Blueprint('sso', __name__, static_folder=None, template_folder='templates') + +from mailu.sso.views import * diff --git a/core/admin/mailu/sso/forms.py b/core/admin/mailu/sso/forms.py new file mode 100644 index 00000000..c190b8bc --- /dev/null +++ b/core/admin/mailu/sso/forms.py @@ -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')) diff --git a/core/admin/mailu/sso/templates/base_sso.html b/core/admin/mailu/sso/templates/base_sso.html new file mode 100644 index 00000000..9dfb25a5 --- /dev/null +++ b/core/admin/mailu/sso/templates/base_sso.html @@ -0,0 +1,86 @@ +{%- import "macros.html" as macros %} +{%- import "bootstrap/utils.html" as utils %} + + + + + + + + Mailu-Admin | {{ config["SITENAME"] }} + + + + +
+ + +
+
+
+
+
+

{%- block title %}{%- endblock %}

+ {% block subtitle %}{% endblock %} +
+
+ {%- block main_action %}{%- endblock %} +
+
+
+
+
+ {{ utils.flashed_messages(container=False, default_category='success') }} + {%- block content %}{%- endblock %} +
+
+ +
+ + + + diff --git a/core/admin/mailu/sso/templates/form_sso.html b/core/admin/mailu/sso/templates/form_sso.html new file mode 100644 index 00000000..b14e7600 --- /dev/null +++ b/core/admin/mailu/sso/templates/form_sso.html @@ -0,0 +1,11 @@ +{%- extends "base_sso.html" %} + +{%- block content %} +{%- call macros.card() %} +
+ {{ macros.form_field(form.email) }} + {{ macros.form_field(form.pw) }} + {{ macros.form_fields(fields, label=False, class="btn btn-default", spacing=False) }} +
+{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/sso/templates/login.html b/core/admin/mailu/sso/templates/login.html new file mode 100644 index 00000000..c727e01e --- /dev/null +++ b/core/admin/mailu/sso/templates/login.html @@ -0,0 +1,5 @@ +{%- extends "form_sso.html" %} + +{%- block title %} +{% trans %}Sign in{% endtrans %} +{%- endblock %} diff --git a/core/admin/mailu/sso/templates/sidebar_sso.html b/core/admin/mailu/sso/templates/sidebar_sso.html new file mode 100644 index 00000000..86db3333 --- /dev/null +++ b/core/admin/mailu/sso/templates/sidebar_sso.html @@ -0,0 +1,55 @@ + diff --git a/core/admin/mailu/sso/views/__init__.py b/core/admin/mailu/sso/views/__init__.py new file mode 100644 index 00000000..7b1830fb --- /dev/null +++ b/core/admin/mailu/sso/views/__init__.py @@ -0,0 +1,3 @@ +__all__ = [ + 'base', 'languages' +] diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py new file mode 100644 index 00000000..fbee52a7 --- /dev/null +++ b/core/admin/mailu/sso/views/base.py @@ -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')) + diff --git a/core/admin/mailu/sso/views/languages.py b/core/admin/mailu/sso/views/languages.py new file mode 100644 index 00000000..66c09b1f --- /dev/null +++ b/core/admin/mailu/sso/views/languages.py @@ -0,0 +1,7 @@ +from mailu.sso import sso +import flask + +@sso.route('/language/', methods=['POST']) +def set_language(language=None): + flask.session['language'] = language + return flask.Response(status=200) diff --git a/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po b/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po index 664ff571..09130a7b 100644 --- a/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po @@ -551,11 +551,11 @@ msgid "" "cache\n" " expires." msgstr "" -"Jeśli nie wiesz, jak skonfigurować rekord MX dla swojej " +"Jeśli nie wiesz, jak skonfigurować rekord MX dla swojej " "strefy DNS,\n" "skontaktuj się z dostawcą DNS lub administratorem. Proszę również " "poczekać\n" -"kilka minut po ustawieniu MX , żeby pamięć podręczna " +"kilka minut po ustawieniu MX , żeby pamięć podręczna " "serwera lokalnego wygasła." #: mailu/ui/templates/fetch/create.html:4 diff --git a/core/admin/mailu/ui/__init__.py b/core/admin/mailu/ui/__init__.py index ec3601a1..49338cd1 100644 --- a/core/admin/mailu/ui/__init__.py +++ b/core/admin/mailu/ui/__init__.py @@ -1,6 +1,6 @@ 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 * diff --git a/core/admin/mailu/ui/forms.py b/core/admin/mailu/ui/forms.py index 60be1699..24d6f899 100644 --- a/core/admin/mailu/ui/forms.py +++ b/core/admin/mailu/ui/forms.py @@ -44,15 +44,6 @@ class MultipleEmailAddressesVerify(object): class ConfirmationForm(flask_wtf.FlaskForm): 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): name = fields.StringField(_('Domain name'), [validators.DataRequired()]) max_users = fields_.IntegerField(_('Maximum user count'), [validators.NumberRange(min=-1)], default=10) diff --git a/core/admin/mailu/ui/templates/base.html b/core/admin/mailu/ui/templates/base.html index fc27d12b..e646e579 100644 --- a/core/admin/mailu/ui/templates/base.html +++ b/core/admin/mailu/ui/templates/base.html @@ -1,15 +1,15 @@ {%- import "macros.html" as macros %} {%- import "bootstrap/utils.html" as utils %} - + Mailu-Admin | {{ config["SITENAME"] }} - - + +
@@ -38,7 +38,7 @@ {{ session['language'] }} @@ -46,7 +46,7 @@
- - + + diff --git a/core/admin/mailu/ui/templates/login.html b/core/admin/mailu/ui/templates/login.html deleted file mode 100644 index d4d115db..00000000 --- a/core/admin/mailu/ui/templates/login.html +++ /dev/null @@ -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"] %} - -{% endif %} -{{ super() }} -{%- endblock %} diff --git a/core/admin/mailu/ui/templates/macros.html b/core/admin/mailu/ui/templates/macros.html index 4080c1e4..5143b697 100644 --- a/core/admin/mailu/ui/templates/macros.html +++ b/core/admin/mailu/ui/templates/macros.html @@ -18,8 +18,12 @@ {%- endif %} {%- 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 %} + {%- else %} + {%- set width = 0 %} + {% endif %}
{%- for field in fields %} @@ -54,7 +58,7 @@
{{ form.hidden_tag() }} {%- for field in form %} - {%- if bootstrap_is_hidden_field(field) %} + {%- if bootstrap_is_hidden_field(field) %} {{ field() }} {%- else %} {{ form_field(field) }} diff --git a/core/admin/mailu/ui/templates/sidebar.html b/core/admin/mailu/ui/templates/sidebar.html index 1a597a5f..6145bef3 100644 --- a/core/admin/mailu/ui/templates/sidebar.html +++ b/core/admin/mailu/ui/templates/sidebar.html @@ -92,7 +92,7 @@ {%- endif %} - {%- if config["WEBMAIL"] != "none" %} + {%- if config["WEBMAIL"] != "none" and current_user.is_authenticated %} - {%- else %} + {% else %}