Files
maskanx_crm_backend/app/core/security.py
T
AFFAANhandClaude Opus 5 f6a54d6f93 Initial commit: Maskan CRM backend
Independent FastAPI backend for Maskan CRM.

Owns contacts, organizations, leads, pipelines, activities, products,
quotes, users, permissions, audit records and first-party integration
credentials, with Alembic migrations against PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:28:41 +05:30

147 lines
4.0 KiB
Python

import base64
import hashlib
import hmac
import os
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt import InvalidTokenError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.models import User
PBKDF2_ITERATIONS = 600_000
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
ROLE_LEVELS = {
"viewer": 10,
"member": 20,
"manager": 30,
"admin": 40,
"owner": 50,
}
def hash_password(password: str) -> str:
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
PBKDF2_ITERATIONS,
)
return "pbkdf2_sha256${}${}${}".format(
PBKDF2_ITERATIONS,
base64.urlsafe_b64encode(salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
def verify_password(password: str, encoded: str) -> bool:
try:
algorithm, iterations, salt_value, digest_value = encoded.split("$", 3)
if algorithm != "pbkdf2_sha256":
return False
salt = base64.urlsafe_b64decode(salt_value.encode("ascii"))
expected = base64.urlsafe_b64decode(digest_value.encode("ascii"))
actual = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
int(iterations),
)
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
return False
def create_access_token(user: User) -> str:
settings = get_settings()
now = datetime.now(UTC)
expires_at = now + timedelta(minutes=settings.access_token_minutes)
payload = {
"sub": user.id,
"tenant_id": user.tenant_id,
"role": user.role,
"iss": settings.jwt_issuer,
"aud": settings.jwt_audience,
"iat": now,
"exp": expires_at,
}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
def hash_service_key(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[Session, Depends(get_db)],
) -> User:
settings = get_settings()
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication is required.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=["HS256"],
audience=settings.jwt_audience,
issuer=settings.jwt_issuer,
)
user_id = payload.get("sub")
tenant_id = payload.get("tenant_id")
except InvalidTokenError as exc:
raise credentials_error from exc
if not user_id or not tenant_id:
raise credentials_error
user = db.scalar(
select(User).where(
User.id == user_id,
User.tenant_id == tenant_id,
User.is_active.is_(True),
),
)
if user is None:
raise credentials_error
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
Database = Annotated[Session, Depends(get_db)]
def require_role(minimum_role: str) -> Callable[[CurrentUser], User]:
minimum_level = ROLE_LEVELS[minimum_role]
def dependency(user: CurrentUser) -> User:
if ROLE_LEVELS.get(user.role, 0) < minimum_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to perform this action.",
)
return user
return dependency
Writer = Annotated[User, Depends(require_role("member"))]
Manager = Annotated[User, Depends(require_role("manager"))]
Admin = Annotated[User, Depends(require_role("admin"))]