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>
main
bors[bot] 2 years ago committed by GitHub
commit 896e7fb54b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -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…
Cancel
Save