Files
Nexus/server/api/auth.py
T
Your Name d744d7df8e 安全与质量修复: P0-4/P0-5 + P1-6~P1-13 (8项)
P0-4: install.py _write_env() 值加引号转义,防止含#$\等字符的密码破坏.env格式
P0-5: install.py _build_redis_url() URL编码redis密码,防止@:等字符破坏URL解析
P1-6: auth_service.py _decode_access_token() 验证exp/sub声明存在,拒绝畸形JWT
P1-7: websocket.py + webssh.py WebSocket JWT验证增加updated_at检查,密码修改后令牌失效
P1-8: auth.py 无refresh_token的logout返回更明确的提示信息
P1-9: install.py create_admin增加_is_installed()检查,防止.env存在后重复创建
P1-10: servers.py server_stats() 改用SQL聚合查询,避免加载全部服务器对象
P1-11: heartbeat_flush.py 移除get_redis()永不返回None的死代码
P1-13: sync_engine_v2.py completed/failed计数器加asyncio.Lock防止并发竞态

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:04:40 +08:00

233 lines
7.7 KiB
Python

"""Nexus — Auth API Routes (Login / TOTP / JWT / Refresh / Logout)
Presentation layer — receives HTTP requests, delegates to AuthService.
A2: TOTP endpoints now require JWT authentication via get_current_admin.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
import bcrypt
from server.api.dependencies import get_auth_service, get_db
from server.api.auth_jwt import get_current_admin
from server.application.services.auth_service import AuthService
from server.domain.models import Admin, AuditLog
router = APIRouter(prefix="/api/auth", tags=["auth"])
# ── Request Models ──
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=1, max_length=255)
totp_code: Optional[str] = Field(None, min_length=6, max_length=6)
class RefreshRequest(BaseModel):
refresh_token: str = Field(..., min_length=1)
class LogoutRequest(BaseModel):
refresh_token: Optional[str] = None
class TotpSetupRequest(BaseModel):
admin_id: int
class TotpVerifyRequest(BaseModel):
admin_id: int
totp_code: str = Field(..., min_length=6, max_length=6)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(..., min_length=1, max_length=255)
new_password: str = Field(..., min_length=6, max_length=255)
# ── Public Routes (no JWT required) ──
@router.post("/login")
async def login(
request: Request,
payload: LoginRequest,
service: AuthService = Depends(get_auth_service),
):
"""Authenticate admin user (password + optional TOTP) → return JWT tokens"""
ip_address = request.client.host if request.client else "unknown"
result = await service.login(
username=payload.username,
password=payload.password,
ip_address=ip_address,
totp_code=payload.totp_code,
)
if not result["success"]:
status_code = 401
if result.get("reason") == "account_locked":
status_code = 429
elif result.get("reason") == "totp_required":
status_code = 202 # Accepted but needs TOTP
raise HTTPException(status_code=status_code, detail=result.get("message", "Login failed"))
return result
@router.post("/refresh")
async def refresh_token(
payload: RefreshRequest,
service: AuthService = Depends(get_auth_service),
):
"""Exchange refresh token for new access + refresh token pair"""
result = await service.refresh_token(payload.refresh_token)
if not result["success"]:
raise HTTPException(status_code=401, detail=result.get("message", "Invalid refresh token"))
return result
@router.post("/logout")
async def logout(
payload: LogoutRequest,
service: AuthService = Depends(get_auth_service),
):
"""Invalidate refresh token (client should also discard access token)"""
if payload.refresh_token:
result = await service.logout_by_token(payload.refresh_token)
else:
# No refresh token provided — only client-side cleanup possible
return {"success": True, "message": "客户端已登出(未提供刷新令牌,服务端会话仍有效)"}
return result
# ── Protected Routes (JWT required) ──
@router.post("/totp/setup")
async def setup_totp(
payload: TotpSetupRequest,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
):
"""Generate TOTP secret for admin user (requires JWT auth)
A2: JWT authentication required — admin can only setup TOTP for themselves.
"""
# Security: only allow setting up TOTP for the authenticated admin
if payload.admin_id != admin.id:
raise HTTPException(status_code=403, detail="Can only setup TOTP for yourself")
result = await service.setup_totp(admin.id)
if not result["success"]:
raise HTTPException(status_code=400, detail=result.get("reason", "Setup failed"))
return result
@router.post("/totp/enable")
async def enable_totp(
payload: TotpVerifyRequest,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
):
"""Enable TOTP after verification (requires JWT auth)
A2: JWT authentication required — admin can only enable TOTP for themselves.
"""
if payload.admin_id != admin.id:
raise HTTPException(status_code=403, detail="Can only enable TOTP for yourself")
result = await service.enable_totp(admin.id, payload.totp_code)
if not result["success"]:
raise HTTPException(status_code=400, detail=result.get("message", "Enable failed"))
return result
@router.post("/totp/disable")
async def disable_totp(
payload: TotpSetupRequest,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
):
"""Disable TOTP for admin user (requires JWT auth)
A2: JWT authentication required — admin can only disable TOTP for themselves.
"""
if payload.admin_id != admin.id:
raise HTTPException(status_code=403, detail="Can only disable TOTP for yourself")
result = await service.disable_totp(admin.id)
return result
@router.put("/password")
async def change_password(
payload: ChangePasswordRequest,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Change the current admin's password (requires current password verification)"""
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
admin_repo = AdminRepositoryImpl(db)
current_admin = await admin_repo.get_by_id(admin.id)
if not current_admin:
raise HTTPException(status_code=404, detail="Admin not found")
# Verify current password
if not bcrypt.checkpw(payload.current_password.encode(), current_admin.password_hash.encode()):
raise HTTPException(status_code=400, detail="当前密码错误")
# Hash new password
new_hash = bcrypt.hashpw(payload.new_password.encode(), bcrypt.gensalt()).decode()
current_admin.password_hash = new_hash
# Security: invalidate all existing sessions (refresh tokens + JWT updated_at check)
current_admin.token_version += 1
current_admin.jwt_refresh_token = None
current_admin.jwt_token_expires = None
await admin_repo.update(current_admin)
# Audit log
request_ip = ""
try:
from fastapi import Request
# IP will be added by the caller context if available
except Exception:
pass
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="change_password",
target_type="admin",
target_id=admin.id,
detail=f"Password changed for {admin.username} (all sessions invalidated)",
ip_address="",
))
return {"success": True, "message": "密码已修改,请重新登录"}
@router.get("/me")
async def get_me(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Get current authenticated admin info (JWT required)"""
# Include system_name from settings for frontend branding
system_name = None
try:
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
repo = SettingRepositoryImpl(db)
system_name = await repo.get("system_name")
except Exception:
pass
return {
"id": admin.id,
"username": admin.username,
"email": admin.email,
"totp_enabled": admin.totp_enabled,
"is_active": admin.is_active,
"last_login": str(admin.last_login) if admin.last_login else None,
"system_name": system_name,
}