2023: Fix sso 1929 r=mergify[bot] a=Diman0

## What type of PR?

Enhancement

## What does this PR do?

- Introduces a separate login page that uses the same styling as the admin page.
- Shows login target of login page (Now this is either Admin or Webmail)
- Allows the user to choose the login target.
- Introduces a new stub /static which is used for retrieving all static files by all web apps (/admin and /sso).

### Related issue(s)
-  closes #1929

## Prerequisites
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/workflow.html#changelog) entry file.


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

@ -38,7 +38,7 @@ RUN set -eu \
&& pip3 install -r requirements.txt \ && pip3 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
@ -51,4 +51,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

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

@ -19,7 +19,7 @@ if header :index 2 :matches "Received" "from * by * for <*>; *"
} }
{% if user.spam_enabled %} {% 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"; setflag "\\seen";
fileinto :create "Junk"; fileinto :create "Junk";
@ -32,6 +32,6 @@ if exists "X-Virus" {
stop; 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 }}"; vacation :days 1 {% if user.displayed_name != "" %}:from "{{ user.displayed_name }} <{{ user.email }}>"{% endif %} :subject "{{ user.reply_subject }}" "{{ user.reply_body }}";
{% endif %} {% endif %}

@ -41,7 +41,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")

@ -119,7 +119,7 @@ def password(localpart, domain_name, password):
""" Change the password of an user """ Change the password of an user
""" """
email = f'{localpart}@{domain_name}' email = f'{localpart}@{domain_name}'
user = models.User.query.get(email) user = models.User.query.get(email)
if user: if user:
user.set_password(password) user.set_password(password)
else: else:

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

@ -551,11 +551,11 @@ msgid ""
"cache\n" "cache\n"
" expires." " expires."
msgstr "" msgstr ""
"Jeśli nie wiesz, jak skonfigurować rekord <code> MX </code> dla swojej " "Jeśli nie wiesz, jak skonfigurować rekord <code> MX </code> dla swojej "
"strefy DNS,\n" "strefy DNS,\n"
"skontaktuj się z dostawcą DNS lub administratorem. Proszę również " "skontaktuj się z dostawcą DNS lub administratorem. Proszę również "
"poczekać\n" "poczekać\n"
"kilka minut po ustawieniu <code> MX </code> , żeby pamięć podręczna " "kilka minut po ustawieniu <code> MX </code>, żeby pamięć podręczna "
"serwera lokalnego wygasła." "serwera lokalnego wygasła."
#: mailu/ui/templates/fetch/create.html:4 #: mailu/ui/templates/fetch/create.html:4

@ -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 %}
@ -54,7 +58,7 @@
<form class="form" method="post" role="form"> <form class="form" method="post" role="form">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{%- for field in form %} {%- for field in form %}
{%- if bootstrap_is_hidden_field(field) %} {%- if bootstrap_is_hidden_field(field) %}
{{ field() }} {{ field() }}
{%- else %} {%- else %}
{{ form_field(field) }} {{ form_field(field) }}

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

@ -32,13 +32,13 @@ from werkzeug.contrib import fixers
# 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
@ -103,9 +103,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):

@ -37,7 +37,7 @@ def run_migrations_offline():
This configures the context with just a URL This configures the context with just a URL
and not an Engine, though an Engine is acceptable and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation here as well. By skipping the Engine creation
we don't even need a DBAPI to be available. we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the Calls to context.execute() here emit the given string to the

@ -6,7 +6,7 @@ import multiprocessing
import logging as log import logging as log
import sys import sys
from podop import run_server from podop import run_server
from socrate import system, conf from socrate import system, conf
log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING")) log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING"))

@ -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,4 +1,4 @@
# Basic configuration # Basic configuration
user nginx; user nginx;
worker_processes auto; worker_processes auto;
error_log /dev/stderr info; error_log /dev/stderr info;
@ -6,7 +6,7 @@ pid /var/run/nginx.pid;
load_module "modules/ngx_mail_module.so"; load_module "modules/ngx_mail_module.so";
events { events {
worker_connections 1024; worker_connections 1024;
} }
http { http {
@ -15,7 +15,7 @@ http {
default_type application/octet-stream; default_type application/octet-stream;
access_log /dev/stdout; access_log /dev/stdout;
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
server_tokens off; server_tokens off;
absolute_redirect off; absolute_redirect off;
resolver {{ RESOLVER }} ipv6=off valid=30s; resolver {{ RESOLVER }} ipv6=off valid=30s;
@ -80,7 +80,7 @@ http {
{% endif %} {% endif %}
# Listen on HTTP only in kubernetes or behind reverse proxy # Listen on HTTP only in kubernetes or behind reverse proxy
{% if KUBERNETES_INGRESS == 'true' or TLS_FLAVOR in [ 'mail-letsencrypt', 'notls', 'mail' ] %} {% if KUBERNETES_INGRESS == 'true' or TLS_FLAVOR in [ 'mail-letsencrypt', 'notls', 'mail' ] %}
listen 80; listen 80;
listen [::]:80; listen [::]:80;
{% endif %} {% endif %}
@ -128,6 +128,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 +154,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,28 +171,20 @@ 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; include /etc/nginx/proxy.conf;
} proxy_pass http://$admin;
expires $expires;
location ~ {{ WEB_ADMIN }}/(ui|static) { }
rewrite ^{{ WEB_ADMIN }}/(.*) /$1 break;
include /etc/nginx/proxy.conf;
proxy_set_header X-Forwarded-Prefix {{ WEB_ADMIN }};
proxy_pass http://$admin;
expires $expires;
}
location {{ WEB_ADMIN }}/antispam { location {{ WEB_ADMIN }}/antispam {
rewrite ^{{ WEB_ADMIN }}/antispam/(.*) /$1 break; rewrite ^{{ WEB_ADMIN }}/antispam/(.*) /$1 break;

@ -7,7 +7,7 @@ import multiprocessing
import logging as log import logging as log
import sys import sys
from podop import run_server from podop import run_server
from pwd import getpwnam from pwd import getpwnam
from socrate import system, conf from socrate import system, conf

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

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

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