- Add hash_password/verify_password to app/auth.py using bcrypt - Fix /login to reject bootstrap creds (403) when admin users exist - Add normal password-based login path for existing admin users - Eagerly load User.roles to avoid lazy-load outside async session - Fix test_user_models.py VARCHAR assertion (SQLAlchemy 2.0 uses 'string') - Use shared-cache SQLite in conftest for endpoint-level tests - Add 23 tests covering all 3 required scenarios plus edge cases: 1. Bootstrap login with hardcoded creds when no admins → PASS (200) 2. Bootstrap login with hardcoded creds when admins exist → FAIL (403) 3. Password login for existing admin with hashed password → PASS (200) - All 130 tests pass (107 existing + 23 new)
154 lines
5.6 KiB
Python
154 lines
5.6 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, func
|
|
from sqlalchemy.orm import joinedload
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import (
|
|
create_access_token,
|
|
get_current_user,
|
|
verify_google_id_token,
|
|
verify_password,
|
|
hash_password,
|
|
)
|
|
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):
|
|
"""Conditional admin login.
|
|
|
|
- When NO admins exist: accepts hardcoded bootstrap credentials and
|
|
auto-provisions the first admin user.
|
|
- When admins DO exist: rejects hardcoded credentials (403) and requires
|
|
normal password-based login for existing admin users.
|
|
"""
|
|
if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD:
|
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Bootstrap admin not configured")
|
|
|
|
from app.database import async_session
|
|
from app.user_models import Role
|
|
|
|
# Check if any admins already exist in the DB
|
|
async with async_session() as session:
|
|
admin_count_result = await session.execute(select(func.count(User.id)))
|
|
admin_count = admin_count_result.scalar()
|
|
|
|
if admin_count > 0:
|
|
# Admins exist — bootstrap creds are locked. Try normal password login.
|
|
if (payload.username == settings.ADMIN_USERNAME and
|
|
payload.password == settings.ADMIN_PASSWORD):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Bootstrap credentials disabled — admin users already exist",
|
|
)
|
|
|
|
# Normal password-based login for existing admin users
|
|
result = await session.execute(
|
|
select(User).options(joinedload(User.roles)).where(
|
|
(User.email == f"{payload.username}@local") |
|
|
(User.display_name == payload.username)
|
|
)
|
|
)
|
|
user = result.unique().scalar_one_or_none()
|
|
if user is None or not user.password_hash or not verify_password(payload.password, user.password_hash):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
if not user.is_admin_effective:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
token = create_access_token(user.id, user.is_admin_effective)
|
|
return TokenResponse(access_token=token)
|
|
|
|
# Bootstrap path — no admins exist yet. Verify hardcoded creds.
|
|
if payload.username != settings.ADMIN_USERNAME or payload.password != settings.ADMIN_PASSWORD:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
|
|
# 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)
|
|
|
|
# Create the bootstrap admin user
|
|
user = User(
|
|
email=f"{settings.ADMIN_USERNAME}@local",
|
|
display_name=settings.ADMIN_USERNAME,
|
|
auth_provider="local",
|
|
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
|
is_admin=True,
|
|
)
|
|
user.roles.append(admin_role)
|
|
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.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
|