- Add Role model and user_roles association table (many-to-many) - Add password_hash column to User model (nullable, for future bcrypt) - Keep is_admin boolean for backward compatibility - Add is_admin_effective property: true if legacy is_admin OR 'admin' role - Update auth dependency (get_current_admin_user) to use is_admin_effective - Update bootstrap login to auto-create 'admin' role and assign on login - Update Google OAuth login to use is_admin_effective for token creation - Add migrate_roles.py: idempotent migration script to backfill roles - Add unit tests for Role, User, association table, and is_admin_effective
128 lines
4.2 KiB
Python
128 lines
4.2 KiB
Python
"""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
|
|
from app.user_models import Role
|
|
|
|
async with async_session() as session:
|
|
# Ensure the 'admin' role exists
|
|
result = await session.execute(select(Role).where(Role.name == "admin"))
|
|
admin_role = result.scalar_one_or_none()
|
|
if admin_role is None:
|
|
admin_role = Role(name="admin", description="Full administrative access")
|
|
session.add(admin_role)
|
|
await session.commit()
|
|
await session.refresh(admin_role)
|
|
|
|
# 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,
|
|
)
|
|
user.roles.append(admin_role)
|
|
session.add(user)
|
|
else:
|
|
# Ensure existing bootstrap user also has the role
|
|
if admin_role not in user.roles:
|
|
user.roles.append(admin_role)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
|
|
token = create_access_token(user.id, user.is_admin_effective)
|
|
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_effective)
|
|
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
|