The previous check counted ALL users (func.count(User.id)), which incorrectly blocked bootstrap login if any non-admin user (e.g., Google OAuth sign-in) already existed. The fix queries specifically for admin users using the legacy is_admin flag OR the admin role assignment in user_roles. Also add tests for the non-admin-user-only scenario and fix the stale test_non_admin_user_cannot_login_as_admin assertion (401 not 403 — bootstrap path is still active when no admin exists).
168 lines
6.1 KiB
Python
168 lines
6.1 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.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, user_roles
|
|
|
|
# Check if any admin users already exist in the DB
|
|
async with async_session() as session:
|
|
# Check for admin users: legacy is_admin flag OR admin role assignment
|
|
admin_role_subq = (
|
|
select(1)
|
|
.select_from(user_roles)
|
|
.join(Role, user_roles.c.role_id == Role.id)
|
|
.where(user_roles.c.user_id == User.id)
|
|
.where(Role.name == "admin")
|
|
.exists()
|
|
)
|
|
|
|
result = await session.execute(
|
|
select(1).where(
|
|
(User.is_admin == True) | admin_role_subq
|
|
).limit(1)
|
|
)
|
|
has_admins = result.scalar() is not None
|
|
|
|
if has_admins:
|
|
# 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
|