Files
ThreatHunt/backend/app/services/auth.py
mblanke 9b98ab9614 feat: interactive network map, IOC highlighting, AUP hunt selector, type filters
- NetworkMap: hunt-scoped force-directed graph with click-to-inspect popover
- NetworkMap: zoom/pan (wheel, drag, buttons), viewport transform
- NetworkMap: clickable IP/Host/Domain/URL legend chips to filter node types
- NetworkMap: brighter colors, 20% smaller nodes
- DatasetViewer: IOC columns highlighted with colored headers + cell tinting
- AUPScanner: hunt dropdown replacing dataset checkboxes, auto-select all
- Rename 'Social Media (Personal)' theme to 'Social Media' with DB migration
- Fix /api/hunts timeout: Dataset.rows lazy='noload' (was selectin cascade)
- Add OS column mapping to normalizer
- Full backend services, DB models, alembic migrations, new routes
- New components: Dashboard, HuntManager, FileUpload, NetworkMap, etc.
- Docker Compose deployment with nginx reverse proxy
2026-02-19 15:41:15 -05:00

202 lines
6.0 KiB
Python

"""Authentication & security — JWT tokens, password hashing, role-based access.
Provides:
- Password hashing (bcrypt via passlib)
- JWT access/refresh token creation and verification
- FastAPI dependency for protecting routes
- Role-based enforcement (analyst, admin, viewer)
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_db
from app.db.models import User
logger = logging.getLogger(__name__)
# ── Password hashing ─────────────────────────────────────────────────
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# ── JWT tokens ────────────────────────────────────────────────────────
ALGORITHM = "HS256"
security = HTTPBearer(auto_error=False)
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int # seconds
class TokenPayload(BaseModel):
sub: str # user_id
role: str
exp: datetime
type: str # "access" or "refresh"
def create_access_token(user_id: str, role: str) -> str:
expires = datetime.now(timezone.utc) + timedelta(
minutes=settings.JWT_ACCESS_TOKEN_MINUTES
)
payload = {
"sub": user_id,
"role": role,
"exp": expires,
"type": "access",
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=ALGORITHM)
def create_refresh_token(user_id: str, role: str) -> str:
expires = datetime.now(timezone.utc) + timedelta(
days=settings.JWT_REFRESH_TOKEN_DAYS
)
payload = {
"sub": user_id,
"role": role,
"exp": expires,
"type": "refresh",
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=ALGORITHM)
def create_token_pair(user_id: str, role: str) -> TokenPair:
return TokenPair(
access_token=create_access_token(user_id, role),
refresh_token=create_refresh_token(user_id, role),
expires_in=settings.JWT_ACCESS_TOKEN_MINUTES * 60,
)
def decode_token(token: str) -> TokenPayload:
"""Decode and validate a JWT token."""
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[ALGORITHM])
return TokenPayload(**payload)
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {e}",
headers={"WWW-Authenticate": "Bearer"},
)
# ── FastAPI dependencies ──────────────────────────────────────────────
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""Extract and validate the current user from JWT.
When AUTH is disabled (no JWT secret configured), returns a default analyst user.
"""
# If auth is disabled (dev mode), return a default user
if settings.JWT_SECRET == "CHANGE-ME-IN-PRODUCTION-USE-A-REAL-SECRET":
return User(
id="dev-user",
username="analyst",
email="analyst@local",
role="analyst",
display_name="Dev Analyst",
)
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
token_data = decode_token(credentials.credentials)
if token_data.type != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type — use access token",
)
result = await db.execute(select(User).where(User.id == token_data.sub))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
return user
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db),
) -> Optional[User]:
"""Like get_current_user, but returns None instead of raising if no token."""
if not credentials:
if settings.JWT_SECRET == "CHANGE-ME-IN-PRODUCTION-USE-A-REAL-SECRET":
return User(
id="dev-user",
username="analyst",
email="analyst@local",
role="analyst",
display_name="Dev Analyst",
)
return None
try:
return await get_current_user(credentials, db)
except HTTPException:
return None
def require_role(*roles: str):
"""Dependency factory that requires the current user to have one of the specified roles."""
async def _check(user: User = Depends(get_current_user)) -> User:
if user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of roles: {', '.join(roles)}. You have: {user.role}",
)
return user
return _check
# Convenience dependencies
require_analyst = require_role("analyst", "admin")
require_admin = require_role("admin")