mirror of
https://github.com/Mailu/Mailu.git
synced 2024-12-14 10:53:30 +02:00
Merge #2023
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>
This commit is contained in:
commit
3ccb6ff4b5
@ -38,7 +38,7 @@ RUN set -eu \
|
||||
&& pip3 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
|
||||
@ -51,4 +51,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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -58,6 +58,7 @@ DEFAULT_CONFIG = {
|
||||
# Web settings
|
||||
'SITENAME': 'Mailu',
|
||||
'WEBSITE': 'https://mailu.io',
|
||||
'ADMIN' : 'none',
|
||||
'WEB_ADMIN': '/admin',
|
||||
'WEB_WEBMAIL': '/webmail',
|
||||
'WEBMAIL': 'none',
|
||||
|
@ -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 %}
|
||||
|
@ -41,7 +41,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")
|
||||
|
@ -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:
|
||||
|
5
core/admin/mailu/sso/__init__.py
Normal file
5
core/admin/mailu/sso/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
sso = Blueprint('sso', __name__, static_folder=None, template_folder='templates')
|
||||
|
||||
from mailu.sso.views import *
|
11
core/admin/mailu/sso/forms.py
Normal file
11
core/admin/mailu/sso/forms.py
Normal file
@ -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'))
|
86
core/admin/mailu/sso/templates/base_sso.html
Normal file
86
core/admin/mailu/sso/templates/base_sso.html
Normal file
@ -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>
|
11
core/admin/mailu/sso/templates/form_sso.html
Normal file
11
core/admin/mailu/sso/templates/form_sso.html
Normal file
@ -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 %}
|
5
core/admin/mailu/sso/templates/login.html
Normal file
5
core/admin/mailu/sso/templates/login.html
Normal file
@ -0,0 +1,5 @@
|
||||
{%- extends "form_sso.html" %}
|
||||
|
||||
{%- block title %}
|
||||
{% trans %}Sign in{% endtrans %}
|
||||
{%- endblock %}
|
55
core/admin/mailu/sso/templates/sidebar_sso.html
Normal file
55
core/admin/mailu/sso/templates/sidebar_sso.html
Normal file
@ -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>
|
3
core/admin/mailu/sso/views/__init__.py
Normal file
3
core/admin/mailu/sso/views/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
__all__ = [
|
||||
'base', 'languages'
|
||||
]
|
56
core/admin/mailu/sso/views/base.py
Normal file
56
core/admin/mailu/sso/views/base.py
Normal file
@ -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'))
|
||||
|
7
core/admin/mailu/sso/views/languages.py
Normal file
7
core/admin/mailu/sso/views/languages.py
Normal file
@ -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"
|
||||
" expires."
|
||||
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"
|
||||
"skontaktuj się z dostawcą DNS lub administratorem. Proszę również "
|
||||
"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."
|
||||
|
||||
#: mailu/ui/templates/fetch/create.html:4
|
||||
|
@ -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 *
|
||||
|
@ -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)
|
||||
|
@ -1,15 +1,15 @@
|
||||
{%- import "macros.html" as macros %}
|
||||
{%- import "bootstrap/utils.html" as utils %}
|
||||
<!doctype html>
|
||||
<html lang="{{ session['language'] }}" data-static="{{ url_for('.static', filename='') }}">
|
||||
<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') }}">
|
||||
<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">
|
||||
@ -38,7 +38,7 @@
|
||||
<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.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 %}
|
||||
</div>
|
||||
</li>
|
||||
@ -46,7 +46,7 @@
|
||||
</nav>
|
||||
<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 %}>
|
||||
<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>
|
||||
</a>
|
||||
{%- include "sidebar.html" %}
|
||||
@ -80,7 +80,7 @@
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="{{ url_for('.static', filename='vendor.js') }}"></script>
|
||||
<script src="{{ url_for('.static', filename='app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</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 %}
|
||||
{%- 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 %}
|
||||
<div class="form-group">
|
||||
<div class="row">
|
||||
{%- for field in fields %}
|
||||
@ -54,7 +58,7 @@
|
||||
<form class="form" method="post" role="form">
|
||||
{{ form.hidden_tag() }}
|
||||
{%- for field in form %}
|
||||
{%- if bootstrap_is_hidden_field(field) %}
|
||||
{%- if bootstrap_is_hidden_field(field) %}
|
||||
{{ field() }}
|
||||
{%- else %}
|
||||
{{ form_field(field) }}
|
||||
|
@ -92,7 +92,7 @@
|
||||
{%- endif %}
|
||||
|
||||
<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">
|
||||
<a href="{{ config["WEB_WEBMAIL"] }}" target="_blank" class="nav-link" role="menuitem">
|
||||
<i class="nav-icon far fa-envelope"></i>
|
||||
@ -130,14 +130,14 @@
|
||||
{%- endif %}
|
||||
{%- if current_user.is_authenticated %}
|
||||
<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>
|
||||
<p>{% trans %}Sign out{% endtrans %}</p>
|
||||
</a>
|
||||
</li>
|
||||
{%- else %}
|
||||
{% else %}
|
||||
<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>
|
||||
<p>{% trans %}Sign in{% endtrans %}</p>
|
||||
</a>
|
||||
|
@ -11,45 +11,6 @@ import flask_login
|
||||
def index():
|
||||
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'])
|
||||
@access.global_admin
|
||||
def announcement():
|
||||
@ -72,7 +33,6 @@ def webmail():
|
||||
def client():
|
||||
return flask.render_template('client.html')
|
||||
|
||||
@ui.route('/antispam', methods=['GET'])
|
||||
@ui.route('/webui_antispam', methods=['GET'])
|
||||
def antispam():
|
||||
return flask.render_template('antispam.html')
|
||||
|
||||
|
@ -32,13 +32,13 @@ from werkzeug.contrib import fixers
|
||||
|
||||
# Login configuration
|
||||
login = flask_login.LoginManager()
|
||||
login.login_view = "ui.login"
|
||||
login.login_view = "sso.login"
|
||||
|
||||
@login.unauthorized_handler
|
||||
def handle_needs_login():
|
||||
""" redirect unauthorized requests to login page """
|
||||
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
|
||||
@ -103,9 +103,6 @@ class PrefixMiddleware(object):
|
||||
self.app = None
|
||||
|
||||
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)
|
||||
|
||||
def init_app(self, app):
|
||||
|
@ -37,7 +37,7 @@ def run_migrations_offline():
|
||||
|
||||
This configures the context with just a URL
|
||||
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.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
|
@ -4,7 +4,7 @@ Mailu Dovecot container
|
||||
Dovecot is an open source IMAP and POP3 email server for Linux/UNIX-like
|
||||
systems, written with security primarily in mind. It's fast, simple to set
|
||||
up, requires no special administration and it uses very little memory.
|
||||
|
||||
|
||||
In the Mailu stack it is used as the IMAP/POP frontend service.
|
||||
|
||||
Resources
|
||||
|
@ -6,7 +6,7 @@ import multiprocessing
|
||||
import logging as log
|
||||
import sys
|
||||
|
||||
from podop import run_server
|
||||
from podop import run_server
|
||||
from socrate import system, conf
|
||||
|
||||
log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING"))
|
||||
|
@ -16,7 +16,7 @@ COPY conf /conf
|
||||
COPY static /static
|
||||
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
|
||||
VOLUME ["/certs"]
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Basic configuration
|
||||
# Basic configuration
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /dev/stderr info;
|
||||
@ -6,7 +6,7 @@ pid /var/run/nginx.pid;
|
||||
load_module "modules/ngx_mail_module.so";
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
@ -15,7 +15,7 @@ http {
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
keepalive_timeout 65;
|
||||
server_tokens off;
|
||||
absolute_redirect off;
|
||||
resolver {{ RESOLVER }} ipv6=off valid=30s;
|
||||
@ -47,12 +47,12 @@ http {
|
||||
|
||||
{% if KUBERNETES_INGRESS != 'true' and TLS_FLAVOR in [ 'letsencrypt', 'cert' ] %}
|
||||
# Enable the proxy for certbot if the flavor is letsencrypt and not on kubernetes
|
||||
#
|
||||
#
|
||||
server {
|
||||
# Listen over HTTP
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
{% if TLS_FLAVOR == 'letsencrypt' %}
|
||||
{% if TLS_FLAVOR == 'letsencrypt' %}
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
}
|
||||
@ -80,7 +80,7 @@ http {
|
||||
{% endif %}
|
||||
|
||||
# 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;
|
||||
{% endif %}
|
||||
@ -113,7 +113,7 @@ http {
|
||||
add_header X-XSS-Protection '1; mode=block';
|
||||
add_header Referrer-Policy 'same-origin';
|
||||
|
||||
{% if TLS_FLAVOR == 'mail-letsencrypt' %}
|
||||
{% if TLS_FLAVOR == 'mail-letsencrypt' %}
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
}
|
||||
@ -128,6 +128,13 @@ http {
|
||||
include /overrides/*.conf;
|
||||
|
||||
# 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' %}
|
||||
location / {
|
||||
expires $expires;
|
||||
@ -147,10 +154,9 @@ http {
|
||||
{% endif %}
|
||||
include /etc/nginx/proxy.conf;
|
||||
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
|
||||
proxy_pass http://$webmail;
|
||||
{% if ADMIN == 'true' %}
|
||||
auth_request /internal/auth/user;
|
||||
error_page 403 @webmail_login;
|
||||
proxy_pass http://$webmail;
|
||||
}
|
||||
|
||||
location {{ WEB_WEBMAIL }}/sso.php {
|
||||
@ -165,28 +171,20 @@ http {
|
||||
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;
|
||||
proxy_pass http://$webmail;
|
||||
}
|
||||
|
||||
location @webmail_login {
|
||||
return 302 {{ WEB_ADMIN }}/ui/login?next=ui.webmail;
|
||||
return 302 /sso/login;
|
||||
}
|
||||
{% else %}
|
||||
}
|
||||
{% endif %}{% endif %}
|
||||
{% endif %}
|
||||
{% if ADMIN == 'true' %}
|
||||
location {{ WEB_ADMIN }} {
|
||||
return 301 {{ WEB_ADMIN }}/ui;
|
||||
}
|
||||
|
||||
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 }} {
|
||||
include /etc/nginx/proxy.conf;
|
||||
proxy_pass http://$admin;
|
||||
expires $expires;
|
||||
}
|
||||
|
||||
location {{ WEB_ADMIN }}/antispam {
|
||||
rewrite ^{{ WEB_ADMIN }}/antispam/(.*) /$1 break;
|
||||
|
@ -7,7 +7,7 @@ import multiprocessing
|
||||
import logging as log
|
||||
import sys
|
||||
|
||||
from podop import run_server
|
||||
from podop import run_server
|
||||
from pwd import getpwnam
|
||||
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
|
||||
|
||||
server {
|
||||
# [...] here goes your standard configuration
|
||||
|
||||
location /webmail {
|
||||
proxy_pass https://localhost:8443/webmail;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
proxy_pass https://localhost:8443/admin;
|
||||
location ~ ^/(admin|sso|static|webdav|webmail)/ {
|
||||
proxy_pass https://localhost:8443;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
|
10
towncrier/newsfragments/1929.enhancement
Normal file
10
towncrier/newsfragments/1929.enhancement
Normal file
@ -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]
|
||||
allow_gravatar = Off
|
||||
{% if ADMIN == "true" %}
|
||||
custom_login_link='sso.php'
|
||||
custom_logout_link='{{ WEB_ADMIN }}/ui/logout'
|
||||
{% endif %}
|
||||
custom_logout_link='/sso/logout'
|
||||
|
||||
[contacts]
|
||||
enable = On
|
||||
|
@ -37,11 +37,11 @@ $config['managesieve_usetls'] = false;
|
||||
|
||||
// Customization settings
|
||||
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['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : '';
|
||||
}
|
||||
$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
|
||||
// will obviously fail but this sounds better than allowing insecure login
|
||||
|
Loading…
Reference in New Issue
Block a user