feat: Nexus 6.0 6-step implementation (Step 0-5)
Step 0: Infrastructure hardening — Redis BlockingConnectionPool, WebSocket two-layer (memory+Redis Pub/Sub), JWT auth, DB session leak fix Step 1: Data layer — 4 new tables (platforms/nodes/command_logs/ssh_sessions), data migration (category→Node), repos + API routes Step 2: Auth layer — JWT middleware on all routes, TOTP JWT integration Step 3: Web SSH — asyncssh connection pool, /ws/terminal endpoint, xterm.js frontend, Koko protocol Step 4: Sync engine v2 — file/command/config/SFTP modes, parallel execution Step 5: Frontend migration — 12 Tailwind CSS v4 + Alpine.js pages, PHP-FPM removal from nginx config 21 Python backend + 12 HTML frontend + 2 deploy configs + 3 test files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+108
-42
@@ -1,94 +1,160 @@
|
||||
"""Nexus — Auth API Routes (Login / TOTP / Session management)
|
||||
"""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 server.api.dependencies import get_auth_service
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.application.services.auth_service import AuthService
|
||||
from server.domain.models import Admin
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=dict)
|
||||
# ── 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):
|
||||
admin_id: int
|
||||
|
||||
|
||||
class TotpSetupRequest(BaseModel):
|
||||
admin_id: int
|
||||
|
||||
|
||||
class TotpVerifyRequest(BaseModel):
|
||||
admin_id: int
|
||||
totp_code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
# ── Public Routes (no JWT required) ──
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
payload: dict,
|
||||
payload: LoginRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Authenticate admin user (password + optional TOTP)
|
||||
|
||||
Body: {
|
||||
"username": "admin",
|
||||
"password": "...",
|
||||
"totp_code": "123456" // Optional, required if TOTP enabled
|
||||
}
|
||||
"""
|
||||
"""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.get("username", ""),
|
||||
password=payload.get("password", ""),
|
||||
username=payload.username,
|
||||
password=payload.password,
|
||||
ip_address=ip_address,
|
||||
totp_code=payload.get("totp_code"),
|
||||
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("/totp/setup", response_model=dict)
|
||||
async def setup_totp(
|
||||
payload: dict,
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(
|
||||
payload: RefreshRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Generate TOTP secret for admin user (requires valid session token)
|
||||
"""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
|
||||
|
||||
Body: {"admin_id": 1}
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
payload: LogoutRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Invalidate refresh token (client should also discard access token)"""
|
||||
result = await service.logout(payload.admin_id)
|
||||
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.
|
||||
"""
|
||||
# TODO: Verify session token from Authorization header
|
||||
admin_id = payload.get("admin_id")
|
||||
if not admin_id:
|
||||
raise HTTPException(status_code=400, detail="admin_id required")
|
||||
result = await service.setup_totp(admin_id)
|
||||
# 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", response_model=dict)
|
||||
@router.post("/totp/enable")
|
||||
async def enable_totp(
|
||||
payload: dict,
|
||||
payload: TotpVerifyRequest,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Enable TOTP after verification (requires valid session token)
|
||||
"""Enable TOTP after verification (requires JWT auth)
|
||||
|
||||
Body: {"admin_id": 1, "totp_code": "123456"}
|
||||
A2: JWT authentication required — admin can only enable TOTP for themselves.
|
||||
"""
|
||||
admin_id = payload.get("admin_id")
|
||||
totp_code = payload.get("totp_code")
|
||||
if not admin_id or not totp_code:
|
||||
raise HTTPException(status_code=400, detail="admin_id and totp_code required")
|
||||
result = await service.enable_totp(admin_id, totp_code)
|
||||
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", response_model=dict)
|
||||
@router.post("/totp/disable")
|
||||
async def disable_totp(
|
||||
payload: dict,
|
||||
payload: TotpSetupRequest,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""Disable TOTP for admin user (requires valid session token)
|
||||
"""Disable TOTP for admin user (requires JWT auth)
|
||||
|
||||
Body: {"admin_id": 1}
|
||||
A2: JWT authentication required — admin can only disable TOTP for themselves.
|
||||
"""
|
||||
admin_id = payload.get("admin_id")
|
||||
if not admin_id:
|
||||
raise HTTPException(status_code=400, detail="admin_id required")
|
||||
result = await service.disable_totp(admin_id)
|
||||
return result
|
||||
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.get("/me")
|
||||
async def get_me(admin: Admin = Depends(get_current_admin)):
|
||||
"""Get current authenticated admin info (JWT required)"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user