diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 72305a6..79768c7 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') diff --git a/requirements.txt b/requirements.txt index 83d1011..b8256b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,3 +43,4 @@ webcolors==1.12 werkzeug==2.1.2 zipp==3.11.0 rcssmin==1.1.1 +zxcvbn==4.4.28 \ No newline at end of file