feat: add Firebase Auth with Google sign-in
Frontend guard shows login screen for unauthenticated users. Backend verifies Firebase ID tokens on all protected endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f0ef26ee15
commit
2a314af419
7 changed files with 62 additions and 6 deletions
|
|
@ -2,3 +2,12 @@ POSTGRES_USER=favs
|
||||||
POSTGRES_PASSWORD=favs
|
POSTGRES_PASSWORD=favs
|
||||||
POSTGRES_DB=favs
|
POSTGRES_DB=favs
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
VITE_FIREBASE_API_KEY=
|
||||||
|
VITE_FIREBASE_AUTH_DOMAIN=
|
||||||
|
VITE_FIREBASE_PROJECT_ID=
|
||||||
|
VITE_FIREBASE_STORAGE_BUCKET=
|
||||||
|
VITE_FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
VITE_FIREBASE_APP_ID=
|
||||||
|
VITE_FIREBASE_MEASUREMENT_ID=
|
||||||
|
|
|
||||||
24
backend/app/auth.py
Normal file
24
backend/app/auth.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import firebase_admin
|
||||||
|
from firebase_admin import auth as firebase_auth, credentials
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if settings.firebase_project_id:
|
||||||
|
firebase_admin.initialize_app(options={"projectId": settings.firebase_project_id})
|
||||||
|
|
||||||
|
bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
cred: HTTPAuthorizationCredentials = Depends(bearer),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
decoded = firebase_auth.verify_id_token(cred.credentials)
|
||||||
|
return decoded
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
)
|
||||||
|
|
@ -5,6 +5,7 @@ class Settings(BaseSettings):
|
||||||
database_url: str = "postgresql+asyncpg://favs:favs@favs-db:5432/favs"
|
database_url: str = "postgresql+asyncpg://favs:favs@favs-db:5432/favs"
|
||||||
anthropic_api_key: str = ""
|
anthropic_api_key: str = ""
|
||||||
categorize_model: str = "claude-haiku-4-5-20251001"
|
categorize_model: str = "claude-haiku-4-5-20251001"
|
||||||
|
firebase_project_id: str = ""
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
model_config = {"env_file": ".env"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Bookmark
|
from app.models import Bookmark
|
||||||
from app.schemas import BookmarkCreate, BookmarkResponse, BookmarkUpdate
|
from app.schemas import BookmarkCreate, BookmarkResponse, BookmarkUpdate
|
||||||
|
|
@ -13,7 +14,9 @@ router = APIRouter(prefix="/api/bookmarks", tags=["bookmarks"])
|
||||||
|
|
||||||
@router.get("/", response_model=list[BookmarkResponse])
|
@router.get("/", response_model=list[BookmarkResponse])
|
||||||
async def list_bookmarks(
|
async def list_bookmarks(
|
||||||
category: str | None = None, db: AsyncSession = Depends(get_db)
|
category: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
query = select(Bookmark).order_by(Bookmark.created_at.desc())
|
query = select(Bookmark).order_by(Bookmark.created_at.desc())
|
||||||
if category:
|
if category:
|
||||||
|
|
@ -23,7 +26,7 @@ async def list_bookmarks(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
|
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
|
||||||
async def get_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def get_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user)):
|
||||||
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
||||||
bookmark = result.scalar_one_or_none()
|
bookmark = result.scalar_one_or_none()
|
||||||
if not bookmark:
|
if not bookmark:
|
||||||
|
|
@ -32,7 +35,7 @@ async def get_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=BookmarkResponse, status_code=201)
|
@router.post("/", response_model=BookmarkResponse, status_code=201)
|
||||||
async def create_bookmark(data: BookmarkCreate, db: AsyncSession = Depends(get_db)):
|
async def create_bookmark(data: BookmarkCreate, db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user)):
|
||||||
bookmark = Bookmark(**data.model_dump())
|
bookmark = Bookmark(**data.model_dump())
|
||||||
db.add(bookmark)
|
db.add(bookmark)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -42,7 +45,7 @@ async def create_bookmark(data: BookmarkCreate, db: AsyncSession = Depends(get_d
|
||||||
|
|
||||||
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
|
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
|
||||||
async def update_bookmark(
|
async def update_bookmark(
|
||||||
bookmark_id: uuid.UUID, data: BookmarkUpdate, db: AsyncSession = Depends(get_db)
|
bookmark_id: uuid.UUID, data: BookmarkUpdate, db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
||||||
bookmark = result.scalar_one_or_none()
|
bookmark = result.scalar_one_or_none()
|
||||||
|
|
@ -56,7 +59,7 @@ async def update_bookmark(
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{bookmark_id}", status_code=204)
|
@router.delete("/{bookmark_id}", status_code=204)
|
||||||
async def delete_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user)):
|
||||||
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
||||||
bookmark = result.scalar_one_or_none()
|
bookmark = result.scalar_one_or_none()
|
||||||
if not bookmark:
|
if not bookmark:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
from app.categorizer import categorize_pending
|
from app.categorizer import categorize_pending
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
|
|
@ -8,6 +9,6 @@ router = APIRouter(tags=["categorize"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/categorize")
|
@router.post("/api/categorize")
|
||||||
async def run_categorize(db: AsyncSession = Depends(get_db)):
|
async def run_categorize(db: AsyncSession = Depends(get_db), _user: dict = Depends(get_current_user)):
|
||||||
count = await categorize_pending(db)
|
count = await categorize_pending(db)
|
||||||
return {"categorized": count}
|
return {"categorized": count}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@ sqlalchemy[asyncio]==2.0.36
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
pydantic-settings==2.7.1
|
pydantic-settings==2.7.1
|
||||||
anthropic==0.43.0
|
anthropic==0.43.0
|
||||||
|
firebase-admin==6.6.0
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://favs:favs@favs-db:5432/favs
|
DATABASE_URL: postgresql+asyncpg://favs:favs@favs-db:5432/favs
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||||
|
FIREBASE_PROJECT_ID: ${VITE_FIREBASE_PROJECT_ID}
|
||||||
depends_on:
|
depends_on:
|
||||||
favs-db:
|
favs-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -29,5 +30,21 @@ services:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
favs-ui:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
- VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
|
||||||
|
- VITE_FIREBASE_AUTH_DOMAIN=${VITE_FIREBASE_AUTH_DOMAIN}
|
||||||
|
- VITE_FIREBASE_PROJECT_ID=${VITE_FIREBASE_PROJECT_ID}
|
||||||
|
- VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
|
||||||
|
- VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
|
||||||
|
- VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
|
||||||
|
- VITE_FIREBASE_MEASUREMENT_ID=${VITE_FIREBASE_MEASUREMENT_ID}
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- favs-api
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
favs_pgdata:
|
favs_pgdata:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue