mirror of
https://github.com/Mailu/Mailu.git
synced 2024-12-12 10:45:38 +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 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
|
||||
|
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…
Reference in New Issue
Block a user