mirror of
https://github.com/reconurge/flowsint.git
synced 2026-04-28 10:22:58 -05:00
feat: auth on fast api
This commit is contained in:
143
flowsint-api/app/core/auth.py
Normal file
143
flowsint-api/app/core/auth.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,4 +6,7 @@ redis==5.0.1
|
||||
holehe
|
||||
dotenv
|
||||
uvicorn
|
||||
sherlock_project
|
||||
sherlock_project
|
||||
python-jose[cryptography]
|
||||
python-dotenv
|
||||
requests
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
26
flowsint-web/src/hooks/use-launch-scan.ts
Normal file
26
flowsint-web/src/hooks/use-launch-scan.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user