541 lines
18 KiB
Python
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") |