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 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
$('document').ready(function() {
@ -75,5 +125,19 @@ $('document').ready(function() {
$('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
email = fields.StringField(_('E-mail'), [validators.Email(), validators.DataRequired()])
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
pwned = fields.HiddenField(label='', default=-1)
submitWebmail = fields.SubmitField(_('Sign in'))
submitAdmin = fields.SubmitField(_('Sign in'))

@ -3,6 +3,7 @@
{%- block content %}
{%- call macros.card() %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.email) }}
{{ macros.form_field(form.pw) }}
{{ macros.form_fields(fields, label=False, class="btn btn-default") }}

@ -40,7 +40,9 @@ def login():
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'), 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
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)

@ -59,6 +59,7 @@ class DomainSignupForm(flask_wtf.FlaskForm):
localpart = fields.StringField(_('Initial admin'), [validators.DataRequired()])
pw = fields.PasswordField(_('Admin password'), [validators.DataRequired()])
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
pwned = fields.HiddenField(label='', default=-1)
captcha = flask_wtf.RecaptchaField()
submit = fields.SubmitField(_('Create'))
@ -79,6 +80,7 @@ class UserForm(flask_wtf.FlaskForm):
localpart = fields.StringField(_('E-mail'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
pw = fields.PasswordField(_('Password'))
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
pwned = fields.HiddenField(label='', default=-1)
quota_bytes = fields_.IntegerSliderField(_('Quota'), default=10**9)
enable_imap = fields.BooleanField(_('Allow IMAP 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)])
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
pwned = fields.HiddenField(label='', default=-1)
submit = fields.SubmitField(_('Sign up'))
class UserSignupFormCaptcha(UserSignupForm):
@ -111,6 +114,7 @@ class UserSettingsForm(flask_wtf.FlaskForm):
class UserPasswordForm(flask_wtf.FlaskForm):
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
pw2 = fields.PasswordField(_('Password check'), [validators.DataRequired()])
pwned = fields.HiddenField(label='', default=-1)
submit = fields.SubmitField(_('Update password'))

@ -60,9 +60,7 @@
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{%- for field in form %}
{%- if bootstrap_is_hidden_field(field) %}
{{ field() }}
{%- else %}
{%- if not bootstrap_is_hidden_field(field) %}
{{ form_field(field) }}
{%- endif %}
{%- endfor %}

@ -1,4 +1,4 @@
from mailu import models
from mailu import models, utils
from mailu.ui import ui, forms, access
from flask import current_app as app
@ -93,6 +93,9 @@ def domain_signup(domain_name=None):
del form.pw
del form.pw2
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_alternative = models.Alternative.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 flask import current_app as app
@ -28,6 +28,10 @@ def user_create(domain_name):
form.quota_bytes.validators = [
wtforms.validators.NumberRange(max=domain.max_quota_bytes)]
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):
flask.flash('Email is already used', 'error')
else:
@ -60,6 +64,10 @@ def user_edit(user_email):
form.quota_bytes.validators = [
wtforms.validators.NumberRange(max=max_quota_bytes)]
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)
if 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:
flask.flash('Passwords do not match', 'error')
else:
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return flask.render_template('user/password.html', form=form, user=user)
flask.session.regenerate()
user.set_password(form.pw.data)
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):
flask.flash('Email is already used', 'error')
else:
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return flask.render_template('user/signup.html', domain=domain, form=form)
flask.session.regenerate()
user = models.User(domain=domain)
form.populate_obj(user)

@ -507,3 +507,14 @@ def gen_temp_token(email, session):
app.config['PERMANENT_SESSION_LIFETIME'],
)
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