Files
saas_backend/app/config/security.py
T

176 lines
6.4 KiB
Python

from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import bcrypt
import jwt
from fastapi import HTTPException, status
import re
import secrets
import string
from app.config.settings import settings
class SecurityUtils:
"""Security utility class for authentication and authorization."""
@staticmethod
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
salt = bcrypt.gensalt(rounds=settings.BCRYPT_ROUNDS)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
@staticmethod
def generate_access_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
"""Generate JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
to_encode.update({"exp": expire, "type": "access"})
# Include tenant_id if provided
if tenant_id:
to_encode["tenant_id"] = str(tenant_id)
return jwt.encode(
to_encode,
settings.ACCESS_TOKEN_SECRET,
algorithm="HS256"
)
@staticmethod
def generate_refresh_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
"""Generate JWT refresh token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
to_encode.update({"exp": expire, "type": "refresh"})
# Include tenant_id if provided
if tenant_id:
to_encode["tenant_id"] = str(tenant_id)
return jwt.encode(
to_encode,
settings.REFRESH_TOKEN_SECRET,
algorithm="HS256"
)
@staticmethod
def verify_access_token(token: str) -> Dict[str, Any]:
"""Verify and decode JWT access token."""
try:
payload = jwt.decode(
token,
settings.ACCESS_TOKEN_SECRET,
algorithms=["HS256"]
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token has expired"
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token"
)
@staticmethod
def verify_refresh_token(token: str) -> Dict[str, Any]:
"""Verify and decode JWT refresh token."""
try:
payload = jwt.decode(
token,
settings.REFRESH_TOKEN_SECRET,
algorithms=["HS256"]
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token has expired"
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
@staticmethod
def generate_otp(length: int = 6) -> str:
"""Generate a random OTP."""
return ''.join(secrets.choice(string.digits) for _ in range(length))
@staticmethod
def validate_password_strength(password: str) -> bool:
"""Validate password strength."""
if len(password) < 8:
return False
if not re.search(r'[A-Z]', password):
return False
if not re.search(r'[a-z]', password):
return False
if not re.search(r'\d', password):
return False
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
return False
return True
@staticmethod
def generate_module_token(data: Dict[str, Any], module_id: str, ttl_seconds: int = 900) -> str:
"""Generate short-lived module-scoped JWT (15 min default)."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
to_encode.update({
"exp": expire,
"type": "module_access",
"aud": str(module_id),
"iat": datetime.now(timezone.utc).timestamp()
})
if not settings.SAAS_PRIVATE_KEY:
raise ValueError("SAAS_PRIVATE_KEY is not configured. Cannot sign module identity tokens.")
return jwt.encode(
to_encode,
settings.SAAS_PRIVATE_KEY,
algorithm="RS256",
headers={"kid": settings.SAAS_KEY_ID}
)
@staticmethod
def validate_email(email: str) -> bool:
"""Validate email format."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
@staticmethod
def validate_ip_address(ip: str) -> bool:
"""Validate IP address format (IPv4 and IPv6)."""
ipv4_pattern = r'^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$'
ipv6_pattern = r'^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$'
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
security = SecurityUtils()