- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest). - Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting. - Updated `requirements.txt` to include Ruff and remove replaced tools. - Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
203 lines
5.7 KiB
Python
203 lines
5.7 KiB
Python
"""
|
|
Shared validators for Pydantic schemas.
|
|
|
|
This module provides reusable validation functions to ensure consistency
|
|
across all schemas and avoid code duplication.
|
|
"""
|
|
|
|
import re
|
|
|
|
# Common weak passwords that should be rejected
|
|
COMMON_PASSWORDS: set[str] = {
|
|
"password",
|
|
"password1",
|
|
"password123",
|
|
"password1234",
|
|
"admin",
|
|
"admin123",
|
|
"admin1234",
|
|
"welcome",
|
|
"welcome1",
|
|
"welcome123",
|
|
"qwerty",
|
|
"qwerty123",
|
|
"12345678",
|
|
"123456789",
|
|
"1234567890",
|
|
"letmein",
|
|
"letmein1",
|
|
"letmein123",
|
|
"monkey123",
|
|
"dragon123",
|
|
"passw0rd",
|
|
"p@ssw0rd",
|
|
"p@ssword",
|
|
}
|
|
|
|
|
|
def validate_password_strength(password: str) -> str:
|
|
"""
|
|
Validate password strength with enterprise-grade requirements.
|
|
|
|
Requirements:
|
|
- Minimum 12 characters (increased from 8 for better security)
|
|
- At least one lowercase letter
|
|
- At least one uppercase letter
|
|
- At least one digit
|
|
- At least one special character
|
|
- Not in common password list
|
|
|
|
Args:
|
|
password: The password to validate
|
|
|
|
Returns:
|
|
The validated password
|
|
|
|
Raises:
|
|
ValueError: If password doesn't meet requirements
|
|
|
|
Examples:
|
|
>>> validate_password_strength("MySecureP@ss123") # Valid
|
|
>>> validate_password_strength("password1") # Invalid - too weak
|
|
"""
|
|
# Check minimum length
|
|
if len(password) < 12:
|
|
raise ValueError("Password must be at least 12 characters long")
|
|
|
|
# Check against common passwords (case-insensitive)
|
|
if password.lower() in COMMON_PASSWORDS:
|
|
raise ValueError("Password is too common. Please choose a stronger password")
|
|
|
|
# Check for required character types
|
|
checks = [
|
|
(any(c.islower() for c in password), "at least one lowercase letter"),
|
|
(any(c.isupper() for c in password), "at least one uppercase letter"),
|
|
(any(c.isdigit() for c in password), "at least one digit"),
|
|
(
|
|
any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?~`" for c in password),
|
|
"at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?~`)",
|
|
),
|
|
]
|
|
|
|
failed = [msg for check, msg in checks if not check]
|
|
if failed:
|
|
raise ValueError(f"Password must contain {', '.join(failed)}")
|
|
|
|
return password
|
|
|
|
|
|
def validate_phone_number(phone: str | None) -> str | None:
|
|
"""
|
|
Validate phone number format.
|
|
|
|
Accepts international format with + prefix or local format with 0 prefix.
|
|
Removes formatting characters (spaces, hyphens, parentheses).
|
|
|
|
Args:
|
|
phone: Phone number to validate (can be None)
|
|
|
|
Returns:
|
|
Cleaned phone number or None
|
|
|
|
Raises:
|
|
ValueError: If phone number format is invalid
|
|
|
|
Examples:
|
|
>>> validate_phone_number("+1 (555) 123-4567") # Valid
|
|
>>> validate_phone_number("0412 345 678") # Valid
|
|
>>> validate_phone_number("invalid") # Invalid
|
|
"""
|
|
if phone is None:
|
|
return None
|
|
|
|
# Check for empty strings
|
|
if not phone or phone.strip() == "":
|
|
raise ValueError("Phone number cannot be empty")
|
|
|
|
# Remove all spaces and formatting characters
|
|
cleaned = re.sub(r"[\s\-\(\)]", "", phone)
|
|
|
|
# Basic pattern:
|
|
# Must start with + or 0
|
|
# After + must have at least 8 digits
|
|
# After 0 must have at least 8 digits
|
|
# Maximum total length of 15 digits (international standard)
|
|
# Only allowed characters are + at start and digits
|
|
pattern = r"^(?:\+[0-9]{8,14}|0[0-9]{8,14})$"
|
|
|
|
if not re.match(pattern, cleaned):
|
|
raise ValueError("Phone number must start with + or 0 followed by 8-14 digits")
|
|
|
|
# Additional validation to catch specific invalid cases
|
|
# NOTE: These checks are defensive code - the regex pattern above already catches these cases
|
|
if cleaned.count("+") > 1: # pragma: no cover
|
|
raise ValueError("Phone number can only contain one + symbol at the start")
|
|
|
|
# Check for any non-digit characters (except the leading +)
|
|
if not all(c.isdigit() for c in cleaned[1:]): # pragma: no cover
|
|
raise ValueError("Phone number can only contain digits after the prefix")
|
|
|
|
return cleaned
|
|
|
|
|
|
def validate_email_format(email: str) -> str:
|
|
"""
|
|
Additional email validation beyond Pydantic's EmailStr.
|
|
|
|
This can be extended for custom email validation rules.
|
|
|
|
Args:
|
|
email: Email address to validate
|
|
|
|
Returns:
|
|
Validated email address
|
|
|
|
Raises:
|
|
ValueError: If email format is invalid
|
|
"""
|
|
# Pydantic's EmailStr already does comprehensive validation
|
|
# This function is here for custom rules if needed
|
|
|
|
# Example: Reject disposable email domains (optional)
|
|
# disposable_domains = {'tempmail.com', '10minutemail.com', 'guerrillamail.com'}
|
|
# domain = email.split('@')[1].lower()
|
|
# if domain in disposable_domains:
|
|
# raise ValueError('Disposable email addresses are not allowed')
|
|
|
|
return email.lower() # Normalize to lowercase
|
|
|
|
|
|
def validate_slug(slug: str) -> str:
|
|
"""
|
|
Validate URL slug format.
|
|
|
|
Slugs must:
|
|
- Be 2-50 characters long
|
|
- Contain only lowercase letters, numbers, and hyphens
|
|
- Not start or end with a hyphen
|
|
- Not contain consecutive hyphens
|
|
|
|
Args:
|
|
slug: URL slug to validate
|
|
|
|
Returns:
|
|
Validated slug
|
|
|
|
Raises:
|
|
ValueError: If slug format is invalid
|
|
"""
|
|
if not slug or len(slug) < 2:
|
|
raise ValueError("Slug must be at least 2 characters long")
|
|
|
|
if len(slug) > 50:
|
|
raise ValueError("Slug must be at most 50 characters long")
|
|
|
|
# Check format
|
|
if not re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", slug):
|
|
raise ValueError(
|
|
"Slug can only contain lowercase letters, numbers, and hyphens. "
|
|
"It cannot start or end with a hyphen, and cannot contain consecutive hyphens"
|
|
)
|
|
|
|
return slug
|