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>
147 lines
4.0 KiB
Python
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"))]
|
|
|