auth.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. from datetime import timedelta
  2. from fastapi import APIRouter, Depends, HTTPException, status
  3. from sqlalchemy import select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from sqlalchemy.orm import selectinload
  6. from backend.app.core.auth import (
  7. ACCESS_TOKEN_EXPIRE_MINUTES,
  8. authenticate_user,
  9. create_access_token,
  10. get_current_active_user,
  11. get_password_hash,
  12. get_user_by_username,
  13. )
  14. from backend.app.core.database import get_db
  15. from backend.app.models.group import Group
  16. from backend.app.models.settings import Settings
  17. from backend.app.models.user import User
  18. from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
  19. def _user_to_response(user: User) -> UserResponse:
  20. """Convert a User model to UserResponse schema."""
  21. return UserResponse(
  22. id=user.id,
  23. username=user.username,
  24. role=user.role,
  25. is_active=user.is_active,
  26. is_admin=user.is_admin,
  27. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  28. permissions=sorted(user.get_permissions()),
  29. created_at=user.created_at.isoformat(),
  30. )
  31. router = APIRouter(prefix="/auth", tags=["authentication"])
  32. async def is_auth_enabled(db: AsyncSession) -> bool:
  33. """Check if authentication is enabled."""
  34. result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
  35. setting = result.scalar_one_or_none()
  36. if setting is None:
  37. return False
  38. return setting.value.lower() == "true"
  39. async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
  40. """Set authentication enabled status."""
  41. from sqlalchemy import func
  42. from sqlalchemy.dialects.sqlite import insert as sqlite_insert
  43. stmt = sqlite_insert(Settings).values(key="auth_enabled", value="true" if enabled else "false")
  44. stmt = stmt.on_conflict_do_update(
  45. index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
  46. )
  47. await db.execute(stmt)
  48. # Note: Don't commit here - let get_db handle it or commit explicitly in the route
  49. async def is_setup_completed(db: AsyncSession) -> bool:
  50. """Check if setup has been completed."""
  51. result = await db.execute(select(Settings).where(Settings.key == "setup_completed"))
  52. setting = result.scalar_one_or_none()
  53. return setting and setting.value.lower() == "true"
  54. async def set_setup_completed(db: AsyncSession, completed: bool) -> None:
  55. """Set setup completed status."""
  56. from sqlalchemy import func
  57. from sqlalchemy.dialects.sqlite import insert as sqlite_insert
  58. stmt = sqlite_insert(Settings).values(key="setup_completed", value="true" if completed else "false")
  59. stmt = stmt.on_conflict_do_update(
  60. index_elements=["key"], set_={"value": "true" if completed else "false", "updated_at": func.now()}
  61. )
  62. await db.execute(stmt)
  63. # Note: Don't commit here - let get_db handle it or commit explicitly in the route
  64. @router.post("/setup", response_model=SetupResponse)
  65. async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
  66. """First-time setup: enable/disable authentication and create admin user."""
  67. import logging
  68. logger = logging.getLogger(__name__)
  69. try:
  70. # Check if auth is already configured (prevent re-setup)
  71. result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
  72. _existing_setting = result.scalar_one_or_none()
  73. # Check if users exist
  74. user_count_result = await db.execute(select(User))
  75. _user_count = len(user_count_result.scalars().all())
  76. # if _existing_setting and _user_count > 0:
  77. # # Auth already configured and users exist - prevent re-setup
  78. # raise HTTPException(
  79. # status_code=status.HTTP_400_BAD_REQUEST,
  80. # detail="Authentication is already configured. Use user management to modify users.",
  81. # )
  82. # If auth_enabled is true but no users exist, allow re-setup (recovery scenario)
  83. admin_created = False
  84. if request.auth_enabled:
  85. # Check if admin users already exist
  86. admin_users_result = await db.execute(select(User).where(User.role == "admin"))
  87. existing_admin_users = list(admin_users_result.scalars().all())
  88. has_admin_users = len(existing_admin_users) > 0
  89. if has_admin_users:
  90. # Admin users already exist, just enable auth (don't create new admin)
  91. logger.info(
  92. f"Admin users already exist ({len(existing_admin_users)} found), enabling authentication without creating new admin"
  93. )
  94. admin_created = False
  95. else:
  96. # No admin users exist, require admin credentials to create first admin
  97. if not request.admin_username or not request.admin_password:
  98. raise HTTPException(
  99. status_code=status.HTTP_400_BAD_REQUEST,
  100. detail="Admin username and password are required when enabling authentication (no admin users exist)",
  101. )
  102. # Check if username already exists (shouldn't happen if no admin users exist, but check anyway)
  103. existing_user = await get_user_by_username(db, request.admin_username)
  104. if existing_user:
  105. raise HTTPException(
  106. status_code=status.HTTP_400_BAD_REQUEST,
  107. detail="User with this username already exists",
  108. )
  109. # Create admin user FIRST (before enabling auth)
  110. try:
  111. logger.info("Creating admin user: %s", request.admin_username)
  112. admin_user = User(
  113. username=request.admin_username,
  114. password_hash=get_password_hash(request.admin_password),
  115. role="admin",
  116. is_active=True,
  117. )
  118. # Try to add user to Administrators group if it exists
  119. admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators"))
  120. admin_group = admin_group_result.scalar_one_or_none()
  121. if admin_group:
  122. admin_user.groups.append(admin_group)
  123. logger.info("Added new admin user to Administrators group")
  124. db.add(admin_user)
  125. logger.info("Admin user added to session: %s", request.admin_username)
  126. admin_created = True
  127. except Exception as e:
  128. await db.rollback()
  129. logger.error("Failed to create admin user: %s", e, exc_info=True)
  130. raise HTTPException(
  131. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  132. detail=f"Failed to create admin user: {str(e)}",
  133. )
  134. # Set auth enabled and mark setup as completed
  135. await set_auth_enabled(db, request.auth_enabled)
  136. await set_setup_completed(db, True)
  137. await db.commit()
  138. if admin_created:
  139. await db.refresh(admin_user)
  140. logger.info("Admin user created successfully: %s", admin_user.id)
  141. logger.info("Setup completed: auth_enabled=%s, admin_created=%s", request.auth_enabled, admin_created)
  142. return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
  143. except HTTPException:
  144. raise
  145. except Exception as e:
  146. logger.error("Setup error: %s", e, exc_info=True)
  147. await db.rollback()
  148. raise HTTPException(
  149. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  150. detail=f"Setup failed: {str(e)}",
  151. )
  152. @router.get("/status")
  153. async def get_auth_status(db: AsyncSession = Depends(get_db)):
  154. """Get authentication status (public endpoint)."""
  155. auth_enabled = await is_auth_enabled(db)
  156. setup_completed = await is_setup_completed(db)
  157. # Only require setup if it hasn't been completed yet
  158. requires_setup = not setup_completed
  159. return {"auth_enabled": auth_enabled, "requires_setup": requires_setup}
  160. @router.post("/disable", response_model=dict)
  161. async def disable_auth(
  162. current_user: User = Depends(get_current_active_user),
  163. db: AsyncSession = Depends(get_db),
  164. ):
  165. """Disable authentication (admin only)."""
  166. import logging
  167. logger = logging.getLogger(__name__)
  168. # Reload user with groups for proper is_admin check
  169. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  170. user = result.scalar_one()
  171. # Only admins can disable authentication
  172. if not user.is_admin:
  173. raise HTTPException(
  174. status_code=status.HTTP_403_FORBIDDEN,
  175. detail="Only admins can disable authentication",
  176. )
  177. try:
  178. await set_auth_enabled(db, False)
  179. await db.commit()
  180. logger.info("Authentication disabled by admin user: %s", user.username)
  181. return {"message": "Authentication disabled successfully", "auth_enabled": False}
  182. except Exception as e:
  183. await db.rollback()
  184. logger.error("Failed to disable authentication: %s", e, exc_info=True)
  185. raise HTTPException(
  186. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  187. detail=f"Failed to disable authentication: {str(e)}",
  188. )
  189. @router.post("/login", response_model=LoginResponse)
  190. async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
  191. """Login and get access token."""
  192. # Check if auth is enabled
  193. auth_enabled = await is_auth_enabled(db)
  194. if not auth_enabled:
  195. raise HTTPException(
  196. status_code=status.HTTP_400_BAD_REQUEST,
  197. detail="Authentication is not enabled",
  198. )
  199. user = await authenticate_user(db, request.username, request.password)
  200. if not user:
  201. raise HTTPException(
  202. status_code=status.HTTP_401_UNAUTHORIZED,
  203. detail="Incorrect username or password",
  204. headers={"WWW-Authenticate": "Bearer"},
  205. )
  206. # Reload user with groups for proper permission calculation
  207. result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
  208. user = result.scalar_one()
  209. access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
  210. access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
  211. return LoginResponse(
  212. access_token=access_token,
  213. token_type="bearer",
  214. user=_user_to_response(user),
  215. )
  216. @router.get("/me", response_model=UserResponse)
  217. async def get_current_user_info(
  218. current_user: User = Depends(get_current_active_user),
  219. db: AsyncSession = Depends(get_db),
  220. ):
  221. """Get current user information."""
  222. # Reload user with groups for proper permission calculation
  223. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  224. user = result.scalar_one()
  225. return _user_to_response(user)
  226. @router.post("/logout")
  227. async def logout():
  228. """Logout (client should discard token)."""
  229. return {"message": "Logged out successfully"}