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>master
commit
3ccb6ff4b5
@ -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
|
||||
|
||||
|
||||
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 *
|
||||
|
@ -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 %}
|
@ -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.
|
||||
|
Loading…
Reference in New Issue