Admin mode. Data driven content sections. Bugfixes.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""Auth router: login, google OAuth, and current user info."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import (
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
verify_google_id_token,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database import get_session
|
||||
from app.user_models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Request/Response schemas ─────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class GoogleLoginRequest(BaseModel):
|
||||
id_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
display_name: str
|
||||
avatar_url: str | None
|
||||
auth_provider: str
|
||||
is_admin: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(payload: LoginRequest):
|
||||
"""Bootstrap admin login (plaintext credentials from env vars)."""
|
||||
if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Bootstrap admin not configured")
|
||||
|
||||
if payload.username != settings.ADMIN_USERNAME or payload.password != settings.ADMIN_PASSWORD:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
# This is handled in the auth router to keep it self-contained
|
||||
from app.database import async_session
|
||||
|
||||
async with async_session() as session:
|
||||
# Find or create the bootstrap admin user
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.email == f"{settings.ADMIN_USERNAME}@local",
|
||||
User.auth_provider == "local",
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
email=f"{settings.ADMIN_USERNAME}@local",
|
||||
display_name=settings.ADMIN_USERNAME,
|
||||
auth_provider="local",
|
||||
is_admin=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
token = create_access_token(user.id, user.is_admin)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.post("/google", response_model=TokenResponse)
|
||||
async def login_with_google(payload: GoogleLoginRequest, session: AsyncSession = Depends(get_session)):
|
||||
"""Google OAuth sign-in. Accepts an ID token, verifies it, upserts user, returns JWT."""
|
||||
info = await verify_google_id_token(payload.id_token)
|
||||
|
||||
result = await session.execute(select(User).where(User.email == info["email"]))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
user = User(
|
||||
email=info["email"],
|
||||
display_name=info["name"],
|
||||
avatar_url=info["picture"] or None,
|
||||
auth_provider="google",
|
||||
is_admin=False,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
token = create_access_token(user.id, user.is_admin)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfoResponse)
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
"""Return current user info."""
|
||||
return current_user
|
||||
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import get_current_admin_user
|
||||
from app.database import get_session
|
||||
from app.models import Event
|
||||
from app.schemas import EventCreate, EventUpdate, EventResponse
|
||||
from app.user_models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -34,7 +36,9 @@ async def get_event(event_id: int, session: AsyncSession = Depends(get_session))
|
||||
|
||||
@router.post("/", response_model=EventResponse, status_code=201)
|
||||
async def create_event(
|
||||
payload: EventCreate, session: AsyncSession = Depends(get_session)
|
||||
payload: EventCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
event = Event(**payload.model_dump(), active=True)
|
||||
session.add(event)
|
||||
@@ -48,6 +52,7 @@ async def update_event(
|
||||
event_id: int,
|
||||
payload: EventUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
result = await session.execute(select(Event).where(Event.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
@@ -61,7 +66,11 @@ async def update_event(
|
||||
|
||||
|
||||
@router.delete("/{event_id}", status_code=204)
|
||||
async def delete_event(event_id: int, session: AsyncSession = Depends(get_session)):
|
||||
async def delete_event(
|
||||
event_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
result = await session.execute(select(Event).where(Event.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
if not event:
|
||||
|
||||
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import get_current_admin_user
|
||||
from app.database import get_session
|
||||
from app.models import Program
|
||||
from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse
|
||||
from app.user_models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -39,7 +41,9 @@ async def get_program(program_id: int, session: AsyncSession = Depends(get_sessi
|
||||
|
||||
@router.post("/", response_model=ProgramResponse, status_code=201)
|
||||
async def create_program(
|
||||
payload: ProgramCreate, session: AsyncSession = Depends(get_session)
|
||||
payload: ProgramCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
program = Program(**payload.model_dump())
|
||||
session.add(program)
|
||||
@@ -53,6 +57,7 @@ async def update_program(
|
||||
program_id: int,
|
||||
payload: ProgramUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
result = await session.execute(select(Program).where(Program.id == program_id))
|
||||
program = result.scalar_one_or_none()
|
||||
@@ -66,7 +71,11 @@ async def update_program(
|
||||
|
||||
|
||||
@router.delete("/{program_id}", status_code=204)
|
||||
async def delete_program(program_id: int, session: AsyncSession = Depends(get_session)):
|
||||
async def delete_program(
|
||||
program_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
result = await session.execute(select(Program).where(Program.id == program_id))
|
||||
program = result.scalar_one_or_none()
|
||||
if not program:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import get_current_admin_user
|
||||
from app.database import get_session
|
||||
from app.models import DonationTier
|
||||
from app.schemas import TierCreate, TierUpdate, TierResponse
|
||||
from app.user_models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -30,7 +30,9 @@ async def get_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
|
||||
|
||||
@router.post("/", response_model=TierResponse, status_code=201)
|
||||
async def create_tier(
|
||||
payload: TierCreate, session: AsyncSession = Depends(get_session)
|
||||
payload: TierCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
tier = DonationTier(**payload.model_dump())
|
||||
session.add(tier)
|
||||
@@ -44,6 +46,7 @@ async def update_tier(
|
||||
tier_id: int,
|
||||
payload: TierUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
|
||||
tier = result.scalar_one_or_none()
|
||||
@@ -57,7 +60,11 @@ async def update_tier(
|
||||
|
||||
|
||||
@router.delete("/{tier_id}", status_code=204)
|
||||
async def delete_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
|
||||
async def delete_tier(
|
||||
tier_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Auth helpers, JWT token creation/verification, and FastAPI dependencies."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_session
|
||||
from app.user_models import User
|
||||
|
||||
|
||||
class AuthBearer(HTTPBearer):
|
||||
"""HTTP Bearer scheme that doesn't auto-fail on missing token."""
|
||||
def __init__(self, auto_error: bool = False):
|
||||
super().__init__(auto_error=auto_error)
|
||||
|
||||
|
||||
bearer = AuthBearer(auto_error=False)
|
||||
|
||||
|
||||
# ── Token helpers ──────────────────────────────────────────────
|
||||
|
||||
def create_access_token(user_id: int, is_admin: bool) -> str:
|
||||
"""Create a JWT access token for the given user."""
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"is_admin": is_admin,
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode and verify a JWT token. Raises HTTPException 401 on failure."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
||||
|
||||
|
||||
# ── FastAPI dependencies ──────────────────────────────────────
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
"""Validate JWT and return the current User."""
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
user_id = int(payload["sub"])
|
||||
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Raise 403 if the current user is not an admin."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
# ── Google OIDC verification ─────────────────────────────────
|
||||
|
||||
_GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs"
|
||||
_GOOGLE_TOKEN_INFO_URL = "https://oauth2.googleapis.com/tokeninfo"
|
||||
|
||||
|
||||
async def verify_google_id_token(id_token: str) -> dict:
|
||||
"""Verify a Google ID token and return (email, name, picture)."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Use tokeninfo endpoint for simplicity (no signature parsing needed)
|
||||
resp = await client.get(_GOOGLE_TOKEN_INFO_URL, params={"idtoken": id_token})
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Google ID token",
|
||||
)
|
||||
|
||||
info = resp.json()
|
||||
email = info.get("email")
|
||||
if not email:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No email in Google token")
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"name": info.get("name", email.split("@")[0]),
|
||||
"picture": info.get("picture", ""),
|
||||
}
|
||||
@@ -6,6 +6,14 @@ class Settings(BaseSettings):
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:4200", "http://localhost:3000"]
|
||||
ENV: str = "development"
|
||||
|
||||
# Auth
|
||||
ADMIN_USERNAME: str = ""
|
||||
ADMIN_PASSWORD: str = ""
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_REDIRECT_URI: str = "http://localhost:4200"
|
||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||
JWT_EXPIRE_MINUTES: int = 1440 # 24 hours
|
||||
|
||||
model_config = {"env_prefix": "KMTN_"}
|
||||
|
||||
|
||||
|
||||
+23
-1
@@ -7,7 +7,8 @@ from sqlalchemy import func, select
|
||||
from app.config import settings
|
||||
from app.database import async_session, engine
|
||||
from app.models import Base, Program
|
||||
from app.api import programs, events, tiers
|
||||
from app.user_models import User
|
||||
from app.api import programs, events, tiers, auth
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -25,6 +26,26 @@ async def lifespan(app: FastAPI):
|
||||
from seed import seed as run_seed
|
||||
await run_seed()
|
||||
|
||||
# Bootstrap admin user if configured
|
||||
if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.email == f"{settings.ADMIN_USERNAME}@local",
|
||||
User.auth_provider == "local",
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
admin_user = User(
|
||||
email=f"{settings.ADMIN_USERNAME}@local",
|
||||
display_name=settings.ADMIN_USERNAME,
|
||||
auth_provider="local",
|
||||
is_admin=True,
|
||||
)
|
||||
session.add(admin_user)
|
||||
await session.commit()
|
||||
print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}")
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@@ -44,6 +65,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# Register routers
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(programs.router, prefix="/api/programs", tags=["programs"])
|
||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.models import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
email = Column(String(255), unique=True, nullable=False)
|
||||
display_name = Column(String(100), nullable=False)
|
||||
avatar_url = Column(String(500), nullable=True)
|
||||
auth_provider = Column(String(20), nullable=False, default="google")
|
||||
is_admin = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
Reference in New Issue
Block a user