diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 2b1c7d7..48f929f 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -205,6 +205,14 @@ class Setting(db.Model): 'deny_domain_override': False, 'account_name_extra_chars': 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): diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 8e34d2d..3e44fd6 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -1557,7 +1557,23 @@ def setting_authentication(): local_db_enabled = True if request.form.get( 'local_db_enabled') else False 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): result = { @@ -1569,7 +1585,19 @@ def setting_authentication(): else: Setting().set('local_db_enabled', local_db_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'} + elif conf_type == 'ldap': ldap_enabled = True if request.form.get('ldap_enabled') else False @@ -2151,3 +2179,10 @@ def validateURN(value): return False return True + + +def safe_cast(val, to_type, default=None): + try: + return to_type(val) + except (ValueError, TypeError): + return default diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 72305a6..a21ad31 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -5,6 +5,8 @@ import traceback import datetime import ipaddress import base64 +import string +from zxcvbn import zxcvbn from distutils.util import strtobool from yaml import Loader, load 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) +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']) def register(): CAPTCHA_ENABLE = current_app.config.get('CAPTCHA_ENABLE') @@ -700,6 +788,10 @@ def register(): 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: result = user.create_local_user() if result and result['status']: diff --git a/powerdnsadmin/routes/user.py b/powerdnsadmin/routes/user.py index 65d7e08..adba502 100644 --- a/powerdnsadmin/routes/user.py +++ b/powerdnsadmin/routes/user.py @@ -9,6 +9,8 @@ from flask_login import current_user, login_required, login_manager from ..models.user import User, Anonymous from ..models.setting import Setting +from .index import password_policy_check + user_bp = Blueprint('user', __name__, @@ -79,12 +81,23 @@ def profile(): .format(current_user.username) }), 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, plain_text_password=new_password, firstname=firstname, lastname=lastname, email=email, reload_info=False) + user.update_profile() return render_template('user_profile.html') diff --git a/powerdnsadmin/templates/admin_setting_authentication.html b/powerdnsadmin/templates/admin_setting_authentication.html index 6196aa8..9ae899b 100644 --- a/powerdnsadmin/templates/admin_setting_authentication.html +++ b/powerdnsadmin/templates/admin_setting_authentication.html @@ -78,24 +78,89 @@

Basic Settings

-
-
- - +
+
+
+ + +
+
+ + +
-
- - +
+
+ PASSWORD REQUIREMENTS +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
+ diff --git a/powerdnsadmin/templates/user_profile.html b/powerdnsadmin/templates/user_profile.html index 3bb9971..ee161bb 100644 --- a/powerdnsadmin/templates/user_profile.html +++ b/powerdnsadmin/templates/user_profile.html @@ -34,13 +34,13 @@