cosmo_backend/app/api/auth.py

298 lines
7.8 KiB
Python

"""
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"}