""" Authentication API routes """ from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException, Depends, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update from sqlalchemy.orm import selectinload from pydantic import BaseModel from app.database import get_db from app.models.db import User, Role, Menu from app.services.auth import verify_password, create_access_token, hash_password from app.services.auth_deps import get_current_user from app.services.token_service import token_service from app.config import settings # HTTP Bearer security security = HTTPBearer() router = APIRouter(prefix="/auth", tags=["auth"]) # Pydantic models class LoginRequest(BaseModel): username: str password: str class RegisterRequest(BaseModel): username: str password: str email: str | None = None full_name: str | None = None class LoginResponse(BaseModel): access_token: str token_type: str = "bearer" user: dict class UserInfo(BaseModel): id: int username: str email: str | None full_name: str | None roles: list[str] class MenuNode(BaseModel): id: int name: str title: str icon: str | None path: str | None component: str | None children: list['MenuNode'] | None = None @router.post("/register", response_model=LoginResponse) async def register( register_data: RegisterRequest, db: AsyncSession = Depends(get_db) ): """ Register a new user """ # Check if username already exists result = await db.execute( select(User).where(User.username == register_data.username) ) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered" ) # Check if email already exists (if provided) if register_data.email: result = await db.execute( select(User).where(User.email == register_data.email) ) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) # Get 'user' role result = await db.execute( select(Role).where(Role.name == "user") ) user_role = result.scalar_one_or_none() if not user_role: # Should not happen if seeded correctly, but fallback handling raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Default role 'user' not found" ) # Create new user new_user = User( username=register_data.username, password_hash=hash_password(register_data.password), email=register_data.email, full_name=register_data.full_name, is_active=True ) db.add(new_user) await db.flush() # Flush to get ID # Assign role from app.models.db.user import user_roles await db.execute( user_roles.insert().values( user_id=new_user.id, role_id=user_role.id ) ) await db.commit() await db.refresh(new_user) # Create access token access_token = create_access_token( data={"sub": str(new_user.id), "username": new_user.username} ) # Save token to Redis await token_service.save_token(access_token, new_user.id, new_user.username) # Return token and user info (simulate fetch with roles loaded) return LoginResponse( access_token=access_token, user={ "id": new_user.id, "username": new_user.username, "email": new_user.email, "full_name": new_user.full_name, "roles": [user_role.name] } ) @router.post("/login", response_model=LoginResponse) async def login( login_data: LoginRequest, db: AsyncSession = Depends(get_db) ): """ Login with username and password Returns JWT access token """ # Query user with roles result = await db.execute( select(User) .options(selectinload(User.roles)) .where(User.username == login_data.username) ) user = result.scalar_one_or_none() # Verify user exists and password is correct if not user or not verify_password(login_data.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) # Check if user is active if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" ) # Update last login time await db.execute( update(User) .where(User.id == user.id) .values(last_login_at=datetime.utcnow()) ) await db.commit() # Create access token access_token = create_access_token( data={"sub": str(user.id), "username": user.username} ) # Save token to Redis await token_service.save_token(access_token, user.id, user.username) # Return token and user info return LoginResponse( access_token=access_token, user={ "id": user.id, "username": user.username, "email": user.email, "full_name": user.full_name, "roles": [role.name for role in user.roles] } ) @router.get("/me", response_model=UserInfo) async def get_current_user_info( current_user: User = Depends(get_current_user) ): """ Get current user information """ return UserInfo( id=current_user.id, username=current_user.username, email=current_user.email, full_name=current_user.full_name, roles=[role.name for role in current_user.roles] ) @router.get("/menus", response_model=list[MenuNode]) async def get_user_menus( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """ Get menus accessible to current user based on their roles """ # Get all role IDs for current user role_ids = [role.id for role in current_user.roles] if not role_ids: return [] # Query menus for user's roles from app.models.db.menu import RoleMenu result = await db.execute( select(Menu) .join(RoleMenu, RoleMenu.menu_id == Menu.id) .where(RoleMenu.role_id.in_(role_ids)) .where(Menu.is_active == True) .order_by(Menu.sort_order) .distinct() ) menus = result.scalars().all() # Build tree structure menu_dict = {} root_menus = [] for menu in menus: menu_node = MenuNode( id=menu.id, name=menu.name, title=menu.title, icon=menu.icon, path=menu.path, component=menu.component, children=[] ) menu_dict[menu.id] = menu_node if menu.parent_id is None: root_menus.append(menu_node) # Attach children to parents for menu in menus: if menu.parent_id and menu.parent_id in menu_dict: parent = menu_dict[menu.parent_id] if parent.children is None: parent.children = [] parent.children.append(menu_dict[menu.id]) # Remove empty children lists for menu_node in menu_dict.values(): if menu_node.children == []: menu_node.children = None return root_menus @router.post("/logout") async def logout( credentials: HTTPAuthorizationCredentials = Depends(security) ): """ Logout - revoke current token """ token = credentials.credentials await token_service.revoke_token(token) return {"message": "Logged out successfully"}