Files
Nexus/server/api/auth.py
T
Your Name d519113734 fix: Gate3 health plain text, refresh empty body, prune underscore chunks
test_api: assert /health returns plain text ok instead of JSON parse.

auth: RefreshRequest.refresh_token optional so SPA POST {} is not 422.

prune: allow leading underscore in asset names to avoid deleting _plugin-vue_export-helper.

Add run_test_api_on_server.sh and deployment follow-up changelog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:41:28 +08:00

375 lines
13 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.
Security: Refresh token stored in HttpOnly + Secure + SameSite=Lax cookie.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
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, JWT_REFRESH_TOKEN_EXPIRE_DAYS
from server.domain.models import Admin, AuditLog
router = APIRouter(prefix="/api/auth", tags=["auth"])
logger = logging.getLogger("nexus.auth")
# ── Refresh Token Cookie Helpers ──
REFRESH_COOKIE_NAME = "nexus_refresh"
REFRESH_COOKIE_PATH = "/"
def _is_secure_request(request: Request) -> bool:
"""Determine if the request is over HTTPS (direct or via reverse proxy)."""
forwarded_proto = request.headers.get("X-Forwarded-Proto", "").lower()
if forwarded_proto == "https":
return True
return request.url.scheme == "https"
def _set_refresh_cookie(response: Response, refresh_token: str, request: Request):
"""Set HttpOnly cookie for refresh token.
- HttpOnly: not accessible from JavaScript (XSS protection)
- Secure: only sent over HTTPS
- SameSite=Lax: not sent on cross-site POST (CSRF protection)
- Path=/: sent to all paths (needed by AppAuthMiddleware on /app/ pages)
"""
response.set_cookie(
key=REFRESH_COOKIE_NAME,
value=refresh_token,
max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400,
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
path=REFRESH_COOKIE_PATH,
)
def _clear_refresh_cookie(response: Response, request: Request):
"""Clear the refresh token cookie. Path and flags must match the original set_cookie."""
response.delete_cookie(
key=REFRESH_COOKIE_NAME,
path=REFRESH_COOKIE_PATH,
secure=_is_secure_request(request),
httponly=True,
samesite="lax",
)
# ── 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):
"""Body optional — refresh token is read from HttpOnly cookie first."""
refresh_token: Optional[str] = Field(default=None, min_length=1)
class LogoutRequest(BaseModel):
refresh_token: Optional[str] = None
class TotpSetupRequest(BaseModel):
admin_id: int
class TotpDisableRequest(BaseModel):
admin_id: int
current_password: str = Field(..., min_length=1, max_length=255)
totp_code: Optional[str] = Field(None, min_length=6, max_length=6)
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)
totp_code: Optional[str] = Field(None, min_length=6, max_length=6) # S-09: required if TOTP enabled
class WebsshTokenRequest(BaseModel):
server_id: int = Field(..., ge=1)
# ── Public Routes (no JWT required) ──
@router.post("/login")
async def login(
request: Request,
response: Response,
payload: LoginRequest,
service: AuthService = Depends(get_auth_service),
):
"""Authenticate admin user (password + optional TOTP) → return JWT tokens
Security: Refresh token is set as HttpOnly cookie (not in JSON body).
Access token is returned in JSON for client-side Bearer auth.
"""
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"))
# Set refresh token as HttpOnly cookie (not accessible from JS)
_set_refresh_cookie(response, result["refresh_token"], request)
# Return access token + admin info (NOT refresh_token — it's in the cookie)
return {
"success": True,
"access_token": result["access_token"],
"token_type": "bearer",
"expires_in": result["expires_in"],
"admin": result["admin"],
}
@router.post("/refresh")
async def refresh_token(
request: Request,
response: Response,
payload: RefreshRequest | None = None,
service: AuthService = Depends(get_auth_service),
):
"""Exchange refresh token for new access token (with rotation + version-based reuse detection)
Security: Refresh token read from HttpOnly cookie (preferred) or request body (fallback).
SameSite=Lax cookie prevents CSRF on cross-site POST requests.
"""
# Read refresh token from cookie (primary) or request body (fallback)
refresh_token = request.cookies.get(REFRESH_COOKIE_NAME)
if not refresh_token and payload and payload.refresh_token:
refresh_token = payload.refresh_token
if not refresh_token:
raise HTTPException(status_code=401, detail="Missing refresh token")
ip_address = request.client.host if request.client else ""
result = await service.refresh_token(refresh_token, ip_address=ip_address)
if not result["success"]:
# Clear the cookie on failure (invalid/expired token)
_clear_refresh_cookie(response, request)
raise HTTPException(status_code=401, detail=result.get("message", "Invalid refresh token"))
# Rotate: set new refresh token cookie
_set_refresh_cookie(response, result["refresh_token"], request)
return {
"success": True,
"access_token": result["access_token"],
"token_type": "bearer",
"expires_in": result["expires_in"],
}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
service: AuthService = Depends(get_auth_service),
):
"""Invalidate refresh token (read from HttpOnly cookie) and clear cookie.
Security: No request body needed — refresh token comes from cookie.
"""
refresh_token = request.cookies.get(REFRESH_COOKIE_NAME)
if refresh_token:
await service.logout_by_token(refresh_token)
# Always clear the cookie (even if not found, for defense in depth)
_clear_refresh_cookie(response, request)
return {"success": True, "message": "已登出"}
# ── 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: TotpDisableRequest,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
):
"""Disable TOTP — requires current password + TOTP code when enabled."""
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,
current_password=payload.current_password,
totp_code=payload.totp_code,
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "Disable failed"))
return result
@router.put("/password")
async def change_password(
request: Request,
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="当前密码错误")
# S-09: If TOTP is enabled, require TOTP code for password change
if current_admin.totp_enabled:
if not payload.totp_code:
raise HTTPException(status_code=400, detail="已启用 TOTP 的账户修改密码需要验证码")
from server.application.services.auth_service import AuthService
if not AuthService._verify_totp(payload.totp_code, current_admin.totp_secret):
raise HTTPException(status_code=400, detail="TOTP 验证码错误")
# 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 token_version)
current_admin.token_version += 1
await admin_repo.update(current_admin)
# Clear all refresh tokens from Redis (all devices logged out)
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
await redis.delete(f"refresh_tokens:{admin.id}")
except Exception:
logger.warning(f"Failed to clear refresh tokens from Redis for admin {admin.id}", exc_info=True)
# Audit log with client IP
ip_address = request.client.host if request.client else ""
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"{admin.username} 修改密码(所有会话已失效)",
ip_address=ip_address,
))
return {"success": True, "message": "密码已修改,请重新登录"}
@router.post("/webssh-token")
async def issue_webssh_token(
payload: WebsshTokenRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
db: AsyncSession = Depends(get_db),
):
"""Issue a short-lived JWT bound to one server for WebSSH (P0 IDOR fix)."""
from server.infrastructure.database.server_repo import ServerRepositoryImpl
server_repo = ServerRepositoryImpl(db)
server = await server_repo.get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.domain:
raise HTTPException(status_code=400, detail="Server has no domain configured")
result = await service.create_webssh_token(admin, payload.server_id)
return {**result, "server_name": server.name}
@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 as e:
logger.warning(f"Failed to load system_name in /me: {e}")
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,
}