Files
insar-management-system-v2/backend/app/routers/auth.py
T

310 lines
9.2 KiB
Python

from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from datetime import datetime
from sqlalchemy import delete
from ..auth_service import (
ROLE_ADMIN,
VALID_ROLES,
SESSION_COOKIE_NAME,
add_audit_log,
authenticate_user,
create_session,
create_user,
get_cookie_options,
get_user_by_session_token,
list_users,
revoke_session,
to_user_payload,
update_user,
)
from ..database import get_db
from ..models import AuthAuditLogInfo, AuthAuditLogORM, AuthSessionORM, AuthUserInfo, AuthUserORM
from .dependencies import (
_add_operation_audit_log,
_build_login_throttle_key,
_clear_login_failure_state,
_get_client_ip,
_get_current_user,
_get_login_retry_after_seconds,
_record_login_failure,
_require_admin,
)
router = APIRouter()
class LoginRequest(BaseModel):
username: str
password: str
class CreateUserRequest(BaseModel):
username: str
password: str
role: str = "viewer"
is_active: bool = True
class UpdateUserRequest(BaseModel):
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
@router.post("/auth/login")
async def auth_login(
payload: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
client_ip = _get_client_ip(request)
submitted_username = (payload.username or "").strip()
throttle_key = _build_login_throttle_key(submitted_username, client_ip)
retry_after = await _get_login_retry_after_seconds(throttle_key, db)
if retry_after > 0:
await add_audit_log(
db,
action="login_blocked_rate_limit",
resource="auth/login",
detail={"username": submitted_username, "retry_after_seconds": retry_after},
ip_address=client_ip,
)
await db.commit()
raise HTTPException(
status_code=429,
detail=f"Too many failed login attempts. Try again in {retry_after} seconds.",
headers={"Retry-After": str(retry_after)},
)
user = await authenticate_user(db, payload.username, payload.password)
if not user:
lock_retry_after = await _record_login_failure(throttle_key, db)
audit_action = "login_failed"
error_status = 401
error_detail = "Invalid username or password."
audit_detail = {"username": submitted_username}
if lock_retry_after > 0:
audit_action = "login_rate_limited"
error_status = 429
error_detail = f"Too many failed login attempts. Try again in {lock_retry_after} seconds."
audit_detail["retry_after_seconds"] = lock_retry_after
await add_audit_log(
db,
action=audit_action,
resource="auth/login",
detail=audit_detail,
ip_address=client_ip,
)
await db.commit()
if lock_retry_after > 0:
raise HTTPException(
status_code=error_status,
detail=error_detail,
headers={"Retry-After": str(lock_retry_after)},
)
raise HTTPException(status_code=error_status, detail=error_detail)
await _clear_login_failure_state(throttle_key, db)
token, expires_at = await create_session(
db,
user,
ip_address=client_ip,
user_agent=request.headers.get("user-agent"),
)
await add_audit_log(
db,
action="login_success",
user=user,
resource="auth/login",
ip_address=client_ip,
)
user_payload = to_user_payload(user)
await db.commit()
cookie_opts = get_cookie_options()
response.set_cookie(value=token, **cookie_opts)
return {
"message": "Login successful.",
"user": user_payload,
"expires_at": expires_at,
}
@router.post("/auth/logout")
async def auth_logout(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
token = request.cookies.get(SESSION_COOKIE_NAME)
user = await get_user_by_session_token(db, token) if token else None
revoked = await revoke_session(db, token)
if user:
await add_audit_log(
db,
action="logout",
user=user,
resource="auth/logout",
ip_address=_get_client_ip(request),
)
await db.commit()
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"message": "Logged out.", "revoked": revoked}
@router.get("/auth/me", response_model=AuthUserInfo)
async def auth_me(current_user: AuthUserORM = Depends(_get_current_user)):
return AuthUserInfo.model_validate(current_user)
@router.get("/auth/users", response_model=List[AuthUserInfo])
async def auth_list_users(
_admin: AuthUserORM = Depends(_require_admin),
db: AsyncSession = Depends(get_db),
):
users = await list_users(db)
return [AuthUserInfo.model_validate(user) for user in users]
@router.get("/auth/audit-logs", response_model=List[AuthAuditLogInfo])
async def auth_list_audit_logs(
limit: int = 200,
_admin: AuthUserORM = Depends(_require_admin),
db: AsyncSession = Depends(get_db),
):
limit = max(1, min(limit, 1000))
result = await db.execute(
select(AuthAuditLogORM)
.order_by(AuthAuditLogORM.created_at.desc(), AuthAuditLogORM.id.desc())
.limit(limit)
)
logs = result.scalars().all()
return [AuthAuditLogInfo.model_validate(item) for item in logs]
@router.post("/auth/users", response_model=AuthUserInfo, status_code=201)
async def auth_create_user(
payload: CreateUserRequest,
request: Request,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
if payload.role not in VALID_ROLES:
raise HTTPException(status_code=422, detail=f"role must be one of: {sorted(VALID_ROLES)}")
try:
user = await create_user(
db,
username=payload.username,
password=payload.password,
role=payload.role,
is_active=payload.is_active,
created_by=admin_user.username,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await add_audit_log(
db,
action="user_created",
user=admin_user,
resource=f"auth/users/{user.id}",
detail={"username": user.username, "role": user.role, "is_active": user.is_active},
ip_address=_get_client_ip(request),
)
await db.commit()
await db.refresh(user)
return AuthUserInfo.model_validate(user)
@router.patch("/auth/users/{user_id}", response_model=AuthUserInfo)
async def auth_update_user(
user_id: int,
payload: UpdateUserRequest,
request: Request,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
user_result = await db.execute(
select(AuthUserORM).where(AuthUserORM.id == user_id)
)
target_user = user_result.scalar_one_or_none()
if not target_user:
raise HTTPException(status_code=404, detail="User not found.")
if payload.role is not None and payload.role not in VALID_ROLES:
raise HTTPException(status_code=422, detail=f"role must be one of: {sorted(VALID_ROLES)}")
if target_user.id == admin_user.id:
if payload.is_active is False:
raise HTTPException(status_code=400, detail="Cannot deactivate current admin account.")
if payload.role is not None and payload.role != ROLE_ADMIN:
raise HTTPException(status_code=400, detail="Cannot remove admin role from current account.")
try:
updated = await update_user(
db,
target_user,
role=payload.role,
is_active=payload.is_active,
password=payload.password,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await add_audit_log(
db,
action="user_updated",
user=admin_user,
resource=f"auth/users/{updated.id}",
detail={
"role": updated.role,
"is_active": updated.is_active,
"password_updated": payload.password is not None,
},
ip_address=_get_client_ip(request),
)
await db.commit()
await db.refresh(updated)
return AuthUserInfo.model_validate(updated)
@router.post("/auth/cleanup-sessions")
async def auth_cleanup_sessions(
request: Request,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
now = datetime.utcnow()
result = await db.execute(
delete(AuthSessionORM).where(
(AuthSessionORM.expires_at <= now) | (AuthSessionORM.is_revoked == True)
)
)
deleted_count = result.rowcount or 0
await add_audit_log(
db,
action="sessions_cleanup",
user=admin_user,
resource="auth/cleanup-sessions",
detail={"deleted_count": deleted_count},
ip_address=_get_client_ip(request),
)
await db.commit()
return {"message": f"已清理 {deleted_count} 条过期/已撤销会话。", "deleted_count": deleted_count}