Merge pull request #1457 from nkukard/nkupdates-password-policy

Implement password strength & complexity checking
This commit is contained in:
Matt Scott 2023-03-17 15:35:10 -04:00 committed by GitHub
commit 4a6d31cfa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 289 additions and 24 deletions

View File

@ -205,6 +205,14 @@ class Setting(db.Model):
'deny_domain_override': False, 'deny_domain_override': False,
'account_name_extra_chars': False, 'account_name_extra_chars': False,
'gravatar_enabled': False, 'gravatar_enabled': False,
'pwd_enforce_characters': False,
'pwd_min_len': 10,
'pwd_min_lowercase': 3,
'pwd_min_uppercase': 2,
'pwd_min_digits': 2,
'pwd_min_special': 1,
'pwd_enforce_complexity': False,
'pwd_min_complexity': 11
} }
def __init__(self, id=None, name=None, value=None): def __init__(self, id=None, name=None, value=None):

View File

@ -1557,7 +1557,23 @@ def setting_authentication():
local_db_enabled = True if request.form.get( local_db_enabled = True if request.form.get(
'local_db_enabled') else False 'local_db_enabled') else False
signup_enabled = True if request.form.get( signup_enabled = True if request.form.get(
'signup_enabled', ) else False 'signup_enabled') else False
pwd_enforce_characters = True if request.form.get('pwd_enforce_characters') else False
pwd_min_len = safe_cast(request.form.get('pwd_min_len', Setting().defaults["pwd_min_len"]), int,
Setting().defaults["pwd_min_len"])
pwd_min_lowercase = safe_cast(request.form.get('pwd_min_lowercase', Setting().defaults["pwd_min_lowercase"]), int,
Setting().defaults["pwd_min_lowercase"])
pwd_min_uppercase = safe_cast(request.form.get('pwd_min_uppercase', Setting().defaults["pwd_min_uppercase"]), int,
Setting().defaults["pwd_min_uppercase"])
pwd_min_digits = safe_cast(request.form.get('pwd_min_digits', Setting().defaults["pwd_min_digits"]), int,
Setting().defaults["pwd_min_digits"])
pwd_min_special = safe_cast(request.form.get('pwd_min_special', Setting().defaults["pwd_min_special"]), int,
Setting().defaults["pwd_min_special"])
pwd_enforce_complexity = True if request.form.get('pwd_enforce_complexity') else False
pwd_min_complexity = safe_cast(request.form.get('pwd_min_complexity', Setting().defaults["pwd_min_complexity"]), int,
Setting().defaults["pwd_min_complexity"])
if not has_an_auth_method(local_db_enabled=local_db_enabled): if not has_an_auth_method(local_db_enabled=local_db_enabled):
result = { result = {
@ -1569,7 +1585,19 @@ def setting_authentication():
else: else:
Setting().set('local_db_enabled', local_db_enabled) Setting().set('local_db_enabled', local_db_enabled)
Setting().set('signup_enabled', signup_enabled) Setting().set('signup_enabled', signup_enabled)
Setting().set('pwd_enforce_characters', pwd_enforce_characters)
Setting().set('pwd_min_len', pwd_min_len)
Setting().set('pwd_min_lowercase', pwd_min_lowercase)
Setting().set('pwd_min_uppercase', pwd_min_uppercase)
Setting().set('pwd_min_digits', pwd_min_digits)
Setting().set('pwd_min_special', pwd_min_special)
Setting().set('pwd_enforce_complexity', pwd_enforce_complexity)
Setting().set('pwd_min_complexity', pwd_min_complexity)
result = {'status': True, 'msg': 'Saved successfully'} result = {'status': True, 'msg': 'Saved successfully'}
elif conf_type == 'ldap': elif conf_type == 'ldap':
ldap_enabled = True if request.form.get('ldap_enabled') else False ldap_enabled = True if request.form.get('ldap_enabled') else False
@ -2151,3 +2179,10 @@ def validateURN(value):
return False return False
return True return True
def safe_cast(val, to_type, default=None):
try:
return to_type(val)
except (ValueError, TypeError):
return default

View File

@ -5,6 +5,8 @@ import traceback
import datetime import datetime
import ipaddress import ipaddress
import base64 import base64
import string
from zxcvbn import zxcvbn
from distutils.util import strtobool from distutils.util import strtobool
from yaml import Loader, load from yaml import Loader, load
from flask import Blueprint, render_template, make_response, url_for, current_app, g, session, request, redirect, abort from flask import Blueprint, render_template, make_response, url_for, current_app, g, session, request, redirect, abort
@ -649,6 +651,92 @@ def logout():
return redirect(redirect_uri) return redirect(redirect_uri)
def password_policy_check(user, password):
def check_policy(chars, user_password, setting):
lenreq = int(Setting().get(setting))
test_string = user_password
for c in chars:
test_string = test_string.replace(c, '')
return (lenreq, len(user_password) - len(test_string))
def matches_policy(item, policy_fails):
return "*" if item in policy_fails else ""
policy = []
policy_fails = {}
# If either policy is enabled check basics first ... this is obvious!
if Setting().get('pwd_enforce_characters') or Setting().get('pwd_enforce_complexity'):
# Cannot contain username
if user.username in password:
policy_fails["username"] = True
policy.append(f"{matches_policy('username', policy_fails)}cannot contain username")
# Cannot contain password
if user.firstname in password:
policy_fails["firstname"] = True
policy.append(f"{matches_policy('firstname', policy_fails)}cannot contain firstname")
# Cannot contain lastname
if user.lastname in password:
policy_fails["lastname"] = True
policy.append(f"{matches_policy('lastname', policy_fails)}cannot contain lastname")
# Cannot contain email
if user.email in password:
policy_fails["email"] = True
policy.append(f"{matches_policy('email', policy_fails)}cannot contain email")
# Check if we're enforcing character requirements
if Setting().get('pwd_enforce_characters'):
# Length
pwd_min_len_setting = int(Setting().get('pwd_min_len'))
pwd_len = len(password)
if pwd_len < pwd_min_len_setting:
policy_fails["length"] = True
policy.append(f"{matches_policy('length', policy_fails)}length={pwd_len}/{pwd_min_len_setting}")
# Digits
(pwd_min_digits_setting, pwd_digits) = check_policy(string.digits, password, 'pwd_min_digits')
if pwd_digits < pwd_min_digits_setting:
policy_fails["digits"] = True
policy.append(f"{matches_policy('digits', policy_fails)}digits={pwd_digits}/{pwd_min_digits_setting}")
# Lowercase
(pwd_min_lowercase_setting, pwd_lowercase) = check_policy(string.digits, password, 'pwd_min_lowercase')
if pwd_lowercase < pwd_min_lowercase_setting:
policy_fails["lowercase"] = True
policy.append(f"{matches_policy('lowercase', policy_fails)}lowercase={pwd_lowercase}/{pwd_min_lowercase_setting}")
# Uppercase
(pwd_min_uppercase_setting, pwd_uppercase) = check_policy(string.digits, password, 'pwd_min_uppercase')
if pwd_uppercase < pwd_min_uppercase_setting:
policy_fails["uppercase"] = True
policy.append(f"{matches_policy('uppercase', policy_fails)}uppercase={pwd_uppercase}/{pwd_min_uppercase_setting}")
# Special
(pwd_min_special_setting, pwd_special) = check_policy(string.digits, password, 'pwd_min_special')
if pwd_special < pwd_min_special_setting:
policy_fails["special"] = True
policy.append(f"{matches_policy('special', policy_fails)}special={pwd_special}/{pwd_min_special_setting}")
if Setting().get('pwd_enforce_complexity'):
# Complexity checking
zxcvbn_inputs = []
for input in (user.firstname, user.lastname, user.username, user.email):
if len(input):
zxcvbn_inputs.append(input)
result = zxcvbn(password, user_inputs=zxcvbn_inputs)
pwd_min_complexity_setting = int(Setting().get('pwd_min_complexity'))
pwd_complexity = result['guesses_log10']
if pwd_complexity < pwd_min_complexity_setting:
policy_fails["complexity"] = True
policy.append(f"{matches_policy('complexity', policy_fails)}complexity={pwd_complexity:.0f}/{pwd_min_complexity_setting}")
policy_str = {"password": f"Fails policy: {', '.join(policy)}. Items prefixed with '*' failed."}
# NK: the first item in the tuple indicates a PASS, so, we check for any True's and negate that
return (not any(policy_fails.values()), policy_str)
@index_bp.route('/register', methods=['GET', 'POST']) @index_bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
CAPTCHA_ENABLE = current_app.config.get('CAPTCHA_ENABLE') CAPTCHA_ENABLE = current_app.config.get('CAPTCHA_ENABLE')
@ -700,6 +788,10 @@ def register():
email=email email=email
) )
(password_policy_pass, password_policy) = password_policy_check(user, password)
if not password_policy_pass:
return render_template('register.html', error_messages=password_policy, captcha_enable=CAPTCHA_ENABLE)
try: try:
result = user.create_local_user() result = user.create_local_user()
if result and result['status']: if result and result['status']:

