mirror of
https://github.com/Mailu/Mailu.git
synced 2025-03-03 14:52:36 +02:00
Merge #2500
2500: Password policy enforcement r=mergify[bot] a=nextgens ## What type of PR? Feature ## What does this PR do? It enforces that all new passwords set by users are at least 8 characters in length and checks all users' passwords at login time against HIBP. The HIBP part requires javascript and Mailu to be accessed over HTTPS to work but degrades gracefully (no message will be shown if the requirements are not met). It was a conscious choice to implement it at this level: administrators can set weaker passwords using non-HTTP based interfaces. ### Related issue(s) - close #2208 - close #287 ## 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. - [ ] 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: Florent Daigniere <nextgens@freenetproject.org> Co-authored-by: Alexander Graf <ghostwheel42@users.noreply.github.com>
This commit is contained in:
commit
896e7fb54b
@ -3,6 +3,56 @@ require('./app.css');
|
|||||||
import logo from './mailu.png';
|
import logo from './mailu.png';
|
||||||
import modules from "./*.json";
|
import modules from "./*.json";
|
||||||
|
|
||||||
|
// Inspired from https://github.com/mehdibo/hibp-js/blob/master/hibp.js
|
||||||
|
function sha1(string) {
|
||||||
|
var buffer = new TextEncoder("utf-8").encode(string);
|
||||||
|
return crypto.subtle.digest("SHA-1", buffer).then(function (buffer) {
|
||||||
|
// Get the hex code
|
||||||
|
var hexCodes = [];
|
||||||
|
var view = new DataView(buffer);
|
||||||
|
for (var i = 0; i < view.byteLength; i += 4) {
|
||||||
|
// Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
|
||||||
|
var value = view.getUint32(i);
|
||||||
|
// toString(16) will give the hex representation of the number without padding
|
||||||
|
var stringValue = value.toString(16);
|
||||||
|
// We use concatenation and slice for padding
|
||||||
|
var padding = '00000000';
|
||||||
|
var paddedValue = (padding + stringValue).slice(-padding.length);
|
||||||
|
hexCodes.push(paddedValue);
|
||||||
|
}
|
||||||
|
// Join all the hex strings into one
|
||||||
|
return hexCodes.join("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hibpCheck(pwd) {
|
||||||
|
// We hash the pwd first
|
||||||
|
sha1(pwd).then(function(hash){
|
||||||
|
// We send the first 5 chars of the hash to hibp's API
|
||||||
|
const req = new XMLHttpRequest();
|
||||||
|
req.open('GET', 'https://api.pwnedpasswords.com/range/'+hash.substr(0, 5));
|
||||||
|
req.setRequestHeader('Add-Padding', 'true');
|
||||||
|
req.addEventListener("load", function(){
|
||||||
|
// When we get back a response from the server
|
||||||
|
// We create an array of lines and loop through them
|
||||||
|
const lines = this.responseText.split("\n");
|
||||||
|
const hashSub = hash.slice(5).toUpperCase();
|
||||||
|
for (var i in lines){
|
||||||
|
// Check if the line matches the rest of the hash
|
||||||
|
if (lines[i].substring(0, 35) == hashSub){
|
||||||
|
const val = parseInt(lines[i].trimEnd("\r").split(":")[1]);
|
||||||
|
if (val > 0) {
|
||||||
|
$("#pwned").val(val);
|
||||||
|
}
|
||||||
|
return; // If found no need to continue the loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("#pwned").val(0);
|
||||||
|
});
|
||||||
|
req.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: conditionally (or lazy) load select2 and dataTable
|
// TODO: conditionally (or lazy) load select2 and dataTable
|
||||||
$('document').ready(function() {
|
$('document').ready(function() {
|
||||||
|
|
||||||
@ -75,5 +125,19 @@ $('document').ready(function() {
|
|||||||
$('form :input').prop('disabled', true);
|
$('form :input').prop('disabled', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.isSecureContext) {
|
||||||
|
$("#pw").on("change paste", function(){
|
||||||
|
hibpCheck($(this).val());
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
$("#pw").closest("form").submit(function(event){
|
||||||
|
if (parseInt($("#pwned").val()) < 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
hibpCheck($("#pw").val());
|
||||||
|
event.trigger();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -7,5 +7,6 @@ class LoginForm(flask_wtf.FlaskForm):
|
|||||||
csrf = False
|
csrf = False
|
||||||
email = fields.StringField(_('E-mail'), [validators.Email(), validators.DataRequired()])
|
email = fields.StringField(_('E-mail'), [validators.Email(), validators.DataRequired()])
|
||||||
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
||||||
|
pwned = fields.HiddenField(label='', default=-1)
|
||||||
submitWebmail = fields.SubmitField(_('Sign in'))
|
submitWebmail = fields.SubmitField(_('Sign in'))
|
||||||
submitAdmin = fields.SubmitField(_('Sign in'))
|
submitAdmin = fields.SubmitField(_('Sign in'))
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
{%- block content %}
|
{%- block content %}
|
||||||
{%- call macros.card() %}
|
{%- call macros.card() %}
|
||||||
<form class="form" method="post" role="form">
|
<form class="form" method="post" role="form">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
{{ macros.form_field(form.email) }}
|
{{ macros.form_field(form.email) }}
|
||||||
{{ macros.form_field(form.pw) }}
|
{{ macros.form_field(form.pw) }}
|
||||||
{{ macros.form_fields(fields, label=False, class="btn btn-default") }}
|
{{ macros.form_fields(fields, label=False, class="btn btn-default") }}
|
||||||
|
@ -40,7 +40,9 @@ def login():
|
|||||||
flask_login.login_user(user)
|
flask_login.login_user(user)
|
||||||
response = flask.redirect(destination)
|
response = flask.redirect(destination)
|
||||||
response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True)
|
response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True)
|
||||||
flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.')
|
flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip} pwned={form.pwned.data}.')
|
||||||
|
if msg := utils.isBadOrPwned(form):
|
||||||
|
flask.flash(msg, "error")
|
||||||
return response
|
return response
|
||||||
else:
|
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)
|
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)
|
||||||
|
@ -59,6 +59,7 @@ class DomainSignupForm(flask_wtf.FlaskForm):
|
|||||||
localpart = fields.StringField(_('Initial admin'), [validators.DataRequired()])
|
localpart = fields.StringField(_('Initial admin'), [validators.DataRequired()])
|
||||||
pw = fields.PasswordField(_('Admin password'), [validators.DataRequired()])
|
pw = fields.PasswordField(_('Admin password'), [validators.DataRequired()])
|
||||||
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
||||||
|
pwned = fields.HiddenField(label='', default=-1)
|
||||||
captcha = flask_wtf.RecaptchaField()
|
captcha = flask_wtf.RecaptchaField()
|
||||||
submit = fields.SubmitField(_('Create'))
|
submit = fields.SubmitField(_('Create'))
|
||||||
|
|
||||||
@ -79,6 +80,7 @@ class UserForm(flask_wtf.FlaskForm):
|
|||||||
localpart = fields.StringField(_('E-mail'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
|
localpart = fields.StringField(_('E-mail'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
|
||||||
pw = fields.PasswordField(_('Password'))
|
pw = fields.PasswordField(_('Password'))
|
||||||
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
||||||
|
pwned = fields.HiddenField(label='', default=-1)
|
||||||
quota_bytes = fields_.IntegerSliderField(_('Quota'), default=10**9)
|
quota_bytes = fields_.IntegerSliderField(_('Quota'), default=10**9)
|
||||||
enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True)
|
enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True)
|
||||||
enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True)
|
enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True)
|
||||||
@ -92,6 +94,7 @@ class UserSignupForm(flask_wtf.FlaskForm):
|
|||||||
localpart = fields.StringField(_('Email address'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
|
localpart = fields.StringField(_('Email address'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
|
||||||
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
||||||
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
||||||
|
pwned = fields.HiddenField(label='', default=-1)
|
||||||
submit = fields.SubmitField(_('Sign up'))
|
submit = fields.SubmitField(_('Sign up'))
|
||||||
|
|
||||||
class UserSignupFormCaptcha(UserSignupForm):
|
class UserSignupFormCaptcha(UserSignupForm):
|
||||||
@ -111,6 +114,7 @@ class UserSettingsForm(flask_wtf.FlaskForm):
|
|||||||
class UserPasswordForm(flask_wtf.FlaskForm):
|
class UserPasswordForm(flask_wtf.FlaskForm):
|
||||||
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
||||||
pw2 = fields.PasswordField(_('Password check'), [validators.DataRequired()])
|
pw2 = fields.PasswordField(_('Password check'), [validators.DataRequired()])
|
||||||
|
pwned = fields.HiddenField(label='', default=-1)
|
||||||
submit = fields.SubmitField(_('Update password'))
|
submit = fields.SubmitField(_('Update password'))
|
||||||
|
|
||||||
|
|
||||||
|
@ -60,9 +60,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 not bootstrap_is_hidden_field(field) %}
|
||||||
{{ field() }}
|
|
||||||
{%- else %}
|
|
||||||
{{ form_field(field) }}
|
{{ form_field(field) }}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from mailu import models
|
from mailu import models, utils
|
||||||
from mailu.ui import ui, forms, access
|
from mailu.ui import ui, forms, access
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
|
|
||||||
@ -93,6 +93,9 @@ def domain_signup(domain_name=None):
|
|||||||
del form.pw
|
del form.pw
|
||||||
del form.pw2
|
del form.pw2
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
|
if msg := utils.isBadOrPwned(form):
|
||||||
|
flask.flash(msg, "error")
|
||||||
|
return flask.render_template('domain/signup.html', form=form)
|
||||||
conflicting_domain = models.Domain.query.get(form.name.data)
|
conflicting_domain = models.Domain.query.get(form.name.data)
|
||||||
conflicting_alternative = models.Alternative.query.get(form.name.data)
|
conflicting_alternative = models.Alternative.query.get(form.name.data)
|
||||||
conflicting_relay = models.Relay.query.get(form.name.data)
|
conflicting_relay = models.Relay.query.get(form.name.data)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from mailu import models
|
from mailu import models, utils
|
||||||
from mailu.ui import ui, access, forms
|
from mailu.ui import ui, access, forms
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
|
|
||||||
@ -28,6 +28,10 @@ def user_create(domain_name):
|
|||||||
form.quota_bytes.validators = [
|
form.quota_bytes.validators = [
|
||||||
wtforms.validators.NumberRange(max=domain.max_quota_bytes)]
|
wtforms.validators.NumberRange(max=domain.max_quota_bytes)]
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
|
if msg := utils.isBadOrPwned(form):
|
||||||
|
flask.flash(msg, "error")
|
||||||
|
return flask.render_template('user/create.html',
|
||||||
|
domain=domain, form=form)
|
||||||
if domain.has_email(form.localpart.data):
|
if domain.has_email(form.localpart.data):
|
||||||
flask.flash('Email is already used', 'error')
|
flask.flash('Email is already used', 'error')
|
||||||
else:
|
else:
|
||||||
@ -60,6 +64,10 @@ def user_edit(user_email):
|
|||||||
form.quota_bytes.validators = [
|
form.quota_bytes.validators = [
|
||||||
wtforms.validators.NumberRange(max=max_quota_bytes)]
|
wtforms.validators.NumberRange(max=max_quota_bytes)]
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
|
if msg := utils.isBadOrPwned(form):
|
||||||
|
flask.flash(msg, "error")
|
||||||
|
return flask.render_template('user/edit.html', form=form, user=user,
|
||||||
|
domain=user.domain, max_quota_bytes=max_quota_bytes)
|
||||||
form.populate_obj(user)
|
form.populate_obj(user)
|
||||||
if form.pw.data:
|
if form.pw.data:
|
||||||
user.set_password(form.pw.data)
|
user.set_password(form.pw.data)
|
||||||
@ -119,6 +127,9 @@ def user_password(user_email):
|
|||||||
if form.pw.data != form.pw2.data:
|
if form.pw.data != form.pw2.data:
|
||||||
flask.flash('Passwords do not match', 'error')
|
flask.flash('Passwords do not match', 'error')
|
||||||
else:
|
else:
|
||||||
|
if msg := utils.isBadOrPwned(form):
|
||||||
|
flask.flash(msg, "error")
|
||||||
|
return flask.render_template('user/password.html', form=form, user=user)
|
||||||
flask.session.regenerate()
|
flask.session.regenerate()
|
||||||
user.set_password(form.pw.data)
|
user.set_password(form.pw.data)
|
||||||
models.db.session.commit()
|
models.db.session.commit()
|
||||||
@ -170,6 +181,9 @@ def user_signup(domain_name=None):
|
|||||||
if domain.has_email(form.localpart.data) or models.Alias.resolve(form.localpart.data, domain_name):
|
if domain.has_email(form.localpart.data) or models.Alias.resolve(form.localpart.data, domain_name):
|
||||||
flask.flash('Email is already used', 'error')
|
flask.flash('Email is already used', 'error')
|
||||||
else:
|
else:
|
||||||
|
if msg := utils.isBadOrPwned(form):
|
||||||
|
flask.flash(msg, "error")
|
||||||
|
return flask.render_template('user/signup.html', domain=domain, form=form)
|
||||||
flask.session.regenerate()
|
flask.session.regenerate()
|
||||||
user = models.User(domain=domain)
|
user = models.User(domain=domain)
|
||||||
form.populate_obj(user)
|
form.populate_obj(user)
|
||||||
|
@ -507,3 +507,14 @@ def gen_temp_token(email, session):
|
|||||||
app.config['PERMANENT_SESSION_LIFETIME'],
|
app.config['PERMANENT_SESSION_LIFETIME'],
|
||||||
)
|
)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
def isBadOrPwned(form):
|
||||||
|
try:
|
||||||
|
if len(form.pw.data) < 8:
|
||||||
|
return "This password is too short."
|
||||||
|
breaches = int(form.pwned.data)
|
||||||
|
except ValueError:
|
||||||
|
breaches = -1
|
||||||
|
if breaches > 0:
|
||||||
|
return f"This password appears in {breaches} data breaches! It is not unique; please change it."
|
||||||
|
return None
|
||||||
|
1
towncrier/newsfragments/2500.feature
Normal file
1
towncrier/newsfragments/2500.feature
Normal file
@ -0,0 +1 @@
|
|||||||
|
Implement a minimum length for passwords of 8 characters. Check passwords upon login against HaveIBeenPwned and warn users if their passwords are compromised.
|
Loading…
x
Reference in New Issue
Block a user