feat: auth on fast api

This commit is contained in:
dextmorgn
2025-04-16 17:33:57 +02:00
parent c9f7ed6b18
commit f9142159e0
13 changed files with 236 additions and 101 deletions

View File

@@ -0,0 +1,143 @@
import os
import requests
from typing import Dict, Any, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from dotenv import load_dotenv
import json
from functools import lru_cache
load_dotenv()
# Chargement des variables d'environnement
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_JWKS_URL = f"{SUPABASE_URL}/auth/v1/jwks" # URL correcte pour les JWKs
SUPABASE_ISSUER = f"{SUPABASE_URL}/auth/v1"
SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET")
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
http_bearer = HTTPBearer(auto_error=True)
@lru_cache(maxsize=1)
def get_jwks() -> Dict[str, Any]:
"""Récupère les clés JWKS depuis Supabase avec mise en cache."""
try:
headers = {"apikey": SUPABASE_KEY} if SUPABASE_KEY else {}
response = requests.get(SUPABASE_JWKS_URL, headers=headers)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
# En production, évitez de révéler trop de détails sur l'erreur
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not fetch JWKS"
)
def get_key_from_jwks(token: str) -> Optional[Dict[str, Any]]:
"""Récupère la clé appropriée depuis JWKS en fonction du kid dans le header du JWT."""
try:
# Récupérer le header sans vérifier la signature
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if not kid:
return None
# Récupérer les clés JWKS
jwks = get_jwks()
for key in jwks.get("keys", []):
if key.get("kid") == kid:
return key
return None
except Exception:
return None
def verify_token(token: str) -> Dict[str, Any]:
"""
Vérifie un token JWT de Supabase.
Essaie d'abord la validation avec JWK (RS256) puis avec secret partagé (HS256).
"""
# Vérification des entrées
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication token"
)
try:
# Première tentative: vérification avec JWKS (RS256)
try:
jwk = get_key_from_jwks(token)
if jwk:
# Si une clé JWK est trouvée, nous utilisons RSA
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
decoded = jwt.decode(
token,
key=public_key,
algorithms=["RS256"],
audience="authenticated",
issuer=SUPABASE_ISSUER,
)
return decoded
except (jwt.JWTError, AttributeError):
# Échec avec RS256, on essaie avec HS256
pass
if SUPABASE_JWT_SECRET:
decoded = jwt.decode(
token,
SUPABASE_JWT_SECRET,
algorithms=["HS256"],
audience="authenticated",
issuer=SUPABASE_ISSUER,
)
return decoded
# Si on arrive ici, aucune méthode n'a fonctionné
raise JWTError("Token validation failed")
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired"
)
except jwt.JWTClaimsError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid claims (audience or issuer)"
)
except (jwt.JWTError, JWTError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token"
)
except Exception as e:
# Fallback général
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Authentication error: {str(e)}"
)
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(http_bearer)
) -> Dict[str, Any]:
"""
Dependency pour obtenir l'utilisateur courant à partir du token d'authentification.
Usage: user = Depends(get_current_user)
"""
token = credentials.credentials
return verify_token(token)
# Fonction utilitaire pour extraire les informations utilisateur du token JWT
def get_user_info(token_data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrait les informations utilisateur du token décodé."""
user_info = {
"user_id": token_data.get("sub"),
"email": token_data.get("email"),
"role": token_data.get("role", ""),
"app_metadata": token_data.get("app_metadata", {}),
"user_metadata": token_data.get("user_metadata", {})
}
return user_info

View File

@@ -11,6 +11,9 @@ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
if not SUPABASE_URL or not SUPABASE_KEY:
raise ValueError("Missing Supabase credentials")
# Création du client Supabase
def get_db() -> Client:
return create_client(SUPABASE_URL, SUPABASE_KEY)
def get_db(token=None):
supabase_url = os.getenv("SUPABASE_URL")
supabase_anon_key = os.getenv("SUPABASE_KEY")
supabase = create_client(supabase_url, supabase_anon_key)
return supabase

View File

@@ -1,7 +1,9 @@
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from app.core.celery import celery_app
from app.scanners.registry import ScannerRegistry
from app.core.auth import get_current_user
app = FastAPI()
@@ -11,22 +13,23 @@ class ScanRequest(BaseModel):
scanner: str
@app.post("/scan")
async def scan(item: ScanRequest):
async def scan(
item: ScanRequest,
user=Depends(get_current_user)
):
try:
if not item.value or not item.sketch_id or not item.scanner:
raise HTTPException(status_code=400, detail="Missing required fields")
if not ScannerRegistry.scanner_exists(name=item.scanner):
raise HTTPException(status_code=400, detail="Scanner not found")
user_id = user.get("sub")
task = celery_app.send_task("run_scan", args=[item.scanner, item.value, item.sketch_id])
print(user_id)
return {"id": task.id}
except HTTPException as e:
raise e
except HTTPException:
raise
except Exception as e:
print(e)
raise HTTPException(status_code=500, detail=f"Server error: {str(e)}")
@app.get("/health")
async def health():
return {"status": "ok"}
return {"status": "ok"}

View File

@@ -1,7 +1,5 @@
# app/scanners/base.py
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from supabase import Client
class Scanner(ABC):
def __init__(self, scan_id: str):
@@ -22,20 +20,7 @@ class Scanner(ABC):
def postprocess(self, results: Dict[str, Any]) -> Dict[str, Any]:
return results
def execute(self, value: str, db: Optional[Client] = None, sketch_id: Optional[str] = None) -> Dict[str, Any]:
try:
if db and sketch_id:
db.table("scans").insert({
"id": self.scan_id,
"status": "pending",
"scan_name": self.name,
"value": value,
"sketch_id": sketch_id,
"results": []
}).execute()
except Exception as e:
raise e
def execute(self, value: str) -> Dict[str, Any]:
preprocessed = self.preprocess(value)
results = self.scan(preprocessed)
results = self.postprocess(results)

View File

@@ -7,8 +7,16 @@ from app.core.db import get_db
def run_scan(self, scanner_name: str, value: str, sketch_id: str):
db=get_db()
try:
db.table("scans").insert({
"id": self.request.id,
"status": "pending",
"scan_name": scanner_name,
"value": value,
"sketch_id": sketch_id,
"results": []
}).execute()
scanner = ScannerRegistry.get_scanner(scanner_name, self.request.id)
results = scanner.execute(value, db=db, sketch_id=sketch_id)
results = scanner.execute(value)
# status = "finished" if "error" not in results else "error"
db.table("scans").update({
"status": "finished",
@@ -17,7 +25,6 @@ def run_scan(self, scanner_name: str, value: str, sketch_id: str):
return {"result": results}
except Exception as ex:
# error_logs = traceback.format_exc().split("\n")
error_logs= "an error occured"
print(f"Error in task: {error_logs}")
db.table("scans").update({

View File

@@ -6,7 +6,7 @@ if [ "$1" = "app" ]; then
exec uvicorn app.main:app --host 0.0.0.0 --port 5000
elif [ "$1" = "celery" ]; then
echo "Démarrage de Celery..."
exec celery -A app.core.celery worker --loglevel=debug
exec celery -A app.core.celery worker --loglevel=info
else
exec "$@"
fi

View File

@@ -6,4 +6,7 @@ redis==5.0.1
holehe
dotenv
uvicorn
sherlock_project
sherlock_project
python-jose[cryptography]
python-dotenv
requests

View File

@@ -17,10 +17,9 @@ import NewInvestigation from "@/components/dashboard/new-investigation"
import { AvatarList } from "@/components/avatar-list"
import { cn } from "@/lib/utils"
import { SubNav } from "@/components/dashboard/sub-nav"
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Card } from "@/components/ui/card"
import StatusBadge from "@/components/investigations/status-badge"
import { InvestigationGraph } from "@/components/dashboard/charts/investigation-chart"
import { Badge } from "@/components/ui/badge"
import { MetricsChart } from "@/components/dashboard/charts/metrics-chart"
import { SectionCards } from "@/components/investigations/section-cards"
import { DateRangePicker } from "@/components/date-range-picker"

View File

@@ -27,6 +27,7 @@ import { NodeNotesEditor } from "./node-notes-editor"
import { performSearch } from "@/lib/actions/search"
import { nodesTypes } from "@/lib/utils"
import { actionItems, type ActionItem } from "@/lib/action-items"
import { useLaunchSan } from "@/hooks/use-launch-scan"
// Node Context Menu component
interface NodeContextMenuProps {
@@ -46,29 +47,7 @@ const NodeContextMenu = memo(({ x, y, onClose }: NodeContextMenuProps) => {
const [nodeToInsert, setNodeToInsert] = useState<any | null>(null)
const { confirm } = useConfirm()
const [_, setIndividualId] = useQueryState("individual_id")
const handleCheckEmail = useCallback(async () => {
// @ts-ignore
if (!currentNode && currentNode?.data && !currentNode?.data?.email) return toast.error("No email found.")
if (!await confirm({ title: "Email scan", message: "This scan will look for some socials that the email might be associated with. The list is not exhaustive and might return false positives." })) return
// @ts-ignore
toast.promise(performSearch(currentNode?.data?.email, 'email', sketch_id), {
loading: "Loading...",
success: () => {
return `Scan on ${currentNode?.data?.email} has been launched.`
},
error: (error: any) => {
return (
<div className="overflow-hidden">
<p className="font-bold">An error occured.</p>
<pre className="overflow-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</div>
)
},
})
}, [currentNode, sketch_id])
const { launchScan } = useLaunchSan()
const handleDuplicateNode = async () => {
if (!currentNode) return
@@ -296,7 +275,10 @@ const NodeContextMenu = memo(({ x, y, onClose }: NodeContextMenuProps) => {
style={{ top: y, left: x }}
>
{Boolean(currentNode?.data?.email) && (
<DropdownMenuItem onClick={handleCheckEmail}>Search {nodeToInsert}</DropdownMenuItem>
<DropdownMenuItem onClick={() => launchScan("email", currentNode?.data?.email as string, sketch_id as string)}>Search {nodeToInsert}</DropdownMenuItem>
)}
{Boolean(currentNode?.data?.username) && (
<DropdownMenuItem onClick={() => launchScan("username", currentNode?.data?.username as string, sketch_id as string)}>Search {nodeToInsert}</DropdownMenuItem>
)}
{["individual", "organization"].includes(currentNode?.data?.type as string) && (
<DropdownMenuSub>

View File

@@ -2,49 +2,21 @@
import { MoreHorizontalIcon, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useParams } from "next/navigation"
import { memo, useCallback } from "react"
import { memo } from "react"
import { cn } from "@/lib/utils"
import { CopyButton } from "@/components/copy"
import { toast } from "sonner"
import { performSearch } from "@/lib/actions/search"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useConfirm } from "@/components/use-confirm-dialog"
import Link from "next/link"
import { task_names } from "@/lib/utils";
import { useLaunchSan } from "@/hooks/use-launch-scan"
export default function ProfilePanel({ data }: { data: any }) {
const { sketch_id, investigation_id } = useParams()
const { confirm } = useConfirm()
const handlePerformCheck = useCallback(async (scan_name: string, item: string) => {
// @ts-ignore
if (!data && item) return toast.error(`No ${item} found.`)
if (!task_names.includes(scan_name)) return toast.error(`Scan "${scan_name}" doesn't exist.`)
if (!await confirm({ title: `Scanning "${item}"`, message: "This scan will look for some socials that this item might be associated with. The list is not exhaustive and might return false positives." })) return
// @ts-ignore
toast.promise(performSearch(item, scan_name, sketch_id), {
loading: "Loading...",
success: () => {
return `Scan on ${item} has been launched.`
},
error: (error: any) => {
return (
<div className="overflow-hidden">
<p className="font-bold">An error occured.</p>
<pre className="overflow-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</div>
)
},
})
}, [data, sketch_id])
const { launchScan } = useLaunchSan()
return (
<div className=" overflow-y-auto overflow-x-hidden h-full">
<div className="flex items-center sticky bg-card top-0 border-b justify-between px-4 py-2 gap-2 z-50">
@@ -64,7 +36,7 @@ export default function ProfilePanel({ data }: { data: any }) {
</Link>
: data?.type === "email" ?
<Button
onClick={() => handlePerformCheck("email", data?.email)}
onClick={() => launchScan("email", data?.email, sketch_id as string)}
disabled={data?.type !== "email"}
className="relative min-w-[80px] h-8 overflow-hidden truncate bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary transition-all duration-300 px-6 py-2 text-white border-none font-medium rounded-full"
>
@@ -76,7 +48,7 @@ export default function ProfilePanel({ data }: { data: any }) {
</Button>
: data?.type === "social" ?
<Button
onClick={() => handlePerformCheck("username", data?.username)}
onClick={() => launchScan("username", data?.username, sketch_id as string)}
disabled={!data?.username}
className="relative min-w-[80px] h-8 overflow-hidden truncate bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary transition-all duration-300 px-6 py-2 text-white border-none font-medium rounded-full"
>

View File

@@ -0,0 +1,26 @@
"use client"
import { toast } from "sonner"
import { performSearch } from "@/lib/actions/search"
import { useConfirm } from "@/components/use-confirm-dialog"
import { scans } from "@/lib/utils"
export function useLaunchSan() {
const { confirm } = useConfirm()
const launchScan = async (scan_name: string, value: string, sketch_id: string) => {
if (!value || !scans.find((s) => s.name === scan_name)) {
return toast.error("The item you want to search for was not found.")
}
const confirmed = await confirm({
title: `${scan_name} scan`,
message: "This scan will look for some socials that the email might be associated with. The list is not exhaustive and might return false positives.",
})
if (!confirmed) return
toast.promise(performSearch(value, scan_name, sketch_id), {
loading: "Loading...",
success: () => `Scan on "${value}" has been launched.`,
error: (error: any) => 'An error occurred.'
})
}
return {
launchScan,
}
}

View File

@@ -1,19 +1,28 @@
'use server'
import { createClient } from "@/lib/supabase/server";
import { task_names } from "@/lib/utils";
export async function performSearch(value: string, task_name: string, sketch_id: string) {
if (!task_names.includes(task_name))
return { error: `Task name "${task_name}" not found.` }
const url = `${process.env.NEXT_PUBLIC_DOCKER_FLOWSINT_API}/scan/${task_name}`;
import { scans } from "@/lib/utils";
export async function performSearch(value: string, scan_name: string, sketch_id: string) {
const supabase = await createClient()
await supabase.auth.refreshSession()
const { data: { session } } = await supabase.auth.getSession();
const jwt = session?.access_token;
console.log(jwt)
const scan = scans.find((s) => s.name === scan_name)
if (!scan)
return { error: `Task name "${scan_name}" not found.` }
const url = `${process.env.NEXT_PUBLIC_DOCKER_FLOWSINT_API}/scan`;
const body = JSON.stringify({
scanner: scan.scan_name,
sketch_id: sketch_id,
value: value
})
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
[task_name]: value,
"sketch_id": sketch_id
})
body
},
);
const resp = await response.json()

View File

@@ -513,4 +513,7 @@ export function encodedRedirect(
return redirect(`${path}?${type}=${encodeURIComponent(message)}`);
}
export const task_names = ["email", "username"]
export const scans = [
{ name: "email", scan_name: "holehe_scanner" },
{ name: "username", scan_name: "sherlock_scanner" }
]