2026-05-21 22:11:38 +08:00
|
|
|
"""Nexus — Auth API Routes (Login / TOTP / JWT / Refresh / Logout)
|
2026-05-20 14:42:55 +08:00
|
|
|
Presentation layer — receives HTTP requests, delegates to AuthService.
|
2026-05-21 22:11:38 +08:00
|
|
|
|
|
|
|
|
A2: TOTP endpoints now require JWT authentication via get_current_admin.
|
2026-05-20 14:42:55 +08:00
|
|
|
"""
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
import logging
|
2026-05-20 14:42:55 +08:00
|
|
|
from typing import Optional
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
2026-05-21 22:11:38 +08:00
|
|
|
from pydantic import BaseModel, Field
|
2026-05-22 09:54:19 +08:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-05-22 22:16:50 +08:00
|
|
|
import bcrypt
|
2026-05-20 14:42:55 +08:00
|
|
|
|
2026-05-22 09:54:19 +08:00
|
|
|
from server.api.dependencies import get_auth_service, get_db
|
2026-05-21 22:11:38 +08:00
|
|
|
from server.api.auth_jwt import get_current_admin
|
2026-05-20 14:42:55 +08:00
|
|
|
from server.application.services.auth_service import AuthService
|
2026-05-22 22:16:50 +08:00
|
|
|
from server.domain.models import Admin, AuditLog
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
logger = logging.getLogger("nexus.auth")
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
# ── 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):
|
2026-05-22 00:30:28 +08:00
|
|
|
refresh_token: Optional[str] = None
|
2026-05-21 22:11:38 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TotpSetupRequest(BaseModel):
|
|
|
|
|
admin_id: int
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
class TotpVerifyRequest(BaseModel):
|
|
|
|
|
admin_id: int
|
|
|
|
|
totp_code: str = Field(..., min_length=6, max_length=6)
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 22:16:50 +08:00
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
|
|
|
current_password: str = Field(..., min_length=1, max_length=255)
|
|
|
|
|
new_password: str = Field(..., min_length=6, max_length=255)
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
class WebsshTokenRequest(BaseModel):
|
|
|
|
|
server_id: int = Field(..., ge=1)
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
# ── Public Routes (no JWT required) ──
|
|
|
|
|
|
|
|
|
|
@router.post("/login")
|
2026-05-20 14:42:55 +08:00
|
|
|
async def login(
|
|
|
|
|
request: Request,
|
2026-05-21 22:11:38 +08:00
|
|
|
payload: LoginRequest,
|
2026-05-20 14:42:55 +08:00
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
):
|
2026-05-21 22:11:38 +08:00
|
|
|
"""Authenticate admin user (password + optional TOTP) → return JWT tokens"""
|
2026-05-20 14:42:55 +08:00
|
|
|
ip_address = request.client.host if request.client else "unknown"
|
|
|
|
|
result = await service.login(
|
2026-05-21 22:11:38 +08:00
|
|
|
username=payload.username,
|
|
|
|
|
password=payload.password,
|
2026-05-20 14:42:55 +08:00
|
|
|
ip_address=ip_address,
|
2026-05-21 22:11:38 +08:00
|
|
|
totp_code=payload.totp_code,
|
2026-05-20 14:42:55 +08:00
|
|
|
)
|
|
|
|
|
if not result["success"]:
|
|
|
|
|
status_code = 401
|
|
|
|
|
if result.get("reason") == "account_locked":
|
|
|
|
|
status_code = 429
|
2026-05-21 22:11:38 +08:00
|
|
|
elif result.get("reason") == "totp_required":
|
|
|
|
|
status_code = 202 # Accepted but needs TOTP
|
2026-05-20 14:42:55 +08:00
|
|
|
raise HTTPException(status_code=status_code, detail=result.get("message", "Login failed"))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
@router.post("/refresh")
|
|
|
|
|
async def refresh_token(
|
2026-05-23 15:26:56 +08:00
|
|
|
request: Request,
|
2026-05-21 22:11:38 +08:00
|
|
|
payload: RefreshRequest,
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
):
|
|
|
|
|
"""Exchange refresh token for new access + refresh token pair"""
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address = request.client.host if request.client else ""
|
|
|
|
|
result = await service.refresh_token(payload.refresh_token, ip_address=ip_address)
|
2026-05-21 22:11:38 +08:00
|
|
|
if not result["success"]:
|
|
|
|
|
raise HTTPException(status_code=401, detail=result.get("message", "Invalid refresh token"))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/logout")
|
|
|
|
|
async def logout(
|
2026-05-23 15:26:56 +08:00
|
|
|
request: Request,
|
2026-05-21 22:11:38 +08:00
|
|
|
payload: LogoutRequest,
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
):
|
|
|
|
|
"""Invalidate refresh token (client should also discard access token)"""
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address = request.client.host if request.client else ""
|
2026-05-22 00:30:28 +08:00
|
|
|
if payload.refresh_token:
|
|
|
|
|
result = await service.logout_by_token(payload.refresh_token)
|
|
|
|
|
else:
|
2026-05-22 23:04:40 +08:00
|
|
|
# No refresh token provided — only client-side cleanup possible
|
|
|
|
|
return {"success": True, "message": "客户端已登出(未提供刷新令牌,服务端会话仍有效)"}
|
2026-05-21 22:11:38 +08:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Protected Routes (JWT required) ──
|
|
|
|
|
|
|
|
|
|
@router.post("/totp/setup")
|
2026-05-20 14:42:55 +08:00
|
|
|
async def setup_totp(
|
2026-05-21 22:11:38 +08:00
|
|
|
payload: TotpSetupRequest,
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
2026-05-20 14:42:55 +08:00
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
):
|
2026-05-21 22:11:38 +08:00
|
|
|
"""Generate TOTP secret for admin user (requires JWT auth)
|
2026-05-20 14:42:55 +08:00
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
A2: JWT authentication required — admin can only setup TOTP for themselves.
|
2026-05-20 14:42:55 +08:00
|
|
|
"""
|
2026-05-21 22:11:38 +08:00
|
|
|
# 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)
|
2026-05-20 14:42:55 +08:00
|
|
|
if not result["success"]:
|
|
|
|
|
raise HTTPException(status_code=400, detail=result.get("reason", "Setup failed"))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
@router.post("/totp/enable")
|
2026-05-20 14:42:55 +08:00
|
|
|
async def enable_totp(
|
2026-05-21 22:11:38 +08:00
|
|
|
payload: TotpVerifyRequest,
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
2026-05-20 14:42:55 +08:00
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
):
|
2026-05-21 22:11:38 +08:00
|
|
|
"""Enable TOTP after verification (requires JWT auth)
|
2026-05-20 14:42:55 +08:00
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
A2: JWT authentication required — admin can only enable TOTP for themselves.
|
2026-05-20 14:42:55 +08:00
|
|
|
"""
|
2026-05-21 22:11:38 +08:00
|
|
|
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)
|
2026-05-20 14:42:55 +08:00
|
|
|
if not result["success"]:
|
|
|
|
|
raise HTTPException(status_code=400, detail=result.get("message", "Enable failed"))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
@router.post("/totp/disable")
|
2026-05-20 14:42:55 +08:00
|
|
|
async def disable_totp(
|
2026-05-23 15:26:56 +08:00
|
|
|
payload: TotpDisableRequest,
|
2026-05-21 22:11:38 +08:00
|
|
|
admin: Admin = Depends(get_current_admin),
|
2026-05-20 14:42:55 +08:00
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
):
|
2026-05-23 15:26:56 +08:00
|
|
|
"""Disable TOTP — requires current password + TOTP code when enabled."""
|
2026-05-21 22:11:38 +08:00
|
|
|
if payload.admin_id != admin.id:
|
|
|
|
|
raise HTTPException(status_code=403, detail="Can only disable TOTP for yourself")
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
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"))
|
2026-05-21 22:11:38 +08:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 22:16:50 +08:00
|
|
|
@router.put("/password")
|
|
|
|
|
async def change_password(
|
2026-05-23 15:26:56 +08:00
|
|
|
request: Request,
|
2026-05-22 22:16:50 +08:00
|
|
|
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
|
2026-05-22 23:04:40 +08:00
|
|
|
# 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
|
2026-05-22 22:16:50 +08:00
|
|
|
await admin_repo.update(current_admin)
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
# Audit log with client IP
|
|
|
|
|
ip_address = request.client.host if request.client else ""
|
2026-05-22 22:16:50 +08:00
|
|
|
audit_repo = AuditLogRepositoryImpl(db)
|
|
|
|
|
await audit_repo.create(AuditLog(
|
|
|
|
|
admin_username=admin.username,
|
|
|
|
|
action="change_password",
|
|
|
|
|
target_type="admin",
|
|
|
|
|
target_id=admin.id,
|
2026-05-22 23:04:40 +08:00
|
|
|
detail=f"Password changed for {admin.username} (all sessions invalidated)",
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address=ip_address,
|
2026-05-22 22:16:50 +08:00
|
|
|
))
|
|
|
|
|
|
2026-05-22 23:04:40 +08:00
|
|
|
return {"success": True, "message": "密码已修改,请重新登录"}
|
2026-05-22 22:16:50 +08:00
|
|
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
@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}
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
@router.get("/me")
|
2026-05-22 09:54:19 +08:00
|
|
|
async def get_me(
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
2026-05-21 22:11:38 +08:00
|
|
|
"""Get current authenticated admin info (JWT required)"""
|
2026-05-22 09:54:19 +08:00
|
|
|
# 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")
|
2026-05-23 15:26:56 +08:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Failed to load system_name in /me: {e}")
|
2026-05-22 09:54:19 +08:00
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
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,
|
2026-05-22 09:54:19 +08:00
|
|
|
"system_name": system_name,
|
2026-05-21 22:11:38 +08:00
|
|
|
}
|