From afbaabd8cdd82f041a4a6bc77e397dfe684c0128 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 31 Oct 2022 19:41:40 +0100 Subject: [PATCH 01/13] v1 --- core/admin/assets/content/assets/app.js | 57 +++++++++++++++++++++++++ core/admin/mailu/sso/forms.py | 1 + core/admin/mailu/sso/views/base.py | 5 ++- core/admin/mailu/ui/forms.py | 4 ++ core/admin/mailu/ui/views/domains.py | 4 ++ core/admin/mailu/ui/views/users.py | 18 ++++++++ 6 files changed, 88 insertions(+), 1 deletion(-) diff --git a/core/admin/assets/content/assets/app.js b/core/admin/assets/content/assets/app.js index 03ea6215..b0d48470 100644 --- a/core/admin/assets/content/assets/app.js +++ b/core/admin/assets/content/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.addEventListener("load", function(){ + // When we get back a response from the server + // We create an array of lines and loop through them + const resp = this.responseText.split('\n'); + const hashSub = hash.slice(5).toUpperCase(); + for(index in resp){ + // Check if the line matches the rest of the hash + if(resp[index].substring(0, 35) == hashSub){ + var val = resp[index].split(":")[1] + if (val > 0) { + $("#pwned").value = val; + } + return; // If found no need to continue the loop + } + } + $("#pwned").value = 0; + }); + req.open('GET', 'https://api.pwnedpasswords.com/range/'+hash.substr(0, 5)); + req.setRequestHeader('Add-Padding', 'true'); + req.send(); + }); +} + // TODO: conditionally (or lazy) load select2 and dataTable $('document').ready(function() { @@ -75,5 +125,12 @@ $('document').ready(function() { $('form :input').prop('disabled', true); } + if (window.isSecureContext) { + $("#HIBPpw").change(function(){ + hibpCheck($("#HIBPpw").value); + return true; + }) + } + }); diff --git a/core/admin/mailu/sso/forms.py b/core/admin/mailu/sso/forms.py index 5cf38dbe..530fc1c2 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/views/base.py b/core/admin/mailu/sso/views/base.py index 00b6912d..34ba98b5 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -40,7 +40,10 @@ 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}.') + breaches = int(form.pwned.data) + if breaches > 0: + flask.flash(f"Your password appears in {breaches} data breaches! Please change it.", "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..67e4b674 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/views/domains.py b/core/admin/mailu/ui/views/domains.py index 4010e2ae..4b237ca0 100644 --- a/core/admin/mailu/ui/views/domains.py +++ b/core/admin/mailu/ui/views/domains.py @@ -93,6 +93,10 @@ def domain_signup(domain_name=None): del form.pw del form.pw2 if form.validate_on_submit(): + breaches = int(form.pwned.data) + if breaches > 0: + flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "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..2bd664b5 100644 --- a/core/admin/mailu/ui/views/users.py +++ b/core/admin/mailu/ui/views/users.py @@ -28,6 +28,11 @@ def user_create(domain_name): form.quota_bytes.validators = [ wtforms.validators.NumberRange(max=domain.max_quota_bytes)] if form.validate_on_submit(): + breaches = int(form.pwned.data) + if breaches > 0: + flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "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 +65,11 @@ def user_edit(user_email): form.quota_bytes.validators = [ wtforms.validators.NumberRange(max=max_quota_bytes)] if form.validate_on_submit(): + breaches = int(form.pwned.data) + if breaches > 0: + flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "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 +129,10 @@ def user_password(user_email): if form.pw.data != form.pw2.data: flask.flash('Passwords do not match', 'error') else: + breaches = int(form.pwned.data) + if breaches > 0: + flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "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 +184,10 @@ 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: + breaches = int(form.pwned.data) + if breaches > 0: + flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") + return flask.render_template('user/signup.html', domain=domain, form=form) flask.session.regenerate() user = models.User(domain=domain) form.populate_obj(user) From 9cb8df57c683c7fb01e2adda8a23028559944e00 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 31 Oct 2022 19:48:13 +0100 Subject: [PATCH 02/13] enforce at least 8 chars --- core/admin/mailu/ui/views/domains.py | 3 +++ core/admin/mailu/ui/views/users.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/core/admin/mailu/ui/views/domains.py b/core/admin/mailu/ui/views/domains.py index 4b237ca0..b39657f1 100644 --- a/core/admin/mailu/ui/views/domains.py +++ b/core/admin/mailu/ui/views/domains.py @@ -93,6 +93,9 @@ def domain_signup(domain_name=None): del form.pw del form.pw2 if form.validate_on_submit(): + if not flask_login.current_user.is_authenticated and len(form.pw.data) < 8: + flask.flash("This password is too short.", "error") + return flask.render_template('domain/signup.html', form=form) breaches = int(form.pwned.data) if breaches > 0: flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") diff --git a/core/admin/mailu/ui/views/users.py b/core/admin/mailu/ui/views/users.py index 2bd664b5..3fe96109 100644 --- a/core/admin/mailu/ui/views/users.py +++ b/core/admin/mailu/ui/views/users.py @@ -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 len(form.pw.data) < 8: + flask.flash("This password is too short.", "error") + return flask.render_template('user/create.html', + domain=domain, form=form) breaches = int(form.pwned.data) if breaches > 0: flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") @@ -65,6 +69,10 @@ def user_edit(user_email): form.quota_bytes.validators = [ wtforms.validators.NumberRange(max=max_quota_bytes)] if form.validate_on_submit(): + if len(form.pw.data) < 8: + flask.flash("This password is too short.", "error") + return flask.render_template('user/edit.html', form=form, user=user, + domain=user.domain, max_quota_bytes=max_quota_bytes) breaches = int(form.pwned.data) if breaches > 0: flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") @@ -129,6 +137,9 @@ def user_password(user_email): if form.pw.data != form.pw2.data: flask.flash('Passwords do not match', 'error') else: + if len(form.pw.data) < 8: + flask.flash("This password is too short.", "error") + return flask.render_template('user/password.html', form=form, user=user) breaches = int(form.pwned.data) if breaches > 0: flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") @@ -184,6 +195,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 len(form.pw.data) < 8: + flask.flash("This password is too short.", "error") + return flask.render_template('user/signup.html', domain=domain, form=form) breaches = int(form.pwned.data) if breaches > 0: flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") From 5d314c49aea869bd54324b464d6162e2478d7ade Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 31 Oct 2022 19:50:08 +0100 Subject: [PATCH 03/13] towncrier --- towncrier/newsfragments/2500.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 towncrier/newsfragments/2500.feature 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. From 66250e396c4d81bb3f1ffe9121bf292d1cb6ba63 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 3 Nov 2022 16:19:44 +0100 Subject: [PATCH 04/13] refactor --- core/admin/mailu/ui/views/domains.py | 10 +++----- core/admin/mailu/ui/views/users.py | 36 +++++++--------------------- core/admin/mailu/utils.py | 11 +++++++++ 3 files changed, 23 insertions(+), 34 deletions(-) diff --git a/core/admin/mailu/ui/views/domains.py b/core/admin/mailu/ui/views/domains.py index b39657f1..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,12 +93,8 @@ def domain_signup(domain_name=None): del form.pw del form.pw2 if form.validate_on_submit(): - if not flask_login.current_user.is_authenticated and len(form.pw.data) < 8: - flask.flash("This password is too short.", "error") - return flask.render_template('domain/signup.html', form=form) - breaches = int(form.pwned.data) - if breaches > 0: - flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") + 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) diff --git a/core/admin/mailu/ui/views/users.py b/core/admin/mailu/ui/views/users.py index 3fe96109..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,13 +28,8 @@ def user_create(domain_name): form.quota_bytes.validators = [ wtforms.validators.NumberRange(max=domain.max_quota_bytes)] if form.validate_on_submit(): - if len(form.pw.data) < 8: - flask.flash("This password is too short.", "error") - return flask.render_template('user/create.html', - domain=domain, form=form) - breaches = int(form.pwned.data) - if breaches > 0: - flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") + 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): @@ -69,13 +64,8 @@ def user_edit(user_email): form.quota_bytes.validators = [ wtforms.validators.NumberRange(max=max_quota_bytes)] if form.validate_on_submit(): - if len(form.pw.data) < 8: - flask.flash("This password is too short.", "error") - return flask.render_template('user/edit.html', form=form, user=user, - domain=user.domain, max_quota_bytes=max_quota_bytes) - breaches = int(form.pwned.data) - if breaches > 0: - flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") + 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) @@ -137,12 +127,8 @@ def user_password(user_email): if form.pw.data != form.pw2.data: flask.flash('Passwords do not match', 'error') else: - if len(form.pw.data) < 8: - flask.flash("This password is too short.", "error") - return flask.render_template('user/password.html', form=form, user=user) - breaches = int(form.pwned.data) - if breaches > 0: - flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") + 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) @@ -195,12 +181,8 @@ 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 len(form.pw.data) < 8: - flask.flash("This password is too short.", "error") - return flask.render_template('user/signup.html', domain=domain, form=form) - breaches = int(form.pwned.data) - if breaches > 0: - flask.flash(f"This password appears in {breaches} data breaches! Please change it.", "error") + 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) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index 7e208702..4c55e27d 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 From 24b2c7c04aee19c8e06fb454aa2507aad2ac2619 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 3 Nov 2022 16:25:10 +0100 Subject: [PATCH 05/13] doh --- core/admin/assets/content/assets/app.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/admin/assets/content/assets/app.js b/core/admin/assets/content/assets/app.js index b0d48470..96d6deb7 100644 --- a/core/admin/assets/content/assets/app.js +++ b/core/admin/assets/content/assets/app.js @@ -38,7 +38,7 @@ function hibpCheck(pwd){ for(index in resp){ // Check if the line matches the rest of the hash if(resp[index].substring(0, 35) == hashSub){ - var val = resp[index].split(":")[1] + const val = resp[index].split(":")[1] if (val > 0) { $("#pwned").value = val; } @@ -126,8 +126,8 @@ $('document').ready(function() { } if (window.isSecureContext) { - $("#HIBPpw").change(function(){ - hibpCheck($("#HIBPpw").value); + $("#pw").change(function(){ + hibpCheck($("#pw").value); return true; }) } From 6b7026ef696c9560006c2775862b1ab7949cf522 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 3 Nov 2022 16:28:07 +0100 Subject: [PATCH 06/13] Here too --- core/admin/mailu/sso/views/base.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index 34ba98b5..6fa9403f 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -41,9 +41,8 @@ def login(): 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} pwned={form.pwned.data}.') - breaches = int(form.pwned.data) - if breaches > 0: - flask.flash(f"Your password appears in {breaches} data breaches! Please change it.", "error") + 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) From 001acd60ac9e2aed1b9b36ba05b374e0130c43a2 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 3 Nov 2022 16:44:18 +0100 Subject: [PATCH 07/13] doh2 --- core/admin/mailu/utils.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index 4c55e27d..f160fe3f 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -509,12 +509,12 @@ def gen_temp_token(email, session): 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." + 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 From 14f802fb4a314c63db19ba9d4f2eca8a24ae13d5 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 3 Nov 2022 18:38:55 +0100 Subject: [PATCH 08/13] untested but that should work --- core/admin/assets/content/assets/app.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/admin/assets/content/assets/app.js b/core/admin/assets/content/assets/app.js index 96d6deb7..81f1802a 100644 --- a/core/admin/assets/content/assets/app.js +++ b/core/admin/assets/content/assets/app.js @@ -129,7 +129,13 @@ $('document').ready(function() { $("#pw").change(function(){ hibpCheck($("#pw").value); return true; - }) + }); + $("#pw").closest("form").submit(function(event){ + if($("#pwned").value > -1) {return;}; + event.preventDefault(); + hibpCheck($("#pw").value) + event.trigger(); + }); } }); From 54e9858633bfdc210ef68131daf5c2379b35facf Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Thu, 3 Nov 2022 18:42:19 +0100 Subject: [PATCH 09/13] this --- core/admin/assets/content/assets/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/admin/assets/content/assets/app.js b/core/admin/assets/content/assets/app.js index 81f1802a..7c13ff24 100644 --- a/core/admin/assets/content/assets/app.js +++ b/core/admin/assets/content/assets/app.js @@ -133,7 +133,7 @@ $('document').ready(function() { $("#pw").closest("form").submit(function(event){ if($("#pwned").value > -1) {return;}; event.preventDefault(); - hibpCheck($("#pw").value) + hibpCheck($("#pw").value); event.trigger(); }); } From 27a5f9db6525ce4780bd6e2cf8e758ceb2f9ae38 Mon Sep 17 00:00:00 2001 From: Alexander Graf Date: Fri, 4 Nov 2022 13:35:13 +0100 Subject: [PATCH 10/13] Reformatting --- core/admin/mailu/sso/forms.py | 2 +- core/admin/mailu/ui/forms.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/admin/mailu/sso/forms.py b/core/admin/mailu/sso/forms.py index 530fc1c2..ca124c02 100644 --- a/core/admin/mailu/sso/forms.py +++ b/core/admin/mailu/sso/forms.py @@ -7,6 +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) + pwned = fields.HiddenField(label='', default=-1) submitWebmail = fields.SubmitField(_('Sign in')) submitAdmin = fields.SubmitField(_('Sign in')) diff --git a/core/admin/mailu/ui/forms.py b/core/admin/mailu/ui/forms.py index 67e4b674..59097c71 100644 --- a/core/admin/mailu/ui/forms.py +++ b/core/admin/mailu/ui/forms.py @@ -59,7 +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) + pwned = fields.HiddenField(label='', default=-1) captcha = flask_wtf.RecaptchaField() submit = fields.SubmitField(_('Create')) @@ -80,7 +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) + 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) @@ -94,7 +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) + pwned = fields.HiddenField(label='', default=-1) submit = fields.SubmitField(_('Sign up')) class UserSignupFormCaptcha(UserSignupForm): @@ -114,7 +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) + pwned = fields.HiddenField(label='', default=-1) submit = fields.SubmitField(_('Update password')) From 311f41c331e12668e57fed59ca283e15d363c9fb Mon Sep 17 00:00:00 2001 From: Alexander Graf Date: Fri, 4 Nov 2022 13:35:38 +0100 Subject: [PATCH 11/13] Add missing hidden fields --- core/admin/mailu/sso/templates/form_sso.html | 1 + 1 file changed, 1 insertion(+) 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") }} From ea636a183546074c19c714d3c905b3810eb27b07 Mon Sep 17 00:00:00 2001 From: Alexander Graf Date: Fri, 4 Nov 2022 15:13:56 +0100 Subject: [PATCH 12/13] Fix hibp test --- core/admin/assets/content/assets/app.js | 43 +++++++++++++------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/core/admin/assets/content/assets/app.js b/core/admin/assets/content/assets/app.js index 7c13ff24..cac55971 100644 --- a/core/admin/assets/content/assets/app.js +++ b/core/admin/assets/content/assets/app.js @@ -4,7 +4,7 @@ import logo from './mailu.png'; import modules from "./*.json"; // Inspired from https://github.com/mehdibo/hibp-js/blob/master/hibp.js -function sha1(string){ +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 @@ -12,12 +12,12 @@ function sha1(string){ 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) + var value = view.getUint32(i); // toString(16) will give the hex representation of the number without padding - var stringValue = value.toString(16) + var stringValue = value.toString(16); // We use concatenation and slice for padding - var padding = '00000000' - var paddedValue = (padding + stringValue).slice(-padding.length) + var padding = '00000000'; + var paddedValue = (padding + stringValue).slice(-padding.length); hexCodes.push(paddedValue); } // Join all the hex strings into one @@ -25,30 +25,30 @@ function sha1(string){ }); } -function hibpCheck(pwd){ +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 resp = this.responseText.split('\n'); + const lines = this.responseText.split("\n"); const hashSub = hash.slice(5).toUpperCase(); - for(index in resp){ + for (var i in lines){ // Check if the line matches the rest of the hash - if(resp[index].substring(0, 35) == hashSub){ - const val = resp[index].split(":")[1] + if (lines[i].substring(0, 35) == hashSub){ + const val = parseInt(lines[i].trimEnd("\r").split(":")[1]); if (val > 0) { - $("#pwned").value = val; - } + $("#pwned").val(val); + } return; // If found no need to continue the loop } } - $("#pwned").value = 0; + $("#pwned").val(0); }); - req.open('GET', 'https://api.pwnedpasswords.com/range/'+hash.substr(0, 5)); - req.setRequestHeader('Add-Padding', 'true'); req.send(); }); } @@ -126,15 +126,16 @@ $('document').ready(function() { } if (window.isSecureContext) { - $("#pw").change(function(){ - hibpCheck($("#pw").value); + $("#pw").on("change paste", function(){ + hibpCheck($(this).val()); return true; }); $("#pw").closest("form").submit(function(event){ - if($("#pwned").value > -1) {return;}; - event.preventDefault(); - hibpCheck($("#pw").value); - event.trigger(); + if (parseInt($("#pwned").val()) < 0) { + event.preventDefault(); + hibpCheck($("#pw").val()); + event.trigger(); + } }); } From defd5333198ff5d52daef022852fdad3d1961451 Mon Sep 17 00:00:00 2001 From: Alexander Graf Date: Mon, 7 Nov 2022 16:16:09 +0100 Subject: [PATCH 13/13] Don't duplicate hidden fields --- core/admin/mailu/ui/templates/macros.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 %}