Source code for authnzerver.actions.passwords

# -*- coding: utf-8 -*-
# actions_user.py - Waqas Bhatti (wbhatti@astro.princeton.edu) - Aug 2018
# License: MIT - see the LICENSE file for the full text.

"""This contains functions for validating passwords.

"""

#############
## LOGGING ##
#############

import logging
from types import SimpleNamespace

# get a logger
LOGGER = logging.getLogger(__name__)


#############
## IMPORTS ##
#############

import socket
from hashlib import sha1
import random
import time

from tornado.escape import squeeze
import requests
from requests.exceptions import HTTPError, Timeout

from difflib import SequenceMatcher

from ..permissions import pii_hash

from .. import validators


###############
## functions ##
###############


[docs]def check_password_pwned( password: str, email: str, reqid: str, pii_salt: str, min_matches: int = 25, ) -> tuple: """ Checks the password against the haveibeenpwned.com API. https://haveibeenpwned.com/API/v3#PwnedPasswords Parameters ---------- password : str The password to check against the haveibeenpwned.com API. email : str The email address of the user creating the account. reqid : int or str The request ID associated with this password validation request. Used to track and correlate these requests in logs. pii_salt : str The PII salt value passed in from a wrapping function. Used to censor personally identifying information in the logs emitted from this function. min_matches : int The minimum number of matches required in the matching set returned by the API to consider a password as compromised. Returns ------- (status, msg, sha1_suffix, all_matches) : tuple If the password is considered to be compromised, returns "bad", msg for the first two elements in the tuple. Otherwise, returns "ok", "". If the API does not respond or there's an error, returns "unknown", "". """ # SHA1 hash the password hashed_password = sha1(password.encode("utf-8")).hexdigest() hashed_password_prefix = hashed_password[:5] hashed_password_suffix = hashed_password[5:] # send the request try: # need to stagger calls to the haveibeenpwned API time.sleep(0.1 + abs(random.random() - 0.4)) resp = requests.get( f"https://api.pwnedpasswords.com/range/{hashed_password_prefix}", timeout=5.0, ) resp.raise_for_status() except HTTPError as e: if e.response and e.response.status_code != 200: LOGGER.warning( f"[{reqid}] The haveibeenpwned.com API did not " f"respond with a 200 OK." f"HTTP response code was: {e.response.status_code}." ) return "unknown", "", hashed_password_suffix, None except Timeout: LOGGER.warning( f"[{reqid}] The haveibeenpwned.com API did not " f"respond within the requested timeout." ) return "unknown", "", hashed_password_suffix, None except Exception: LOGGER.exception(f"[{reqid}] The haveibeenpwned.com API call failed.") return "unknown", "", hashed_password_suffix, None # load the resp body respbody = resp.text resp_lines = respbody.split("\n") resp_lines = [tuple(x.strip().split(":")) for x in resp_lines] resp_check = {x[0].casefold(): int(x[1]) for x in resp_lines} if ( hashed_password_suffix in resp_check and resp_check[hashed_password_suffix] >= min_matches ): err_msg = ( f"Your password was found with " f"{resp_check[hashed_password_suffix]} matches in " f"the database of recently " f"compromised Web account passwords from " f"https://haveibeenpwned.com/Passwords and " f"is not secure." ) LOGGER.warning( f"[{reqid}] Password for account with " f"email: {pii_hash(email, pii_salt)} was found in " f"haveibeenpwned.com data with " f"{resp_check[hashed_password_suffix]} matches." ) return "bad", err_msg, hashed_password_suffix, resp_check return "ok", "", hashed_password_suffix, resp_check
[docs]def validate_input_password( full_name: str, email: str, password: str, pii_salt: str, reqid: str, min_pass_length: int = 12, max_unsafe_similarity: int = 33, max_character_frequency: float = 0.3, min_pwned_matches: int = 25, config: SimpleNamespace = None, ) -> tuple: """Validates user input passwords. Password rules are: 1. must be at least min_pass_length characters (we'll truncate the password at 1024 characters since we don't want to store entire novels) 2. must not match within max_unsafe_similarity of their email or full_name 3. must not match within max_unsafe_similarity of the site's FQDN 4. must not have a single case-folded character take up more than 20% of the length of the password 5. must not be completely numeric 6. must not be in the top 10k passwords list If all of the above pass, one last check is done: 7. must not be in the https://haveibeenpwned.com/Passwords database with at least *min_pwned_matches* matches Parameters ---------- full_name : str The full name of the user creating the account. email : str The email address of the user creating the account. password : str The password of the user creating the account. pii_salt : str The PII salt value passed in from a wrapping function. Used to censor personally identifying information in the logs emitted from this function. reqid : int or str The request ID associated with this password validation request. Used to track and correlate these requests in logs. min_pass_length : int The minimum required character length of the password. The value provided in this kwarg will be overriden by the ``passpolicy`` attribute in the config object if that is passed in as well. max_unsafe_similarity : int The maximum ratio required to fuzzy-match the input password against the server's domain name, the user's email, or their name. The value provided in this kwarg will be overriden by the ``passpolicy`` attribute in the config object if that is passed in as well. max_character_frequency : float The maximum number of times a character can appear in the password as a fraction of the total number of characters in the password. Upper and lower case characters are counted separately. min_pwned_matches : int The minimum number of matches required in the matching set returned by the haveibeenpwned.com password compromise database API to consider a password as compromised. config : SimpleNamespace object or None An object containing systemwide config variables as attributes. This is useful when the wrapping function needs to pass in some settings directly from environment variables. Returns ------- (password_ok, messages) : tuple *password_ok* is True if the password is OK to use and meets all specification, False otherwise. *messages* is a list of strings containing helpful messages on why the password was rejected (if it was) that can be passed to an end-user. """ server_fqdn = socket.getfqdn() # handle kwargs passed via config object if config is not None: passpolicy = getattr(config, "passpolicy", None) config_fqdn = getattr(config, "fqdn", None) if config_fqdn is not None: server_fqdn = config_fqdn if passpolicy: try: ( pass_minlen, pass_maxsim, pass_charfreq, min_pwned, ) = passpolicy.split(";") min_pass_length = int( pass_minlen.strip().replace(" ", "").split(":")[1] ) max_unsafe_similarity = int( pass_maxsim.strip().replace(" ", "").split(":")[1] ) max_character_frequency = float( pass_charfreq.strip().replace(" ", "").split(":")[1] ) min_pwned_matches = int( min_pwned.strip().replace(" ", "").split(":")[1] ) except Exception: LOGGER.exception( "[%s] Invalid password policy could not be parsed: '%s'. " "Falling back to kwarg values." % (reqid, passpolicy) ) pass messages = [] # we'll ignore any repeated white space and fail immediately if the password # is all white space if len(squeeze(password.strip())) < min_pass_length: LOGGER.warning( "[%s] Password for account " "with email: %s is too short (%s chars < required %s)." % ( reqid, pii_hash(email, pii_salt), len(password), min_pass_length, ) ) messages.append( "Your password is too short. " "It must have at least %s characters." % min_pass_length ) passlen_ok = False else: passlen_ok = True # check if the password is straight-up dumb if password.casefold() in validators.TOP_10K_PASSWORDS: LOGGER.warning( "[%s] Password for account " "with email: %s was found in the " "top 10k passwords list." % (reqid, pii_hash(email, pii_salt)) ) messages.append( "Your password is on the list of the " "most common passwords and is vulnerable to guessing." ) tenk_ok = False else: tenk_ok = True # FIXME: also add matching to top 10k passwords list to avoid stuff # like 'passwordpasswordpassword' # check the match against the FQDN, user name, and email address password_to_match = squeeze(password.casefold().strip()) fqdn_matcher = SequenceMatcher( None, password_to_match, server_fqdn.casefold() ) email_matcher = SequenceMatcher(None, password_to_match, email.casefold()) name_matcher = SequenceMatcher( None, password_to_match, full_name.casefold() ) fqdn_match = fqdn_matcher.ratio() * 100.0 email_match = email_matcher.ratio() * 100.0 name_match = name_matcher.ratio() * 100.0 fqdn_ok = fqdn_match < max_unsafe_similarity email_ok = email_match < max_unsafe_similarity name_ok = name_match < max_unsafe_similarity if not fqdn_ok or not email_ok or not name_ok: LOGGER.warning( "[%s] Password for account " "with email: %s matches FQDN " "(similarity: %.1f), their name (similarity: %.1f), " " or their email address " "(similarity: %.1f)." % ( reqid, pii_hash(email, pii_salt), fqdn_match, name_match, email_match, ) ) messages.append( "Your password is too similar to either " "the domain name of this server or your " "own name or email address." ) # next, check if the password is complex enough histogram = {} for char in password: if char not in histogram: histogram[char] = 1 else: histogram[char] = histogram[char] + 1 hist_ok = True for h in histogram: if (histogram[h] / len(password)) > max_character_frequency: hist_ok = False LOGGER.warning( "[%s] Password for account " "with email: %s does not have enough entropy. " "One character is more than " "%s x length of the password." % (reqid, pii_hash(email, pii_salt), max_character_frequency) ) messages.append( "Your password is not complex enough. " "One or more characters appear appear too frequently." ) break # check if the password is all numeric if password.isdigit(): numeric_ok = False LOGGER.warning( "[%s] Password for account " "with email: %s is all numbers." % (reqid, pii_hash(email, pii_salt)) ) messages.append("Your password cannot be all numbers.") else: numeric_ok = True # check the password against haveibeenbeenpwned.com. only do this check if # all the other ones pass, since this is an external HTTP API call if ( passlen_ok and email_ok and name_ok and fqdn_ok and hist_ok and numeric_ok and tenk_ok ): pwned_status, pwned_msg, _, _ = check_password_pwned( password, email, reqid, pii_salt, min_matches=min_pwned_matches ) is_pwned = pwned_status == "bad" if is_pwned: messages.append(pwned_msg) else: is_pwned = False return ( ( passlen_ok and email_ok and name_ok and fqdn_ok and hist_ok and numeric_ok and tenk_ok and not is_pwned ), messages, )
[docs]def validate_password( payload: dict, raiseonfail: bool = False, override_authdb_path: str = None, config: SimpleNamespace = None, ) -> dict: """External interface to password validation. Use this in a frontend server or client to validate any passwords sent by the end-user. Parameters ---------- payload : dict This is a dict with the following required keys: - password: str - email: str - full_name: str The following keys are optional: - min_pass_length: int, default = 12 - max_unsafe_similarity: int, default = 33 - max_character_frequency: float, default = 0.3 - min_pwned_matches: int, default = 25 The *email* and *full_name* are required to check if the password is too similar to either of these items. *min_pass_length* is the minimum number of characters required for the password. All passwords are capped at 256 characters. This value will be overriden by a value in the *config* object's *min_pass_length* attribute. *max_unsafe_similarity* is the maximum ratio required to fuzzy-match the input password against the server's domain name, the user's email, or their name. This value will be overriden by a value in the *config* object's *max_unsafe_similarity* attribute. *max_character_frequency* is the maximum ratio required to fuzzy-match the input password against the server's domain name, the user's email, or their name. The value provided in this kwarg will be overriden by the ``passpolicy`` attribute in the config object if that is passed in as well. *min_pwned_matches* is the minimum number of matches required in the matching set returned by the haveibeenpwned.com password compromise database API to consider a password as compromised. In addition to these items received from an authnzerver client, the payload must also include the following keys (usually added in by a wrapping function): - reqid: int or str - pii_salt: str raiseonfail : bool If True, will raise an Exception if something goes wrong. override_authdb_path : str or None If given as a str, is the alternative path to the auth DB. config : SimpleNamespace object or None An object containing systemwide config variables as attributes. This is useful when the wrapping function needs to pass in some settings directly from environment variables. Returns ------- dict Returns a dict containing a *success* key indicating if the user's password is valid and can be used. If the password is invalid, the *messages* key will contain messages that inform the user why their password was rejected. """ for key in ("reqid", "pii_salt"): if key not in payload: LOGGER.error( "Missing %s in payload dict. Can't process this request." % key ) return { "success": False, "failure_reason": ( "invalid request: missing '%s' in request" % key ), "messages": ["Invalid password validation request."], } for key in {"password", "email", "full_name"}: if key not in payload: LOGGER.error( "[%s] Invalid password validation request, missing %s." % (payload["reqid"], key) ) return { "success": False, "failure_reason": ( "invalid request: missing '%s' in request" % key ), "messages": [ "Invalid password validation request. " "Some required parameters are missing." ], } password, email, full_name = ( payload["password"], payload["email"], payload["full_name"], ) try: min_pass_length = int(payload.get("min_pass_length", 12)) if min_pass_length < 0: min_pass_length = 12 except Exception: min_pass_length = 12 try: max_unsafe_similarity = int(payload.get("max_unsafe_similarity", 33)) if max_unsafe_similarity < 0: max_unsafe_similarity = 33 except Exception: max_unsafe_similarity = 33 try: max_character_frequency = float( payload.get("max_character_frequency", 0.3) ) if max_character_frequency < 0: max_character_frequency = 0.3 except Exception: max_character_frequency = 0.3 try: min_pwned_matches = int(payload.get("min_pwned_matches", 25)) if min_pwned_matches < 0: min_pwned_matches = 25 except Exception: min_pwned_matches = 25 password_ok, messages = validate_input_password( full_name, email, password, payload["pii_salt"], payload["reqid"], min_pass_length=min_pass_length, max_unsafe_similarity=max_unsafe_similarity, max_character_frequency=max_character_frequency, min_pwned_matches=min_pwned_matches, config=config, ) retdict = {"success": password_ok, "messages": messages} if not password_ok: retdict["failure_reason"] = "password is insecure or invalid" return retdict