"""
Password policy enforcement for secure authentication.
This module implements a comprehensive password policy that balances security
with usability. The policy is based on NIST SP 800-63B guidelines and industry
best practices, while avoiding overly restrictive rules that lead users to
create predictable passwords.
Policy Philosophy:
Modern password security research (NIST SP 800-63B, 2017) recommends:
- Length over complexity (longer passwords are more secure)
- Blocking common/compromised passwords
- Avoiding arbitrary complexity rules that frustrate users
- Not requiring periodic password changes (unless compromise suspected)
This implementation follows these principles while providing configurable
enforcement levels for different deployment scenarios.
Policy Levels:
- BASIC: Minimum viable security (8 chars, basic checks)
- STANDARD: Recommended for most deployments (12 chars, common password check)
- STRICT: High-security environments (16 chars, all checks enabled)
Usage:
from mud_server.api.password_policy import (
validate_password_strength,
PasswordPolicy,
PolicyLevel,
)
# Using default (STANDARD) policy
result = validate_password_strength("my_password_123")
if not result.is_valid:
print(result.errors)
# Using a specific policy level
result = validate_password_strength("password", level=PolicyLevel.STRICT)
# Custom policy
policy = PasswordPolicy(
min_length=14,
require_uppercase=True,
require_lowercase=True,
require_digit=True,
require_special=True,
check_common_passwords=True,
)
result = policy.validate("my_password")
Security Considerations:
- Common password list is checked in constant time to prevent timing attacks
- Password strength feedback helps users create better passwords
- All validation errors are returned at once (not fail-fast) for better UX
- Entropy estimation provides a security score for password meters
"""
import re
from dataclasses import dataclass, field
from enum import Enum
# ============================================================================
# COMMON PASSWORDS LIST
# ============================================================================
# This list contains the most commonly used passwords that should always be
# rejected. Based on analysis of password breaches and security research.
# This is a subset - production systems should use a larger database.
# ============================================================================
COMMON_PASSWORDS: frozenset[str] = frozenset(
[
# Top 100 most common passwords (lowercase normalized)
"123456",
"password",
"12345678",
"qwerty",
"123456789",
"12345",
"1234",
"111111",
"1234567",
"dragon",
"123123",
"baseball",
"abc123",
"football",
"monkey",
"letmein",
"696969",
"shadow",
"master",
"666666",
"qwertyuiop",
"123321",
"mustang",
"1234567890",
"michael",
"654321",
"pussy",
"superman",
"1qaz2wsx",
"7777777",
"fuckyou",
"121212",
"000000",
"qazwsx",
"123qwe",
"killer",
"trustno1",
"jordan",
"jennifer",
"zxcvbnm",
"asdfgh",
"hunter",
"buster",
"soccer",
"harley",
"batman",
"andrew",
"tigger",
"sunshine",
"iloveyou",
"fuckme",
"2000",
"charlie",
"robert",
"thomas",
"hockey",
"ranger",
"daniel",
"starwars",
"klaster",
"112233",
"george",
"asshole",
"computer",
"michelle",
"jessica",
"pepper",
"1111",
"zxcvbn",
"555555",
"11111111",
"131313",
"freedom",
"777777",
"pass",
"fuck",
"maggie",
"159753",
"aaaaaa",
"ginger",
"princess",
"joshua",
"cheese",
"amanda",
"summer",
"love",
"ashley",
"6969",
"nicole",
"chelsea",
"biteme",
"matthew",
"access",
"yankees",
"987654321",
"dallas",
"austin",
"thunder",
"taylor",
"matrix",
# Additional commonly breached passwords
"password1",
"password123",
"admin",
"admin123",
"root",
"toor",
"guest",
"login",
"welcome",
"welcome1",
"welcome123",
"changeme",
"passw0rd",
"p@ssw0rd",
"p@ssword",
"letmein1",
"letmein123",
"qwerty123",
"qwerty1",
"abc1234",
"abcd1234",
"test",
"test123",
"test1234",
"temp",
"temp123",
"default",
"secret",
"secret123",
# Keyboard patterns
"qwertyui",
"asdfghjk",
"zxcvbnm,",
"1q2w3e4r",
"1q2w3e4r5t",
"1qaz2wsx3edc",
"qazwsxedc",
"!qaz2wsx",
"1qazxsw2",
# Year-based passwords
"2020",
"2021",
"2022",
"2023",
"2024",
"2025",
"2026",
# Common names with numbers
"john123",
"mike123",
"user123",
"admin1234",
]
)
# ============================================================================
# POLICY CONFIGURATION
# ============================================================================
[docs]
class PolicyLevel(Enum):
"""
Predefined password policy levels for different security requirements.
Each level provides a balance between security and usability appropriate
for different deployment scenarios.
Attributes:
BASIC: Minimum viable security. Suitable for development/testing
environments or low-risk internal applications.
- 8 character minimum
- No complexity requirements
- Basic common password check
STANDARD: Recommended for most production deployments. Provides
strong security without being overly restrictive.
- 12 character minimum (NIST recommended)
- Encourages but doesn't require complexity
- Comprehensive common password check
- Sequential/repeated character detection
STRICT: For high-security environments handling sensitive data.
- 16 character minimum
- Requires uppercase, lowercase, digit, and special char
- All security checks enabled
- Maximum entropy requirements
"""
BASIC = "basic"
STANDARD = "standard"
STRICT = "strict"
[docs]
@dataclass
class ValidationResult:
"""
Result of password validation containing success status and feedback.
This class provides detailed feedback about password validation, including
all errors encountered and suggestions for improvement. All errors are
collected (not fail-fast) to provide complete feedback to users.
Attributes:
is_valid: True if the password meets all policy requirements.
errors: List of human-readable error messages describing each
validation failure. Empty if password is valid.
warnings: List of suggestions for improving password strength.
May be present even for valid passwords.
score: Estimated password strength score from 0-100. Higher is better.
Based on length, character diversity, and entropy estimation.
entropy_bits: Estimated entropy in bits. Passwords with 60+ bits
are considered strong, 80+ bits are excellent.
Example:
>>> result = validate_password_strength("weak")
>>> result.is_valid
False
>>> result.errors
['Password must be at least 12 characters long (currently 4)']
>>> result.score
15
"""
is_valid: bool
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
score: int = 0
entropy_bits: float = 0.0
[docs]
@dataclass
class PasswordPolicy:
"""
Configurable password policy with comprehensive validation rules.
This class encapsulates all password policy settings and provides
validation methods. It can be configured for different security
levels or customized for specific requirements.
Attributes:
min_length: Minimum password length. NIST recommends 8 minimum,
but 12+ provides significantly better security.
max_length: Maximum password length. Set high (128) to allow
passphrases. Bcrypt has a 72-byte limit internally.
require_uppercase: Require at least one uppercase letter (A-Z).
require_lowercase: Require at least one lowercase letter (a-z).
require_digit: Require at least one digit (0-9).
require_special: Require at least one special character.
special_characters: Set of characters considered "special".
check_common_passwords: Check against known common passwords.
check_sequential: Detect sequential characters (abc, 123).
check_repeated: Detect repeated characters (aaa, 111).
max_repeated: Maximum allowed consecutive repeated characters.
Example:
>>> policy = PasswordPolicy(min_length=14, require_special=True)
>>> result = policy.validate("MySecurePass123!")
>>> result.is_valid
True
"""
# Length requirements
min_length: int = 12
max_length: int = 128
# Character class requirements
require_uppercase: bool = False
require_lowercase: bool = False
require_digit: bool = False
require_special: bool = False
special_characters: str = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~"
# Security checks
check_common_passwords: bool = True
check_sequential: bool = True
check_repeated: bool = True
max_repeated: int = 3
[docs]
def validate(self, password: str) -> ValidationResult:
"""
Validate a password against this policy.
Performs all configured validation checks and returns a comprehensive
result with all errors, warnings, and a strength score. Validation
is not fail-fast - all checks are performed to provide complete
feedback.
Args:
password: The password string to validate. Should be the raw
password before hashing.
Returns:
ValidationResult containing:
- is_valid: True if all requirements are met
- errors: List of validation failure messages
- warnings: List of improvement suggestions
- score: Strength score 0-100
- entropy_bits: Estimated entropy
Example:
>>> policy = PasswordPolicy()
>>> result = policy.validate("short")
>>> result.is_valid
False
>>> "at least 12 characters" in result.errors[0]
True
"""
errors: list[str] = []
warnings: list[str] = []
# =================================================================
# LENGTH CHECKS
# =================================================================
if len(password) < self.min_length:
errors.append(
f"Password must be at least {self.min_length} characters long "
f"(currently {len(password)})"
)
if len(password) > self.max_length:
errors.append(
f"Password must be at most {self.max_length} characters long "
f"(currently {len(password)})"
)
# =================================================================
# CHARACTER CLASS CHECKS
# =================================================================
has_uppercase = bool(re.search(r"[A-Z]", password))
has_lowercase = bool(re.search(r"[a-z]", password))
has_digit = bool(re.search(r"[0-9]", password))
has_special = bool(any(char in self.special_characters for char in password))
if self.require_uppercase and not has_uppercase:
errors.append("Password must contain at least one uppercase letter (A-Z)")
if self.require_lowercase and not has_lowercase:
errors.append("Password must contain at least one lowercase letter (a-z)")
if self.require_digit and not has_digit:
errors.append("Password must contain at least one digit (0-9)")
if self.require_special and not has_special:
errors.append(
f"Password must contain at least one special character "
f"({self.special_characters[:10]}...)"
)
# Add warnings for missing character classes (even if not required)
if not self.require_uppercase and not has_uppercase:
warnings.append("Adding uppercase letters would improve password strength")
if not self.require_digit and not has_digit:
warnings.append("Adding numbers would improve password strength")
if not self.require_special and not has_special:
warnings.append("Adding special characters would improve password strength")
# =================================================================
# COMMON PASSWORD CHECK
# =================================================================
if self.check_common_passwords:
# Normalize for comparison (lowercase, strip whitespace)
normalized = password.lower().strip()
if normalized in COMMON_PASSWORDS:
errors.append(
"This password is too common and easily guessed. "
"Please choose a more unique password."
)
# Also check with common substitutions reversed
# e.g., p@ssw0rd -> password
desubstituted = self._reverse_substitutions(normalized)
if desubstituted in COMMON_PASSWORDS and desubstituted != normalized:
errors.append(
"This password is a common password with simple substitutions. "
"Please choose a more unique password."
)
# =================================================================
# SEQUENTIAL CHARACTER CHECK
# =================================================================
if self.check_sequential:
sequential_found = self._find_sequential(password)
if sequential_found:
errors.append(
f"Password contains sequential characters ({sequential_found}). "
"Avoid sequences like 'abc', '123', or 'xyz'."
)
# =================================================================
# REPEATED CHARACTER CHECK
# =================================================================
if self.check_repeated:
repeated_found = self._find_repeated(password, self.max_repeated)
if repeated_found:
errors.append(
f"Password contains too many repeated characters ({repeated_found}). "
f"Avoid repeating the same character more than {self.max_repeated} times."
)
# =================================================================
# CALCULATE STRENGTH SCORE AND ENTROPY
# =================================================================
entropy_bits = self._estimate_entropy(password)
score = self._calculate_score(
password, entropy_bits, has_uppercase, has_lowercase, has_digit, has_special
)
# Reduce score if there are errors
if errors:
score = min(score, 25)
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
warnings=warnings,
score=score,
entropy_bits=entropy_bits,
)
def _reverse_substitutions(self, password: str) -> str:
"""
Reverse common character substitutions to detect leet-speak passwords.
Common substitutions like @ -> a, 0 -> o, 3 -> e are reversed to
check if the underlying password is a common one.
Args:
password: Lowercase password string.
Returns:
Password with common substitutions reversed.
"""
substitutions = {
"@": "a",
"4": "a",
"8": "b",
"(": "c",
"3": "e",
"6": "g",
"1": "i",
"!": "i",
"|": "l",
"0": "o",
"5": "s",
"$": "s",
"7": "t",
"+": "t",
"2": "z",
}
result = password
for sub, original in substitutions.items():
result = result.replace(sub, original)
return result
def _find_sequential(self, password: str, min_length: int = 3) -> str | None:
"""
Find sequential character patterns in the password.
Detects ascending or descending sequences of characters that are
easy to guess, such as 'abc', '123', 'cba', '321'.
Args:
password: Password to check.
min_length: Minimum sequence length to detect.
Returns:
The sequential pattern found, or None if no pattern detected.
"""
password_lower = password.lower()
# Check for sequential patterns
for i in range(len(password_lower) - min_length + 1):
# Get sequence of characters
seq = password_lower[i : i + min_length]
# Check if ascending sequence
is_ascending = all(ord(seq[j + 1]) == ord(seq[j]) + 1 for j in range(len(seq) - 1))
if is_ascending:
return seq
# Check if descending sequence
is_descending = all(ord(seq[j + 1]) == ord(seq[j]) - 1 for j in range(len(seq) - 1))
if is_descending:
return seq
return None
def _find_repeated(self, password: str, max_allowed: int) -> str | None:
"""
Find repeated character patterns exceeding the allowed limit.
Detects sequences where the same character is repeated too many
times consecutively, such as 'aaa' or '1111'.
Args:
password: Password to check.
max_allowed: Maximum allowed consecutive repetitions.
Returns:
The repeated pattern found, or None if within limits.
"""
if max_allowed < 1:
return None
current_char = ""
current_count = 0
for char in password:
if char == current_char:
current_count += 1
if current_count > max_allowed:
return char * current_count
else:
current_char = char
current_count = 1
return None
def _estimate_entropy(self, password: str) -> float:
"""
Estimate password entropy in bits.
Entropy measures the unpredictability of a password. Higher entropy
means the password is harder to guess through brute force.
Calculation considers:
- Character set size (uppercase, lowercase, digits, special)
- Password length
- Penalties for common patterns
Args:
password: Password to analyze.
Returns:
Estimated entropy in bits. Guidelines:
- < 28 bits: Very weak
- 28-35 bits: Weak
- 36-59 bits: Reasonable
- 60-127 bits: Strong
- 128+ bits: Very strong
"""
import math
if not password:
return 0.0
# Determine character set size
charset_size = 0
if re.search(r"[a-z]", password):
charset_size += 26
if re.search(r"[A-Z]", password):
charset_size += 26
if re.search(r"[0-9]", password):
charset_size += 10
if any(c in self.special_characters for c in password):
charset_size += len(self.special_characters)
if charset_size == 0:
charset_size = 1 # Avoid log(0)
# Basic entropy calculation: log2(charset_size^length)
entropy = len(password) * math.log2(charset_size)
# Apply penalties for patterns
# Repeated characters reduce entropy
for i in range(len(password) - 1):
if password[i] == password[i + 1]:
entropy -= 1
# Sequential patterns reduce entropy
for i in range(len(password) - 2):
if (
ord(password[i + 1]) == ord(password[i]) + 1
and ord(password[i + 2]) == ord(password[i]) + 2
):
entropy -= 2
return max(0.0, entropy)
def _calculate_score(
self,
password: str,
entropy: float,
has_upper: bool,
has_lower: bool,
has_digit: bool,
has_special: bool,
) -> int:
"""
Calculate a 0-100 strength score for the password.
The score combines multiple factors to provide an overall assessment
of password strength for UI feedback purposes.
Scoring breakdown:
- Length: Up to 40 points (4 points per character up to 10)
- Entropy: Up to 30 points (based on bits of entropy)
- Character diversity: Up to 20 points (5 per class present)
- Bonus: Up to 10 points for exceeding minimum requirements
Args:
password: The password being scored.
entropy: Calculated entropy in bits.
has_upper: True if password contains uppercase.
has_lower: True if password contains lowercase.
has_digit: True if password contains digits.
has_special: True if password contains special chars.
Returns:
Integer score from 0 to 100.
"""
score = 0
# Length score (up to 40 points)
length_score = min(40, len(password) * 4)
score += length_score
# Entropy score (up to 30 points)
# 60 bits = full points, scale linearly
entropy_score = min(30, int(entropy / 2))
score += entropy_score
# Character diversity (up to 20 points)
diversity_score = 0
if has_upper:
diversity_score += 5
if has_lower:
diversity_score += 5
if has_digit:
diversity_score += 5
if has_special:
diversity_score += 5
score += diversity_score
# Bonus for exceeding minimum length (up to 10 points)
if len(password) > self.min_length:
bonus = min(10, (len(password) - self.min_length) * 2)
score += bonus
return min(100, score)
# ============================================================================
# PREDEFINED POLICIES
# ============================================================================
# Basic policy - minimum viable security
POLICY_BASIC = PasswordPolicy(
min_length=8,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=False,
check_common_passwords=True,
check_sequential=False,
check_repeated=False,
)
# Standard policy - recommended for most deployments (NIST-aligned)
POLICY_STANDARD = PasswordPolicy(
min_length=12,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=False,
check_common_passwords=True,
check_sequential=True,
check_repeated=True,
max_repeated=3,
)
# Strict policy - for high-security environments
POLICY_STRICT = PasswordPolicy(
min_length=16,
require_uppercase=True,
require_lowercase=True,
require_digit=True,
require_special=True,
check_common_passwords=True,
check_sequential=True,
check_repeated=True,
max_repeated=2,
)
# Policy lookup by level
_POLICIES: dict[PolicyLevel, PasswordPolicy] = {
PolicyLevel.BASIC: POLICY_BASIC,
PolicyLevel.STANDARD: POLICY_STANDARD,
PolicyLevel.STRICT: POLICY_STRICT,
}
# ============================================================================
# PUBLIC API
# ============================================================================
[docs]
def validate_password_strength(
password: str,
level: PolicyLevel = PolicyLevel.STANDARD,
) -> ValidationResult:
"""
Validate password strength using a predefined policy level.
This is the primary function for password validation. It uses one of
the predefined policy levels (BASIC, STANDARD, or STRICT) to validate
the password and return comprehensive feedback.
Args:
password: The password to validate. Should be the raw password
before any hashing.
level: Security level to enforce. Defaults to STANDARD which is
appropriate for most production deployments.
Returns:
ValidationResult containing validation status, errors, warnings,
strength score, and entropy estimate.
Example:
>>> result = validate_password_strength("MyStr0ng!Pass#2024")
>>> result.is_valid
True
>>> result.score
85
>>> result = validate_password_strength("weak", level=PolicyLevel.STRICT)
>>> result.is_valid
False
>>> len(result.errors) > 0
True
See Also:
PasswordPolicy: For custom policy configuration.
PolicyLevel: For available predefined levels.
"""
policy = _POLICIES[level]
return policy.validate(password)
[docs]
def get_policy(level: PolicyLevel = PolicyLevel.STANDARD) -> PasswordPolicy:
"""
Get the PasswordPolicy instance for a given level.
Useful when you need to inspect policy settings or use the policy
object directly.
Args:
level: The policy level to retrieve.
Returns:
The PasswordPolicy instance for that level.
Example:
>>> policy = get_policy(PolicyLevel.STRICT)
>>> policy.min_length
16
>>> policy.require_special
True
"""
return _POLICIES[level]
[docs]
def get_password_requirements(level: PolicyLevel = PolicyLevel.STANDARD) -> str:
"""
Get a human-readable description of password requirements.
Useful for displaying password requirements to users in registration
forms or password change dialogs.
Args:
level: The policy level to describe.
Returns:
Multi-line string describing all password requirements.
Example:
>>> print(get_password_requirements(PolicyLevel.STANDARD))
Password Requirements:
- At least 12 characters long
- Cannot be a commonly used password
- Cannot contain sequential characters (abc, 123)
- Cannot repeat the same character more than 3 times
"""
policy = _POLICIES[level]
lines = ["Password Requirements:"]
lines.append(f"- At least {policy.min_length} characters long")
if policy.max_length < 128:
lines.append(f"- At most {policy.max_length} characters long")
if policy.require_uppercase:
lines.append("- Must contain at least one uppercase letter (A-Z)")
if policy.require_lowercase:
lines.append("- Must contain at least one lowercase letter (a-z)")
if policy.require_digit:
lines.append("- Must contain at least one number (0-9)")
if policy.require_special:
lines.append("- Must contain at least one special character (!@#$%...)")
if policy.check_common_passwords:
lines.append("- Cannot be a commonly used password")
if policy.check_sequential:
lines.append("- Cannot contain sequential characters (abc, 123)")
if policy.check_repeated:
lines.append(f"- Cannot repeat the same character more than {policy.max_repeated} times")
return "\n".join(lines)