From 0896a6b16324f5d8a42e30787886ca8ac43cbda6 Mon Sep 17 00:00:00 2001 From: Darks Date: Sat, 25 Jul 2020 18:06:49 +0200 Subject: [PATCH] passwords: enhances passwords rules - based on entropy (min 60 bits) - adds a coloured progress bar if Js is enabled --- app/forms/account.py | 2 +- app/models/users.py | 10 ++-- app/routes/account/account.py | 9 ++-- app/routes/admin/account.py | 7 +-- app/static/css/form.css | 25 ++++++++++ app/static/scripts/entropy.js | 42 +++++++++++++++++ app/templates/account/account.html | 1 + app/templates/account/register.html | 1 + app/templates/account/reset_password.html | 1 + app/templates/admin/edit_account.html | 1 + app/utils/render.py | 2 - app/utils/validators.py | 56 ++++++++++------------- config.py | 5 -- 13 files changed, 112 insertions(+), 50 deletions(-) create mode 100644 app/static/scripts/entropy.js diff --git a/app/forms/account.py b/app/forms/account.py index 936c760..227fafc 100644 --- a/app/forms/account.py +++ b/app/forms/account.py @@ -72,7 +72,7 @@ class UpdateAccountForm(FlaskForm): ], ) password = PasswordField( - 'Mot de passe', + 'Nouveau mot de passe', validators=[ Optional(), vd.password, diff --git a/app/models/users.py b/app/models/users.py index 807f847..e01567e 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -35,6 +35,10 @@ class User(UserMixin, db.Model): # Other fields populated automatically through relations: # relationship populated from the Post class. + # Minimum and maximum user name length + NAME_MINLEN = 3 + NAME_MAXLEN = 32 + __mapper_args__ = { 'polymorphic_identity': __tablename__, 'polymorphic_on': type @@ -54,7 +58,7 @@ class Guest(User): id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) # Reusable username, cannot be chosen as the name of a member # but will be distinguished at rendering time if a member take it later - name = db.Column(db.Unicode(64)) + name = db.Column(db.Unicode(User.NAME_MAXLEN)) def __init__(self, name): self.name = name @@ -73,8 +77,8 @@ class Member(User): id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) # Primary attributes (needed for the system to work) - name = db.Column(db.Unicode(V5Config.USER_NAME_MAXLEN), index=True) - norm = db.Column(db.Unicode(V5Config.USER_NAME_MAXLEN), index=True, + name = db.Column(db.Unicode(User.NAME_MAXLEN), index=True) + norm = db.Column(db.Unicode(User.NAME_MAXLEN), index=True, unique=True) email = db.Column(db.Unicode(120), index=True, unique=True) email_confirmed = db.Column(db.Boolean) diff --git a/app/routes/account/account.py b/app/routes/account/account.py index 015a422..b248161 100644 --- a/app/routes/account/account.py +++ b/app/routes/account/account.py @@ -35,7 +35,8 @@ def edit_account(): else: flash('Erreur lors de la modification', 'error') - return render('account/account.html', form=form) + return render('account/account.html', scripts=["+scripts/entropy.js"], + form=form) @app.route('/compte/reinitialiser', methods=['GET', 'POST']) @guest_only @@ -74,7 +75,8 @@ def reset_password(token): else: flash('Erreur lors de la modification', 'error') - return render('account/reset_password.html', form=form) + return render('account/reset_password.html', + scripts=["+scripts/entropy.js"], form=form) @app.route('/compte/supprimer', methods=['GET', 'POST']) @@ -113,7 +115,8 @@ def register(): send_validation_mail(member.name, member.email) return redirect(url_for('validation') + "?email=" + form.email.data) - return render('account/register.html', title='Register', form=form) + return render('account/register.html', title='Register', + scripts=["+scripts/entropy.js"], form=form) @app.route('/inscription/validation', methods=['GET']) diff --git a/app/routes/admin/account.py b/app/routes/admin/account.py index f8ddc5a..3859d6a 100644 --- a/app/routes/admin/account.py +++ b/app/routes/admin/account.py @@ -107,9 +107,10 @@ def adm_edit_account(user_id): for g in user.groups: groups_owned.add(f"g{g.id}") - return render('admin/edit_account.html', user=user, form=form, - trophy_form=trophy_form, trophies_owned=trophies_owned, - group_form=group_form, groups_owned=groups_owned) + return render('admin/edit_account.html', scripts=["+scripts/entropy.js"], + user=user, form=form, trophy_form=trophy_form, + trophies_owned=trophies_owned, group_form=group_form, + groups_owned=groups_owned) @app.route('/admin/compte//supprimer', methods=['GET', 'POST']) diff --git a/app/static/css/form.css b/app/static/css/form.css index 262f9b4..6fa683f 100644 --- a/app/static/css/form.css +++ b/app/static/css/form.css @@ -50,6 +50,31 @@ resize: vertical; } +.form progress.entropy { + display: none; /* display with Js enabled */ + width: 100%; margin-top: 5px; + background: var(--background); + border: var(--border); +} +.form progress.entropy.low::-moz-progress-bar { + background: var(--error); +} +.form progress.entropy.low::-webkit-progress-bar { + background: var(--error); +} +.form progress.entropy.medium::-moz-progress-bar { + background: var(--warn); +} +.form progress.entropy.medium::-webkit-progress-bar { + background: var(--warn); +} +.form progress.entropy.high::-moz-progress-bar { + background: var(--ok); +} +.form progress.entropy.high::-webkit-progress-bar { + background: var(--ok); +} + .form input[type="checkbox"], .form input[type="radio"] { display: inline; diff --git a/app/static/scripts/entropy.js b/app/static/scripts/entropy.js new file mode 100644 index 0000000..3465557 --- /dev/null +++ b/app/static/scripts/entropy.js @@ -0,0 +1,42 @@ +function entropy(password) { + var chars = [ + "abcdefghijklmnopqrstuvwxyz", + "ABCDFEGHIJKLMNOPQRSTUVWXYZ", + "0123456789", + " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~§", // OWASP special chars + "áàâéèêíìîóòôúùûç" + ]; + + used = new Set(); + for(c in password) { + for(k in chars) { + if(chars[k].includes(password[c])) { + used.add(chars[k]); + } + } + } + return Math.log(Math.pow(Array.from(used).join("").length, password.length)) / Math.log(2); +} + +function update_entropy(ev) { + var i = document.querySelector(".entropy").previousElementSibling; + var p = document.querySelector(".entropy"); + var e = entropy(i.value); + + p.classList.remove('low'); + p.classList.remove('medium'); + p.classList.remove('high'); + + if(e < 60) { + p.classList.add('low'); + } else if(e < 100) { + p.classList.add('medium'); + } else { + p.classList.add('high'); + } + + p.value = e; +} + +document.querySelector(".entropy").previousElementSibling.addEventListener('input', update_entropy); +document.querySelector(".entropy").style.display = "block"; diff --git a/app/templates/account/account.html b/app/templates/account/account.html index 0f92345..3b3bc80 100644 --- a/app/templates/account/account.html +++ b/app/templates/account/account.html @@ -30,6 +30,7 @@
{{ form.password.label }} {{ form.password(placeholder='************') }} + {% for error in form.password.errors %} {{ error }} {% endfor %} diff --git a/app/templates/account/register.html b/app/templates/account/register.html index 5d1ca31..6efe74f 100644 --- a/app/templates/account/register.html +++ b/app/templates/account/register.html @@ -24,6 +24,7 @@
{{ form.password.label }} {{ form.password() }} + {% for error in form.password.errors %} {{ error }} {% endfor %} diff --git a/app/templates/account/reset_password.html b/app/templates/account/reset_password.html index a9a94d2..c095437 100644 --- a/app/templates/account/reset_password.html +++ b/app/templates/account/reset_password.html @@ -10,6 +10,7 @@ {{ form.password.label }}
{{ form.password.description }}
{{ form.password() }} + {% for error in form.password.errors %} {{ error }} {% endfor %} diff --git a/app/templates/admin/edit_account.html b/app/templates/admin/edit_account.html index af3f4d0..2f67edf 100644 --- a/app/templates/admin/edit_account.html +++ b/app/templates/admin/edit_account.html @@ -45,6 +45,7 @@ {{ form.password.label }}
{{ form.password.description }}
{{ form.password(placeholder='************') }} + {% for error in form.password.errors %} {{ error }} {% endfor %} diff --git a/app/utils/render.py b/app/utils/render.py index db0f2b1..f2f9f75 100644 --- a/app/utils/render.py +++ b/app/utils/render.py @@ -36,14 +36,12 @@ def render(*args, styles=[], scripts=[], **kwargs): ] for s in styles: - print(s[1:]) if s[0] == '-': styles_.remove(s[1:]) if s[0] == '+': styles_.append(s[1:]) for s in scripts: - print(s[1:]) if s[0] == '-': scripts_.remove(s[1:]) if s[0] == '+': diff --git a/app/utils/validators.py b/app/utils/validators.py index 8820b91..f805537 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -1,9 +1,10 @@ from flask_login import current_user from wtforms.validators import ValidationError from PIL import Image -from app.models.users import Member +from app.models.users import Member, User from app.utils.valid_name import valid_name from app.utils.unicode_names import normalize +from math import log import app.utils.ldap as ldap from config import V5Config @@ -14,10 +15,10 @@ def name_valid(form, name): msg = { "too-short": "Le nom d'utilisateur doit faire au moins " - f"{V5Config.USER_NAME_MINLEN} caractères.", + f"{User.USER_NAME_MINLEN} caractères.", "too-long": "Le nom d'utilisateur doit faire au plus " - f"{V5Config.USER_NAME_MAXLEN} caractères.", + f"{User.USER_NAME_MAXLEN} caractères.", "cant-normalize": "Ce nom d'utilisateur contient des caractères interdits. Les " "caractères autorisés sont les lettres, lettres accentuées, " @@ -63,37 +64,26 @@ def password(form, password): if len(password.data) == 0: return - errors = [] - if len(password.data) < V5Config.PASSWORD_MINLEN: - errors.append('Le mot de passe doit faire au moins ' - f'{V5Config.PASSWORD_MINLEN} caractères.') + def entropy(password): + """Estimate entropy of a password, in bits""" + # If you edit this function, please edit accordingly the JS one + # in static/script/entropy.js + chars = [ + "abcdefghijklmnopqrstuvwxyz", + "ABCDFEGHIJKLMNOPQRSTUVWXYZ", + "0123456789", + " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~§", # OWASP special chars + "áàâéèêíìîóòôúùûç", + ] + used = set() + for c in password: + for i in chars: + if c in i: + used.add(i) + return log(len(''.join(used)) ** len(password), 2) - checks = set() - for c in password.data: - if c in "abcdefghijklmnopqrstuvwxyz": - checks.add('lower') - elif c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": - checks.add('upper') - elif c in "0123456789": - checks.add('numeric') - else: - checks.add('other') - - missing = [] - if 'lower' not in checks: - missing.append('une minuscule') - if 'upper' not in checks: - missing.append('une majuscule') - if 'numeric' not in checks: - missing.append('un chiffre') - if 'other' not in checks: - missing.append('un caractère spécial') - - if missing != []: - errors.append('Le mot de passe doit aussi contenir ' + ', '.join(missing) + '.') - - if errors != []: - raise ValidationError(' '.join(errors)) + if entropy(password.data) < 60: + raise ValidationError("Mot de passe pas assez complexe") def avatar(form, avatar): diff --git a/config.py b/config.py index f0c0e54..490ee76 100644 --- a/config.py +++ b/config.py @@ -30,11 +30,6 @@ class DefaultConfig(object): FORBIDDEN_USERNAMES = ["admin", "root", "webmaster", "contact"] # Unauthorized message (@priv_required) UNAUTHORIZED_MSG = "Vous n'avez pas l'autorisation d'effectuer cette action !" - # Minimum and maximum user name length - USER_NAME_MINLEN = 3 - USER_NAME_MAXLEN = 32 - # Minimum password length for new users and new passwords - PASSWORD_MINLEN = 10 # Maximum thread name length THREAD_NAME_MAXLEN = 32 # Amount of comments per thread page