Moved auth_utils to utils
This commit is contained in:
parent
aefc14c610
commit
168fe8cc8e
275
src/backend/utils/auth_utils.py
Normal file
275
src/backend/utils/auth_utils.py
Normal file
@ -0,0 +1,275 @@
|
||||
# auth_utils.py
|
||||
"""
|
||||
Secure Authentication Utilities
|
||||
Provides password hashing, verification, and security features
|
||||
"""
|
||||
|
||||
import traceback
|
||||
import bcrypt
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PasswordSecurity:
|
||||
"""Handles password hashing and verification using bcrypt"""
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Hash a password with a random salt using bcrypt
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Tuple of (password_hash, salt) both as strings
|
||||
"""
|
||||
# Generate a random salt
|
||||
salt = bcrypt.gensalt()
|
||||
|
||||
# Hash the password
|
||||
password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
|
||||
|
||||
return password_hash.decode('utf-8'), salt.decode('utf-8')
|
||||
|
||||
@staticmethod
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""
|
||||
Verify a password against its hash
|
||||
|
||||
Args:
|
||||
password: Plain text password to verify
|
||||
password_hash: Stored password hash
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
password.encode('utf-8'),
|
||||
password_hash.encode('utf-8')
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Password verification error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def generate_secure_token(length: int = 32) -> str:
|
||||
"""Generate a cryptographically secure random token"""
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
class AuthenticationRecord(BaseModel):
|
||||
"""Authentication record for storing user credentials"""
|
||||
user_id: str
|
||||
password_hash: str
|
||||
salt: str
|
||||
refresh_tokens: list = []
|
||||
reset_password_token: Optional[str] = None
|
||||
reset_password_expiry: Optional[datetime] = None
|
||||
last_password_change: datetime
|
||||
mfa_enabled: bool = False
|
||||
mfa_method: Optional[str] = None
|
||||
mfa_secret: Optional[str] = None
|
||||
login_attempts: int = 0
|
||||
locked_until: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat() if v else None
|
||||
}
|
||||
|
||||
class SecurityConfig:
|
||||
"""Security configuration constants"""
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
ACCOUNT_LOCKOUT_DURATION_MINUTES = 15
|
||||
PASSWORD_MIN_LENGTH = 8
|
||||
TOKEN_EXPIRY_HOURS = 24
|
||||
REFRESH_TOKEN_EXPIRY_DAYS = 30
|
||||
|
||||
class AuthenticationManager:
|
||||
"""Manages authentication operations with security features"""
|
||||
|
||||
def __init__(self, database):
|
||||
self.database = database
|
||||
self.password_security = PasswordSecurity()
|
||||
|
||||
async def create_user_authentication(self, user_id: str, password: str) -> AuthenticationRecord:
|
||||
"""
|
||||
Create authentication record for a new user
|
||||
|
||||
Args:
|
||||
user_id: Unique user identifier
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
AuthenticationRecord object
|
||||
"""
|
||||
if len(password) < SecurityConfig.PASSWORD_MIN_LENGTH:
|
||||
raise ValueError(f"Password must be at least {SecurityConfig.PASSWORD_MIN_LENGTH} characters long")
|
||||
|
||||
# Hash the password
|
||||
password_hash, salt = self.password_security.hash_password(password)
|
||||
|
||||
# Create authentication record
|
||||
auth_record = AuthenticationRecord(
|
||||
user_id=user_id,
|
||||
password_hash=password_hash,
|
||||
salt=salt,
|
||||
last_password_change=datetime.now(timezone.utc),
|
||||
login_attempts=0
|
||||
)
|
||||
|
||||
# Store in database
|
||||
await self.database.set_authentication(user_id, auth_record.model_dump())
|
||||
|
||||
logger.info(f"🔐 Created authentication record for user {user_id}")
|
||||
return auth_record
|
||||
|
||||
async def verify_user_credentials(self, login: str, password: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
Verify user credentials with security checks
|
||||
|
||||
Args:
|
||||
login: Email or username
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, user_data, error_message)
|
||||
"""
|
||||
try:
|
||||
# Get user data
|
||||
user_data = await self.database.get_user(login)
|
||||
if not user_data:
|
||||
logger.warning(f"⚠️ Login attempt with non-existent user: {login}")
|
||||
return False, None, "Invalid credentials"
|
||||
|
||||
# Get authentication record
|
||||
auth_record = await self.database.get_authentication(user_data["id"])
|
||||
if not auth_record:
|
||||
logger.error(f"❌ No authentication record found for user {user_data['id']}")
|
||||
return False, None, "Authentication record not found"
|
||||
|
||||
auth_data = AuthenticationRecord.model_validate(auth_record)
|
||||
|
||||
# Check if account is locked
|
||||
if auth_data.locked_until and auth_data.locked_until > datetime.now(timezone.utc):
|
||||
time_until_unlock = auth_data.locked_until - datetime.now(timezone.utc)
|
||||
# Convert time_until_unlock to minutes:seconds format
|
||||
total_seconds = time_until_unlock.total_seconds()
|
||||
minutes = int(total_seconds // 60)
|
||||
seconds = int(total_seconds % 60)
|
||||
time_until_unlock_str = f"{minutes}m {seconds}s"
|
||||
logger.warning(f"🔒 Account is locked for user {login} for another {time_until_unlock_str}.")
|
||||
return False, None, f"Account is temporarily locked due to too many failed attempts. Retry after {time_until_unlock_str}"
|
||||
|
||||
# Verify password
|
||||
if not self.password_security.verify_password(password, auth_data.password_hash):
|
||||
# Increment failed attempts
|
||||
auth_data.login_attempts += 1
|
||||
|
||||
# Lock account if too many attempts
|
||||
if auth_data.login_attempts >= SecurityConfig.MAX_LOGIN_ATTEMPTS:
|
||||
auth_data.locked_until = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=SecurityConfig.ACCOUNT_LOCKOUT_DURATION_MINUTES
|
||||
)
|
||||
logger.warning(f"🔒 Account locked for user {login} after {auth_data.login_attempts} failed attempts")
|
||||
|
||||
# Update authentication record
|
||||
await self.database.set_authentication(user_data["id"], auth_data.model_dump())
|
||||
|
||||
logger.warning(f"⚠️ Invalid password for user {login} (attempt {auth_data.login_attempts})")
|
||||
return False, None, "Invalid credentials"
|
||||
|
||||
# Reset failed attempts on successful login
|
||||
if auth_data.login_attempts > 0:
|
||||
auth_data.login_attempts = 0
|
||||
auth_data.locked_until = None
|
||||
await self.database.set_authentication(user_data["id"], auth_data.model_dump())
|
||||
|
||||
logger.info(f"✅ Successful authentication for user {login}")
|
||||
return True, user_data, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"❌ Authentication error for user {login}: {e}")
|
||||
return False, None, "Authentication failed"
|
||||
|
||||
async def check_user_exists(self, email: str, username: str | None = None) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a user already exists with the given email or username
|
||||
|
||||
Args:
|
||||
email: Email address to check
|
||||
username: Username to check (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (exists, conflict_field)
|
||||
"""
|
||||
try:
|
||||
# Check email
|
||||
existing_user = await self.database.get_user(email)
|
||||
if existing_user:
|
||||
return True, "email"
|
||||
|
||||
# Check username if provided
|
||||
if username:
|
||||
existing_user = await self.database.get_user(username)
|
||||
if existing_user:
|
||||
return True, "username"
|
||||
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error checking user existence: {e}")
|
||||
# In case of error, assume user doesn't exist to avoid blocking creation
|
||||
return False, None
|
||||
|
||||
async def update_last_login(self, user_id: str):
|
||||
"""Update user's last login timestamp"""
|
||||
try:
|
||||
user_data = await self.database.get_user_by_id(user_id)
|
||||
if user_data:
|
||||
user_data["lastLogin"] = datetime.now(timezone.utc).isoformat()
|
||||
await self.database.set_user_by_id(user_id, user_data)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating last login for user {user_id}: {e}")
|
||||
|
||||
# Utility functions for common operations
|
||||
def validate_password_strength(password: str) -> Tuple[bool, list]:
|
||||
"""
|
||||
Validate password strength
|
||||
|
||||
Args:
|
||||
password: Password to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_issues)
|
||||
"""
|
||||
issues = []
|
||||
|
||||
if len(password) < SecurityConfig.PASSWORD_MIN_LENGTH:
|
||||
issues.append(f"Password must be at least {SecurityConfig.PASSWORD_MIN_LENGTH} characters long")
|
||||
|
||||
if not any(c.isupper() for c in password):
|
||||
issues.append("Password must contain at least one uppercase letter")
|
||||
|
||||
if not any(c.islower() for c in password):
|
||||
issues.append("Password must contain at least one lowercase letter")
|
||||
|
||||
if not any(c.isdigit() for c in password):
|
||||
issues.append("Password must contain at least one digit")
|
||||
|
||||
# Check for special characters
|
||||
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
if not any(c in special_chars for c in password):
|
||||
issues.append("Password must contain at least one special character")
|
||||
|
||||
return len(issues) == 0, issues
|
||||
|
||||
def sanitize_login_input(login: str) -> str:
|
||||
"""Sanitize login input (email or username)"""
|
||||
return login.strip().lower() if login else ""
|
Loading…
x
Reference in New Issue
Block a user