Files
Nexus/server/api/auth.py
T

391 lines
14 KiB
Python
Raw Normal View History

"""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
2026-05-22 22:16:50 +08:00
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
2026-05-22 22:16:50 +08:00
from server.domain.models import Admin, AuditLog
from server.utils.client_ip import get_client_ip
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)
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-30 22:34:45 +08:00
totp_code: Optional[str] = Field(None, min_length=6, max_length=6) # S-09: required if TOTP enabled
2026-05-22 22:16:50 +08:00
class WebsshTokenRequest(BaseModel):
server_id: int = Field(..., ge=1)
# ── Public Routes (no JWT required) ──
@router.get("/login-access")
async def login_access(request: Request):
"""Precheck login form visibility. Blocked IPs get 403 with empty body (no IP leak)."""
from server.utils.login_allowlist import check_login_ip
client_ip = get_client_ip(request)
allowlist_enabled, allowed, _message = check_login_ip(client_ip)
if allowlist_enabled and not allowed:
return Response(status_code=403)
return {"allowed": True}
@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 = get_client_ip(request)
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
elif result.get("reason") == "ip_blocked":
return Response(status_code=403)
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 = get_client_ip(request)
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
2026-05-22 22:16:50 +08:00
@router.put("/password")
async def change_password(
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, LoginAttemptRepositoryImpl
2026-05-22 22:16:50 +08:00
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.refresh_token_repo import RefreshTokenRepositoryImpl
2026-05-22 22:16:50 +08:00
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 验证码错误")
2026-05-22 22:16:50 +08:00
# 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 = (current_admin.token_version or 0) + 1
2026-05-22 22:16:50 +08:00
await admin_repo.update(current_admin)
audit_repo = AuditLogRepositoryImpl(db)
auth_service = AuthService(
admin_repo=admin_repo,
attempt_repo=LoginAttemptRepositoryImpl(db),
audit_repo=audit_repo,
refresh_token_repo=RefreshTokenRepositoryImpl(db),
)
await auth_service._delete_all_refresh_tokens(admin.id)
# Audit log with client IP
ip_address = get_client_ip(request)
2026-05-22 22:16:50 +08:00
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,
2026-05-22 22:16:50 +08:00
))
return {"success": True, "message": "密码已修改,请重新登录"}
2026-05-22 22:16:50 +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")
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,
}