View File

@ -9,6 +9,8 @@ from flask_login import current_user, login_required, login_manager
from ..models.user import User, Anonymous from ..models.user import User, Anonymous
from ..models.setting import Setting from ..models.setting import Setting
from .index import password_policy_check
user_bp = Blueprint('user', user_bp = Blueprint('user',
__name__, __name__,
@ -79,12 +81,23 @@ def profile():
.format(current_user.username) .format(current_user.username)
}), 400) }), 400)
(password_policy_pass, password_policy) = password_policy_check(current_user.get_user_info_by_username(), new_password)
if not password_policy_pass:
if request.data:
return make_response(
jsonify({
'status': 'error',
'msg': password_policy['password'],
}), 400)
return render_template('user_profile.html', error_messages=password_policy)
user = User(username=current_user.username, user = User(username=current_user.username,
plain_text_password=new_password, plain_text_password=new_password,
firstname=firstname, firstname=firstname,
lastname=lastname, lastname=lastname,
email=email, email=email,
reload_info=False) reload_info=False)
user.update_profile() user.update_profile()
return render_template('user_profile.html') return render_template('user_profile.html')

View File

@ -78,24 +78,89 @@
<h3 class="card-title">Basic Settings</h3> <h3 class="card-title">Basic Settings</h3>
</div> </div>
<!-- /.card-header --> <!-- /.card-header -->
<div class="card-body"> <fieldset>
<div class="form-group"> <div class="card-body">
<input type="checkbox" id="local_db_enabled" <div class="form-group">
name="local_db_enabled" <input type="checkbox" id="local_db_enabled"
class="checkbox" name="local_db_enabled"
{% if SETTING.get('local_db_enabled') %}checked{% endif %}> class="checkbox"
<label for="local_db_enabled">Local DB {% if SETTING.get('local_db_enabled') %}checked{% endif %}>
Authentication</label> <label for="local_db_enabled">Local DB
Authentication</label>
</div>
<div class="form-group">
<input type="checkbox" id="signup_enabled"
name="signup_enabled"
class="checkbox"
{% if SETTING.get('signup_enabled') %}checked{% endif %}>
<label for="signup_enabled">Allow users to sign
up</label>
</div>
</div> </div>
<div class="form-group"> </fieldset>
<input type="checkbox" id="signup_enabled" <fieldset>
name="signup_enabled" <legend>PASSWORD REQUIREMENTS</legend>
class="checkbox" <div class="card-body">
{% if SETTING.get('signup_enabled') %}checked{% endif %}> <div class="form-group">
<label for="signup_enabled">Allow users to sign <input type="checkbox" id="pwd_enforce_characters"
up</label> name="pwd_enforce_characters" class="checkbox"
{% if SETTING.get('pwd_enforce_characters') %}checked{% endif %}>
<label for="pwd_enforce_characters">
Enforce Character Requirements
</label>
</div>
<div class="form-group">
<label for="pwd_min_len">Minimum Password Length</label>
<input type="text" class="form-control"
name="pwd_min_len" id="pwd_min_len"
data-error="Please enter a minimum password length"
value="{{ SETTING.get('pwd_min_len') }}">
</div>
<div class="form-group">
<label for="pwd_min_lowercase">Minimum Lowercase Characters</label>
<input type="text" class="form-control"
name="pwd_min_lowercase" id="pwd_min_lowercase"
data-error="Please enter the minimum number of lowercase letters required"
value="{{ SETTING.get('pwd_min_lowercase') }}">
</div>
<div class="form-group">
<label for="pwd_min_uppercase">Minimum Uppercase Characters</label>
<input type="text" class="form-control"
name="pwd_min_uppercase" id="pwd_min_uppercase"
data-error="Please enter the minimum number of uppercase letters required"
value="{{ SETTING.get('pwd_min_uppercase') }}">
</div>
<div class="form-group">
<label for="pwd_min_digits">Minimum Digit Characters</label>
<input type="text" class="form-control"
name="pwd_min_digits" id="pwd_min_digits"
data-error="Please enter the minimum number of digits required"
value="{{ SETTING.get('pwd_min_digits') }}">
</div>
<div class="form-group">
<label for="pwd_min_special">Minimum Special Characters</label>
<input type="text" class="form-control"
name="pwd_min_special" id="pwd_min_special"
data-error="Please enter the minimum number of special characters required"
value="{{ SETTING.get('pwd_min_special') }}">
</div>
<div class="form-group">
<input type="checkbox" id="pwd_enforce_complexity"
name="pwd_enforce_complexity" class="checkbox"
{% if SETTING.get('pwd_enforce_complexity') %}checked{% endif %}>
<label for="pwd_enforce_complexity">
Enforce Complexity Requirement
</label>
</div>
<div class="form-group">
<label for="pwd_min_complexity">Minimum Complexity (zxcvbn)</label>
<input type="text" class="form-control"
name="pwd_min_complexity" id="pwd_min_complexity"
data-error="Please enter the minimum password complexity required"
value="{{ SETTING.get('pwd_min_complexity') }}">
</div>
</div> </div>
</div> </fieldset>
<!-- /.card-body --> <!-- /.card-body -->
<div class="card-footer"> <div class="card-footer">
<button type="submit" class="btn btn-primary" <button type="submit" class="btn btn-primary"
@ -117,7 +182,49 @@
</div> </div>
<!-- /.card-header --> <!-- /.card-header -->
<div class="card-body"> <div class="card-body">
<p>Fill in all the fields in the left form.</p> <dl class="dl-horizontal">
<dt>Local DB Authentication</dt>
<dd>Enable/disable local database authentication.</dd>
<dt>Allow Users to Signup</dt>
<dd>Allow users to signup. This requires local database authentication
to be enabled.</dd>
<legend>PASSWORD REQUIREMENTS</legend>
This section allows you to customize your local DB password requirements
and ensure that when users change their password or signup they are
picking strong passwords.
<br/>
Setting any entry field to a blank value will revert it back to default.
<dt>Enforce Character Requirements</dt>
<dd>This option will enforce the character type requirements for
passwords.
<ul>
<li>Minimum Lowercase Characters - Minimum number of lowercase
characters required to appear in the password.</li>
<li>Minimum Uppercase Characters - Minimum number of uppercase
characters required to appear in the password.</li>
<li>Minimum Digit Characters - Minimum number of digits
required to appear in the password. Digits include
1234567890.</li>
<li>Minimum Special Characters - Minimum number of special
characters required to appear in the password. Special
characters include
`!@#$%^&amp;*()_-=+[]\{}|;:",.&gt;&lt;/?.</li>
</ul>
</dd>
<dt>Enforce Complexity Requirement</dt>
<dd>Enable the enforcement of complex passwords. We currently use
<a href="https://github.com/dropbox/zxcvbn">zxcvbn</a> for
determining this.
<ul>
<li>Minimum Complexity - The default value of the log factor
is 11 as it is considered secure. More information about
the this can be found at
<a href="https://www.usenix.org/system/files/conference/usenixsecurity16/sec16_paper_wheeler.pdf">here</a>
</li>
</ul>
</dd>
</dl>
</div> </div>
<!-- /.card-body --> <!-- /.card-body -->
</div> </div>

View File

@ -34,13 +34,13 @@
<div class="nav-tabs-custom mb-2"> <div class="nav-tabs-custom mb-2">
<ul class="nav nav-tabs" role="tablist"> <ul class="nav nav-tabs" role="tablist">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" href="#tabs-personal" data-toggle="tab"> <a class="nav-link {{ 'active' if not error_messages else '' }}" href="#tabs-personal" data-toggle="tab">
Personal Info Personal Info
</a> </a>
</li> </li>
{% if session['authentication_type'] == 'LOCAL' %} {% if session['authentication_type'] == 'LOCAL' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="#tabs-password" data-toggle="tab"> <a class="nav-link {{ 'active' if 'password' in error_messages else '' }}" href="#tabs-password" data-toggle="tab">
Change Password Change Password
</a> </a>
</li> </li>
@ -57,7 +57,8 @@
<!-- /.nav-tabs-custom --> <!-- /.nav-tabs-custom -->
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane fade show active" id="tabs-personal"> <div class="tab-pane fade {{ 'show active' if not error_messages else '' }}"
id="tabs-personal">
<form role="form" method="post" action="{{ user_profile }}"> <form role="form" method="post" action="{{ user_profile }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-group"> <div class="form-group">
@ -91,7 +92,8 @@
<!-- /.tab-pane --> <!-- /.tab-pane -->
{% if session['authentication_type'] == 'LOCAL' %} {% if session['authentication_type'] == 'LOCAL' %}
<div class="tab-pane fade" id="tabs-password"> <div class="tab-pane fade {{ 'show active' if 'password' in error_messages else '' }}"
id="tabs-password">
{% if not current_user.password %} {% if not current_user.password %}
Your account password is managed via LDAP which isn't supported to Your account password is managed via LDAP which isn't supported to
change here. change here.
@ -101,8 +103,15 @@
value="{{ csrf_token() }}"> value="{{ csrf_token() }}">
<div class="form-group"> <div class="form-group">
<label for="password">New Password</label> <label for="password">New Password</label>
<input type="password" class="form-control" name="password" <input type="password" class="form-control {{ 'is-invalid' if 'password' in error_messages else '' }}"
name="password"
id="newpassword"> id="newpassword">
{% if 'password' in error_messages %}
<div class="invalid-feedback">
<i class="fas fa-exclamation-triangle"></i>
{{ error_messages['password'] }}
</div>
{% endif %}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="rpassword">Re-type New Password</label> <label for="rpassword">Re-type New Password</label>

View File

@ -43,4 +43,5 @@ webcolors==1.12
werkzeug==2.1.2 werkzeug==2.1.2
zipp==3.11.0 zipp==3.11.0
rcssmin==1.1.1 rcssmin==1.1.1
psycopg2==2.9.5 zxcvbn==4.4.28
psycopg2==2.9.5