From afbaabd8cdd82f041a4a6bc77e397dfe684c0128 Mon Sep 17 00:00:00 2001 From: Florent Daigniere Date: Mon, 31 Oct 2022 19:41:40 +0100 Subject: [PATCH] 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)