forked from cardosofelipe/fast-next-template
Refactor error handling, validation, and schema logic; improve query performance and add shared validators
- Added reusable validation functions (`validate_password_strength`, `validate_phone_number`, etc.) to centralize schema validation in `validators.py`. - Updated `schemas/users.py` to use shared validators, simplifying and unifying validation logic. - Introduced new error codes (`AUTH_007`, `SYS_005`) for enhanced error specificity. - Refactored exception handling in admin routes to use more appropriate error types (`AuthorizationError`, `DuplicateError`). - Improved organization query performance by replacing N+1 queries with optimized methods for member counts and data aggregation. - Strengthened security in JWT decoding to prevent algorithm confusion attacks, with strict validation of required claims and algorithm enforcement.
This commit is contained in:
183
backend/app/schemas/validators.py
Normal file
183
backend/app/schemas/validators.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Shared validators for Pydantic schemas.
|
||||
|
||||
This module provides reusable validation functions to ensure consistency
|
||||
across all schemas and avoid code duplication.
|
||||
"""
|
||||
import re
|
||||
from typing import Set
|
||||
|
||||
# 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
|
||||
if cleaned.count('+') > 1:
|
||||
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:]):
|
||||
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
|
||||
Reference in New Issue
Block a user