Files
Nexus/server/api/auth.py
T
Your Name 152852e2c3 fix: 会话时效30天 + 多设备同时登录
- access token 从 30 分钟延长到 30 天
- refresh token 从 7 天延长到 30 天
- refresh token 从 MySQL 单字段改为 Redis Set 存储,支持多设备同时登录
- 单设备退出不再踢掉其他设备(只删自己的 Redis hash)
- 改密码/禁用 TOTP 仍会让所有设备掉线(token_version 递增)
- 移除 reuse 误杀逻辑:版本不匹配只拒绝当前 token,不惩罚其他设备

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:52:14 +08:00

365 lines
12 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 = "/api/auth"
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=/api/auth: only sent to auth endpoints (minimal exposure)
"""
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):
refresh_token: str = Field(..., 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)
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: Optional[RefreshRequest] = 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="当前密码错误")
# 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")
ip_address = request.client.host if request.client else ""
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,
}