Files
docqube_backend/app/modules/auth/routes/auth_routes.py
T
2026-09-08 18:32:55 +05:30

541 lines
18 KiB
Python

from fastapi import APIRouter, Depends, Response, Request, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.modules.auth.controllers.auth_controller import AuthController, UserController
from app.modules.auth.repositories.user_repository import UserRepository
from app.modules.auth.schemas.auth_schema import (
RegisterIn,
Token,
UserOut,
GoogleLoginIn,
UpdateProfileIn,
ChangePasswordIn,
RegisterOut,
ForgotPasswordIn,
ResetPasswordIn,
)
from app.middleware.auth import get_current_user, oauth2_scheme
from app.core.token_blacklist import TokenBlacklist
from app.modules.auth.models.user_model import User
from app.modules.tenant.models.tenant_model import Tenant
from app.middleware.tenant import get_tenant_from_header
from app.core.settings import settings
from jose import jwt
from app.modules.auth.schemas.access_schema import EditorTokenOut
from app.core.schemas import MessageOut, StatusMessageOut
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/register", response_model=RegisterOut)
def register(
request: Request,
payload: RegisterIn,
db: Session = Depends(get_db),
tenant: Tenant = Depends(get_tenant_from_header),
):
return AuthController.register_user(payload, tenant, db)
@router.post("/login", response_model=Token)
def login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
token_data = AuthController.login_user(form_data, db, request)
is_secure = settings.APP_ENV in ["production", "test", "testing"] or request.url.scheme == "https"
samesite_mode = "none" if is_secure else "lax"
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
response.set_cookie(
key="docqube_access_token",
value=token_data["access_token"],
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=3600,
path="/",
)
response.set_cookie(
key="docqube_refresh_token",
value=token_data["refresh_token"],
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=7 * 24 * 3600,
path="/api/auth/refresh",
)
response.set_cookie(
key="docqube_has_session",
value="true",
httponly=False,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=7 * 24 * 3600,
path="/",
)
response.set_cookie(
key="csrf_token",
value=str(uuid.uuid4()),
httponly=False,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
path="/"
)
if hasattr(request.state, "new_device_id"):
response.set_cookie(
key="docqube_device_id",
value=request.state.new_device_id,
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=365 * 24 * 3600,
path="/",
)
return token_data
@router.post("/google", response_model=Token)
def google_login(
request: Request, response: Response, payload: GoogleLoginIn, db: Session = Depends(get_db)
):
token_data = AuthController.google_login(payload, db, request)
is_secure = settings.APP_ENV in ["production", "test", "testing"] or request.url.scheme == "https"
samesite_mode = "none" if is_secure else "lax"
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
response.set_cookie(
key="docqube_access_token",
value=token_data["access_token"],
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=3600,
path="/",
)
response.set_cookie(
key="docqube_refresh_token",
value=token_data["refresh_token"],
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=7 * 24 * 3600,
path="/api/auth/refresh",
)
response.set_cookie(
key="docqube_has_session",
value="true",
httponly=False,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=7 * 24 * 3600,
path="/",
)
response.set_cookie(
key="csrf_token",
value=str(uuid.uuid4()),
httponly=False,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
path="/"
)
if hasattr(request.state, "new_device_id"):
response.set_cookie(
key="docqube_device_id",
value=request.state.new_device_id,
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=365 * 24 * 3600,
path="/",
)
return token_data
@router.post("/forgot-password", response_model=MessageOut)
def forgot_password(
request: Request, payload: ForgotPasswordIn, db: Session = Depends(get_db)
):
return AuthController.forgot_password(payload, db)
@router.post("/reset-password")
def reset_password(
request: Request, payload: ResetPasswordIn, db: Session = Depends(get_db)
):
return AuthController.reset_password(payload, db)
@router.get("/me", response_model=UserOut)
@router.get("/verify", response_model=UserOut)
def verify_auth(user: User = Depends(get_current_user)):
return user
@router.post("/accept-terms", response_model=StatusMessageOut)
def accept_terms(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if not user.terms_accepted:
user.terms_accepted = True
return {"status": "success", "message": "Terms accepted successfully"}
@router.post("/refresh", response_model=Token)
def refresh_token(request: Request, response: Response, db: Session = Depends(get_db)):
refresh_token = request.cookies.get("docqube_refresh_token")
if not refresh_token:
raise HTTPException(status_code=401, detail="Refresh token missing")
try:
payload = jwt.decode(
refresh_token, settings.APP_SECRET, algorithms=[settings.ALGORITHM]
)
user_id = payload.get("sub")
token_type = payload.get("type")
if not user_id or token_type != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
session_id = payload.get("session_id")
if session_id:
from app.modules.auth.repositories.session_repository import SessionRepository
from app.core.security import verify_password
session_repo = SessionRepository(db)
session = session_repo.get_by_id(session_id)
if not session or session.status != "ACTIVE":
if session and session.status == "REVOKED":
if session.revoked_by == "reauth":
raise HTTPException(status_code=401, detail="You have logged in from another tab on this device. Please refresh.")
else:
raise HTTPException(status_code=401, detail="Your session was remotely logged out from another device.")
raise HTTPException(status_code=401, detail="Your session has expired.")
if not session.refresh_token_hash or session.refresh_token_hash == "pending" or not verify_password(refresh_token, session.refresh_token_hash):
raise HTTPException(status_code=401, detail="Invalid refresh token hash")
from datetime import timedelta, datetime, timezone
now = datetime.now(timezone.utc)
last_activity = session.last_activity
if last_activity and last_activity.tzinfo is None:
last_activity = last_activity.replace(tzinfo=timezone.utc)
if not last_activity or now - last_activity > timedelta(minutes=5):
session_repo.update_last_activity(session)
user_repo = UserRepository(db)
user = user_repo.get_by_id(int(user_id))
if not user or user.is_deleted or (hasattr(user, "is_active") and not user.is_active):
raise HTTPException(status_code=401, detail="User not found")
from app.core.security import create_access_token
payload_access = {
"sub": str(user.id),
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
}
if session_id:
payload_access["session_id"] = session_id
new_access_token = create_access_token(payload_access)
is_secure = settings.APP_ENV in ["production", "test", "testing"] or (request and request.url.scheme == "https")
samesite_mode = "none" if is_secure else "lax"
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
response.set_cookie(
key="docqube_access_token",
value=new_access_token,
httponly=True,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=3600,
path="/",
)
response.set_cookie(
key="docqube_has_session",
value="true",
httponly=False,
secure=is_secure,
samesite=samesite_mode,
domain=cookie_domain,
max_age=7 * 24 * 3600,
path="/",
)
return {
"access_token": new_access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
}
except HTTPException as he:
raise he
except Exception as e:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
@router.get("/editor-token", response_model=EditorTokenOut)
def get_editor_token(current_user: User = Depends(get_current_user)):
from app.core.security import create_access_token
token = create_access_token(
{
"sub": str(current_user.id),
"tenant_id": str(current_user.tenant_id) if current_user.tenant_id else None,
}
)
return {"token": token}
@router.post("/logout")
def logout(
response: Response,
token: str = Depends(oauth2_scheme),
request: Request = None,
db: Session = Depends(get_db)
):
auth_token = token
if not auth_token and request:
auth_token = request.cookies.get("docqube_access_token")
if auth_token:
try:
unverified_payload = jwt.get_unverified_claims(auth_token)
jti = unverified_payload.get("jti")
exp = unverified_payload.get("exp")
if jti:
ttl = 3600
if exp:
from datetime import datetime
now = datetime.utcnow().timestamp()
ttl = max(1, int(exp - now))
TokenBlacklist.add(jti, expires_in=ttl)
session_id = unverified_payload.get("session_id")
if session_id:
from app.modules.auth.services.auth_service import AuthService
AuthService(db).logout(session_id)
except Exception:
pass
is_secure = settings.APP_ENV in ["production", "test", "testing"] or (request and request.url.scheme == "https")
samesite_mode = "none" if is_secure else "lax"
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
response.delete_cookie(
"docqube_access_token",
path="/",
samesite=samesite_mode,
secure=is_secure,
domain=cookie_domain,
)
response.delete_cookie(
"docqube_refresh_token",
path="/api/auth/refresh",
samesite=samesite_mode,
secure=is_secure,
domain=cookie_domain,
)
response.delete_cookie(
"docqube_has_session",
path="/",
samesite=samesite_mode,
secure=is_secure,
domain=cookie_domain,
)
response.delete_cookie(
"csrf_token",
path="/",
samesite=samesite_mode,
secure=is_secure,
domain=cookie_domain,
)
return {"status": "success", "message": "Logged out"}
from pydantic import BaseModel
class ResolveDeviceLimitIn(BaseModel):
logout_device_id: int
@router.post("/resolve-device-limit")
def resolve_device_limit(
payload: ResolveDeviceLimitIn,
request: Request,
db: Session = Depends(get_db)
):
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Authentication required")
token = auth_header.split(" ")[1]
try:
payload_jwt = jwt.decode(token, settings.APP_SECRET, algorithms=[settings.ALGORITHM])
jti = payload_jwt.get("jti")
if jti and TokenBlacklist.is_blacklisted(jti):
raise HTTPException(status_code=401, detail="Token blacklisted")
except HTTPException as he:
raise he
except Exception:
raise HTTPException(status_code=401, detail="Invalid token")
if payload_jwt.get("type") != "device_management":
raise HTTPException(status_code=403, detail="Invalid token type")
user_id = int(payload_jwt.get("sub"))
from app.modules.auth.repositories.device_repository import DeviceRepository
from app.modules.auth.repositories.session_repository import SessionRepository
device_repo = DeviceRepository(db)
device = device_repo.get_by_id(payload.logout_device_id)
if not device or device.user_id != user_id:
raise HTTPException(status_code=404, detail="Device not found")
session_repo = SessionRepository(db)
session_repo.revoke_all_for_device(device.id, by="user")
return {"status": "success", "message": "Device logged out successfully. You can now login."}
from typing import List
from app.modules.auth.schemas.auth_schema import DeviceOut
@router.get("/devices", response_model=List[DeviceOut])
def get_user_devices(
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
from app.modules.auth.repositories.device_repository import DeviceRepository
from app.modules.auth.utils.device_utils import generate_device_fingerprint
device_repo = DeviceRepository(db)
devices = device_repo.get_user_devices(current_user.id)
current_fingerprint = generate_device_fingerprint(request)
results = []
for d in devices:
results.append({
"id": d.id,
"browser": d.browser,
"os": d.os,
"device_type": d.device_type,
"city": d.city,
"country": d.country,
"state": d.state,
"last_login": d.last_login,
"is_current_device": d.fingerprint == current_fingerprint
})
results.sort(key=lambda x: not x["is_current_device"])
return results
@router.delete("/devices/{device_id}")
def logout_device(
device_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
from app.modules.auth.repositories.device_repository import DeviceRepository
from app.modules.auth.repositories.session_repository import SessionRepository
device_repo = DeviceRepository(db)
device = device_repo.get_by_id(device_id)
if not device or device.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Device not found")
session_repo = SessionRepository(db)
session_repo.revoke_all_for_device(device.id, by="user")
try:
from app.modules.activity_logs.service import log_event
from app.modules.activity_logs.constants import ActivityLogModule, ActivityLogAction, ActivityLogStatus, ActivityLogTargetType
log_event(
tenant_id=current_user.tenant_id,
user_id=current_user.id,
user_email=current_user.email,
module=ActivityLogModule.TENANT,
action=ActivityLogAction.DEVICE_LOGOUT,
target_id=str(device.id),
target_type=ActivityLogTargetType.SESSION,
metadata={
"device_name": device.device_name,
"ip_address": device.ip_address,
},
status=ActivityLogStatus.SUCCESS
)
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Failed to log device logout: {e}")
return {"status": "success", "message": "Logged out from device successfully"}
from fastapi.responses import StreamingResponse
import asyncio
@router.get("/session/events")
async def session_events(
request: Request,
current_user: User = Depends(get_current_user)
):
"""
SSE Endpoint for real-time session and device revocation events.
Frontend can listen to this to instantly log out when their session is revoked.
"""
from app.core.redis import redis_pubsub
import json
async def event_generator():
if not redis_pubsub._client:
await redis_pubsub.connect()
if not redis_pubsub._client:
return
pubsub = redis_pubsub._client.pubsub()
channel = f"user_events:{current_user.id}"
await pubsub.subscribe(channel)
try:
while True:
if await request.is_disconnected():
break
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
if message and message["type"] == "message":
data = message["data"]
if isinstance(data, bytes):
data = data.decode('utf-8')
yield f"data: {data}\n\n"
await asyncio.sleep(0.1)
except asyncio.CancelledError:
pass
except Exception:
pass
finally:
try:
await pubsub.unsubscribe(channel)
await pubsub.aclose()
except Exception:
pass
return StreamingResponse(event_generator(), media_type="text/event-stream")