298 lines
7.8 KiB
Python
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"}
|