mirror of
https://github.com/StanGirard/quivr.git
synced 2024-09-11 13:15:41 +03:00
feat(auth): now application has authentication (#144)
* feat(auth): backend authentification verification * feat(auth): added to all endpoints * feat(auth): added to all endpoints * feat(auth): redirect if not connected * chore(print): removed * feat(login): redirect * feat(icon): added * chore(yarn): removed lock * chore(gitignore): removed
This commit is contained in:
parent
9aa16fe6fc
commit
327074c5d4
@ -1,4 +1,6 @@
|
||||
SUPABASE_URL="XXXXX"
|
||||
SUPABASE_SERVICE_KEY="eyXXXXX"
|
||||
OPENAI_API_KEY="sk-XXXXXX"
|
||||
ANTHROPIC_API_KEY="XXXXXX"
|
||||
ANTHROPIC_API_KEY="XXXXXX"
|
||||
JWT_SECRET_KEY="Found in Supabase settings in the API tab"
|
||||
AUTHENTICATE="true"
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -49,3 +49,4 @@ streamlit-demo/.streamlit/secrets.toml
|
||||
.backend_env
|
||||
.frontend_env
|
||||
backend/pandoc-*
|
||||
**/yarn.lock
|
@ -88,6 +88,7 @@ cp .frontend_env.example frontend/.env
|
||||
- **Step 3**: Update the `backend/.env` and `frontend/.env` file
|
||||
|
||||
> _Your `supabase_service_key` can be found in your Supabase dashboard under Project Settings -> API. Use the `anon` `public` key found in the `Project API keys` section._
|
||||
> _Your `JWT_SECRET_KEY`can be found in your supabase settings under Project Settings -> JWT Settings -> JWT Secret_
|
||||
|
||||
- **Step 4**: Run the following migration scripts on the Supabase database via the web interface (SQL Editor -> `New query`)
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Header, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import os
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Tuple
|
||||
from typing import List, Tuple, Annotated
|
||||
from supabase import create_client, Client
|
||||
from tempfile import SpooledTemporaryFile
|
||||
from auth_bearer import JWTBearer
|
||||
import shutil
|
||||
import pypandoc
|
||||
|
||||
@ -87,13 +89,15 @@ async def filter_file(file: UploadFile, enable_summarization: bool, supabase_cli
|
||||
return {"message": f"❌ {file.filename} is not supported.", "type": "error"}
|
||||
|
||||
|
||||
@app.post("/upload")
|
||||
|
||||
@app.post("/upload", dependencies=[Depends(JWTBearer())])
|
||||
async def upload_file(commons: CommonsDep, file: UploadFile, enable_summarization: bool = False):
|
||||
message = await filter_file(file, enable_summarization, commons['supabase'])
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@app.post("/chat/")
|
||||
@app.post("/chat/", dependencies=[Depends(JWTBearer())])
|
||||
async def chat_endpoint(commons: CommonsDep, chat_message: ChatMessage):
|
||||
history = chat_message.history
|
||||
qa = get_qa_llm(chat_message)
|
||||
@ -124,7 +128,7 @@ async def chat_endpoint(commons: CommonsDep, chat_message: ChatMessage):
|
||||
return {"history": history}
|
||||
|
||||
|
||||
@app.post("/crawl/")
|
||||
@app.post("/crawl/", dependencies=[Depends(JWTBearer())])
|
||||
async def crawl_endpoint(commons: CommonsDep, crawl_website: CrawlWebsite, enable_summarization: bool = False):
|
||||
file_path, file_name = crawl_website.process()
|
||||
|
||||
@ -139,7 +143,7 @@ async def crawl_endpoint(commons: CommonsDep, crawl_website: CrawlWebsite, enabl
|
||||
return message
|
||||
|
||||
|
||||
@app.get("/explore")
|
||||
@app.get("/explore", dependencies=[Depends(JWTBearer())])
|
||||
async def explore_endpoint(commons: CommonsDep):
|
||||
response = commons['supabase'].table("documents").select(
|
||||
"name:metadata->>file_name, size:metadata->>file_size", count="exact").execute()
|
||||
@ -152,7 +156,7 @@ async def explore_endpoint(commons: CommonsDep):
|
||||
return {"documents": unique_data}
|
||||
|
||||
|
||||
@app.delete("/explore/{file_name}")
|
||||
@app.delete("/explore/{file_name}", dependencies=[Depends(JWTBearer())])
|
||||
async def delete_endpoint(commons: CommonsDep, file_name: str):
|
||||
# Cascade delete the summary from the database first, because it has a foreign key constraint
|
||||
commons['supabase'].table("summaries").delete().match(
|
||||
@ -162,7 +166,7 @@ async def delete_endpoint(commons: CommonsDep, file_name: str):
|
||||
return {"message": f"{file_name} has been deleted."}
|
||||
|
||||
|
||||
@app.get("/explore/{file_name}")
|
||||
@app.get("/explore/{file_name}", dependencies=[Depends(JWTBearer())])
|
||||
async def download_endpoint(commons: CommonsDep, file_name: str):
|
||||
response = commons['supabase'].table("documents").select(
|
||||
"metadata->>file_name, metadata->>file_size, metadata->>file_extension, metadata->>file_url").match({"metadata->>file_name": file_name}).execute()
|
||||
|
31
backend/auth_bearer.py
Normal file
31
backend/auth_bearer.py
Normal file
@ -0,0 +1,31 @@
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
from auth_handler import decode_access_token
|
||||
|
||||
class JWTBearer(HTTPBearer):
|
||||
def __init__(self, auto_error: bool = True):
|
||||
super().__init__(auto_error=auto_error)
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = await super().__call__(request)
|
||||
if os.environ.get("AUTHENTICATE") == "false":
|
||||
return True
|
||||
if credentials:
|
||||
if not credentials.scheme == "Bearer":
|
||||
raise HTTPException(status_code=402, detail="Invalid authorization scheme.")
|
||||
token = credentials.credentials
|
||||
if not self.verify_jwt(token):
|
||||
raise HTTPException(status_code=402, detail="Invalid token or expired token.")
|
||||
return credentials.credentials
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Invalid authorization code.")
|
||||
|
||||
def verify_jwt(self, jwtoken: str) -> bool:
|
||||
isTokenValid: bool = False
|
||||
payload = decode_access_token(jwtoken)
|
||||
if payload:
|
||||
isTokenValid = True
|
||||
return isTokenValid
|
26
backend/auth_handler.py
Normal file
26
backend/auth_handler.py
Normal file
@ -0,0 +1,26 @@
|
||||
from jose import jwt
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from jose.exceptions import JWTError
|
||||
import os
|
||||
|
||||
SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def decode_access_token(token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM], options={"verify_aud": False})
|
||||
return payload
|
||||
except JWTError as e:
|
||||
print(f"JWTError: {str(e)}")
|
||||
return None
|
@ -60,8 +60,6 @@ async def process_audio(upload_file: UploadFile, stats_db):
|
||||
|
||||
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
|
||||
chunk_size=chunk_size, chunk_overlap=chunk_overlap)
|
||||
print(transcript)
|
||||
print("#########")
|
||||
texts = text_splitter.split_text(transcript)
|
||||
|
||||
docs_with_metadata = [Document(page_content=text, metadata={"file_sha1": file_sha, "file_size": file_size, "file_name": file_meta_name,
|
||||
|
@ -15,3 +15,4 @@ uvicorn==0.22.0
|
||||
pypandoc==1.11
|
||||
docx2txt==0.8
|
||||
guidance==0.0.53
|
||||
python-jose==3.3.0
|
@ -7,24 +7,20 @@ import Modal from "../components/ui/Modal";
|
||||
import { MdSettings } from "react-icons/md";
|
||||
import ChatMessages from "./ChatMessages";
|
||||
import PageHeading from "../components/ui/PageHeading";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ChatPage() {
|
||||
const [question, setQuestion] = useState("");
|
||||
const [history, setHistory] = useState<Array<[string, string]>>([
|
||||
// ["user", "Hello!"],
|
||||
// ["assistant", "Hello Back!"],
|
||||
// ["user", "Send some long message"],
|
||||
// [
|
||||
// "assistant",
|
||||
// "This is a very long and really long message which is longer than every other message.",
|
||||
// ],
|
||||
// ["user", "What is redux"],
|
||||
// ["assistant", ``],
|
||||
]);
|
||||
const [history, setHistory] = useState<Array<[string, string]>>([]);
|
||||
const [model, setModel] = useState("gpt-3.5-turbo");
|
||||
const [temperature, setTemperature] = useState(0);
|
||||
const [maxTokens, setMaxTokens] = useState(500);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const { supabase, session } = useSupabase()
|
||||
if (session === null) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const askQuestion = async () => {
|
||||
setHistory((hist) => [...hist, ["user", question]]);
|
||||
@ -37,6 +33,11 @@ export default function ChatPage() {
|
||||
history,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
setHistory(response.data.history);
|
||||
|
@ -1,13 +1,24 @@
|
||||
import axios from "axios";
|
||||
import { Document } from "../types";
|
||||
import { useSupabase } from "../../supabase-provider";
|
||||
|
||||
interface DocumentDataProps {
|
||||
documentName: string;
|
||||
}
|
||||
|
||||
const DocumentData = async ({ documentName }: DocumentDataProps) => {
|
||||
const { supabase, session } = useSupabase();
|
||||
if (!session) {
|
||||
throw new Error('User session not found');
|
||||
}
|
||||
|
||||
const res = await axios.get(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore/${documentName}`
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore/${documentName}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const documents = res.data.documents as any[];
|
||||
const doc = documents[0];
|
||||
|
@ -7,6 +7,7 @@ import { Dispatch, SetStateAction, Suspense, useState } from "react";
|
||||
import axios from "axios";
|
||||
import DocumentData from "./DocumentData";
|
||||
import Spinner from "@/app/components/ui/Spinner";
|
||||
import { useSupabase } from "@/app/supabase-provider";
|
||||
|
||||
interface DocumentProps {
|
||||
document: Document;
|
||||
@ -15,13 +16,23 @@ interface DocumentProps {
|
||||
|
||||
const DocumentItem = ({ document, setDocuments }: DocumentProps) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { supabase, session } = useSupabase()
|
||||
if (!session) {
|
||||
throw new Error('User session not found');
|
||||
}
|
||||
|
||||
|
||||
const deleteDocument = async (name: string) => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
console.log(`Deleting Document ${name}`);
|
||||
const response = await axios.delete(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore/${name}`
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore/${name}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
setDocuments((docs) => docs.filter((doc) => doc.name !== name)); // Optimistic update
|
||||
} catch (error) {
|
||||
|
@ -7,10 +7,18 @@ import Button from "../components/ui/Button";
|
||||
import Link from "next/link";
|
||||
import Spinner from "../components/ui/Spinner";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
|
||||
|
||||
export default function ExplorePage() {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [isPending, setIsPending] = useState(true);
|
||||
const { supabase, session } = useSupabase();
|
||||
if (session === null) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
@ -23,7 +31,12 @@ export default function ExplorePage() {
|
||||
`Fetching documents from ${process.env.NEXT_PUBLIC_BACKEND_URL}/explore`
|
||||
);
|
||||
const response = await axios.get<{ documents: Document[] }>(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore`
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
setDocuments(response.data.documents);
|
||||
} catch (error) {
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 43 KiB |
@ -4,12 +4,16 @@ import Card from "../components/ui/Card";
|
||||
import Button from "../components/ui/Button";
|
||||
import PageHeading from "../components/ui/PageHeading";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
|
||||
export default function Login() {
|
||||
const { supabase } = useSupabase();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
|
||||
const handleLogin = async () => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email,
|
||||
@ -47,7 +51,9 @@ export default function Login() {
|
||||
/>
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button onClick={handleLogin}>Login</Button>
|
||||
<Link href="/signup">Dont have an account? Sign up</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
@ -53,4 +53,4 @@ export const useSupabase = () => {
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,8 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import Card from "../components/ui/Card";
|
||||
import PageHeading from "../components/ui/PageHeading";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function UploadPage() {
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
@ -16,6 +18,10 @@ export default function UploadPage() {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [pendingFileIndex, setPendingFileIndex] = useState<number>(0);
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { supabase, session } = useSupabase()
|
||||
if (session === null) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const crawlWebsite = useCallback(async () => {
|
||||
// Validate URL
|
||||
@ -41,7 +47,12 @@ export default function UploadPage() {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/crawl`,
|
||||
config
|
||||
config,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setMessage({
|
||||
@ -62,7 +73,12 @@ export default function UploadPage() {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/upload`,
|
||||
formData
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setMessage({
|
||||
|
Loading…
Reference in New Issue
Block a user