feat: add conditional bootstrap login + 23 admin login tests
- 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)
This commit is contained in:
+52
-26
@@ -2,13 +2,16 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
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
|
||||
@@ -48,18 +51,52 @@ class UserInfoResponse(BaseModel):
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(payload: LoginRequest):
|
||||
"""Bootstrap admin login (plaintext credentials from env vars)."""
|
||||
"""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")
|
||||
|
||||
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
|
||||
|
||||
# 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()
|
||||
@@ -69,27 +106,16 @@ async def login(payload: LoginRequest):
|
||||
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",
|
||||
)
|
||||
# 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 = 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)
|
||||
user.roles.append(admin_role)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user