diff --git a/core/admin/assets/assets/app.js b/core/admin/assets/assets/app.js index 03ea6215..cac55971 100644 --- a/core/admin/assets/assets/app.js +++ b/core/admin/assets/assets/app.js @@ -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(); + } + }); + } + }); diff --git a/core/admin/mailu/sso/forms.py b/core/admin/mailu/sso/forms.py index 5cf38dbe..ca124c02 100644 --- a/core/admin/mailu/sso/forms.py +++ b/core/admin/mailu/sso/forms.py @@ -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')) diff --git a/core/admin/mailu/sso/templates/form_sso.html b/core/admin/mailu/sso/templates/form_sso.html index d2451597..d713251e 100644 --- a/core/admin/mailu/sso/templates/form_sso.html +++ b/core/admin/mailu/sso/templates/form_sso.html @@ -3,6 +3,7 @@ {%- block content %} {%- call macros.card() %}
+ {{ form.hidden_tag() }} {{ macros.form_field(form.email) }} {{ macros.form_field(form.pw) }} {{ macros.form_fields(fields, label=False, class="btn btn-default") }} diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index 00b6912d..6fa9403f 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -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) diff --git a/core/admin/mailu/ui/forms.py b/core/admin/mailu/ui/forms.py index 683db7ae..59097c71 100644 --- a/core/admin/mailu/ui/forms.py +++ b/core/admin/mailu/ui/forms.py @@ -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')) diff --git a/core/admin/mailu/ui/templates/macros.html b/core/admin/mailu/ui/templates/macros.html index 0da069a9..46a76991 100644 --- a/core/admin/mailu/ui/templates/macros.html +++ b/core/admin/mailu/ui/templates/macros.html @@ -60,9 +60,7 @@ {{ 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 %} diff --git a/core/admin/mailu/ui/views/domains.py b/core/admin/mailu/ui/views/domains.py index 4010e2ae..4cdd830a 100644 --- a/core/admin/mailu/ui/views/domains.py +++ b/core/admin/mailu/ui/views/domains.py @@ -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) diff --git a/core/admin/mailu/ui/views/users.py b/core/admin/mailu/ui/views/users.py index c4b26036..85a5c2db 100644 --- a/core/admin/mailu/ui/views/users.py +++ b/core/admin/mailu/ui/views/users.py @@ -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) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index 7e208702..f160fe3f 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -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 diff --git a/towncrier/newsfragments/2500.feature b/towncrier/newsfragments/2500.feature new file mode 100644 index 00000000..3c37934e --- /dev/null +++ b/towncrier/newsfragments/2500.feature @@ -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.