feat: comprehensive test infrastructure improvements
- Fix unit test imports: add env setup in conftest.py before module imports - Add 24 new auth router tests (RBAC, preferences, password validation) - Add 16 new tests for litellm and chat_summary routers - Apply black formatting and ruff linting across codebase - Add pre-commit hooks configuration (black, ruff, file checks) - Increase CI coverage threshold from 40% to 50% Test Results: - 206 tests passing (91 unit + 115 integration) - Coverage: 58.79% on core modules - auth.py: 57% → 85%, litellm.py: 23% → 87%, chat_summary.py: 41% → 100% - auth_service: 96.51%, config: 100%, rbac: 93.48%
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
"""Core authentication endpoints: login, token refresh, user info, proxy auth."""
|
||||
from datetime import timedelta
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
|
||||
import jwt
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
import jwt
|
||||
import config
|
||||
|
||||
import auth_service as auth
|
||||
import logging
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -40,7 +42,7 @@ def _set_auth_response_tokens(response: Response, access_token: str, refresh_tok
|
||||
auth.set_auth_cookies(response, access_token, refresh_token)
|
||||
|
||||
|
||||
def _extract_refresh_token(request: Request, req: Optional[RefreshRequest] = None) -> str:
|
||||
def _extract_refresh_token(request: Request, req: RefreshRequest | None = None) -> str:
|
||||
cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
@@ -87,7 +89,7 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
||||
res = await client.post(
|
||||
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
data={"chat_id": config.TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "Markdown"},
|
||||
timeout=10.0
|
||||
timeout=10.0,
|
||||
)
|
||||
logger.info("Telegram alert sent, status: %s", res.status_code)
|
||||
except Exception as e:
|
||||
@@ -147,6 +149,7 @@ async def proxy_auth(request: Request, response: Response):
|
||||
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
||||
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
||||
from rbac import resolve_role
|
||||
|
||||
if not config.is_trusted_proxy(request.client.host):
|
||||
logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}")
|
||||
raise HTTPException(status_code=403, detail="Unauthorized proxy source")
|
||||
@@ -196,7 +199,7 @@ async def read_users_me(current_user=Depends(auth.get_current_user)):
|
||||
|
||||
@router.post("/refresh")
|
||||
@limiter.limit("10/minute")
|
||||
async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
|
||||
async def refresh_token(request: Request, response: Response, req: RefreshRequest | None = Body(default=None)):
|
||||
refresh_token_value = _extract_refresh_token(request, req)
|
||||
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
|
||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
||||
@@ -232,6 +235,7 @@ async def rbac_config(current_user=Depends(auth.get_current_user)):
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import load_rbac
|
||||
|
||||
return load_rbac()
|
||||
|
||||
|
||||
@@ -240,6 +244,7 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import update_rbac
|
||||
|
||||
body = await request.json()
|
||||
pages = body.get("pages")
|
||||
if pages is None:
|
||||
@@ -257,6 +262,7 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend
|
||||
@router.get("/preferences")
|
||||
async def get_preferences(current_user=Depends(auth.get_current_user)):
|
||||
from rbac import get_sidebar_order
|
||||
|
||||
order = get_sidebar_order(current_user.username)
|
||||
return {"sidebar_order": order}
|
||||
|
||||
@@ -264,7 +270,9 @@ async def get_preferences(current_user=Depends(auth.get_current_user)):
|
||||
@router.put("/preferences")
|
||||
async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)):
|
||||
import traceback
|
||||
|
||||
from rbac import save_sidebar_order
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
order = body.get("sidebar_order")
|
||||
@@ -298,6 +306,7 @@ async def rbac_update_role(role: str, request: Request, current_user=Depends(aut
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import update_rbac
|
||||
|
||||
body = await request.json()
|
||||
pages = body.get("pages")
|
||||
sidebar_links = body.get("sidebar_links")
|
||||
|
||||
@@ -10,6 +10,7 @@ router = APIRouter()
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_docker_client():
|
||||
"""Get or create Docker client (lazy initialization)."""
|
||||
global _client
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from config import CHAT_SUMMARY_DB
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _db():
|
||||
return aiosqlite.connect(CHAT_SUMMARY_DB)
|
||||
|
||||
|
||||
@router.get("/dates")
|
||||
async def list_dates():
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute("SELECT date FROM summaries ORDER BY date DESC")
|
||||
return [r[0] for r in await cursor.fetchall()]
|
||||
|
||||
|
||||
@router.get("/summary/{date}")
|
||||
async def get_summary(date: str):
|
||||
async with await _db() as db:
|
||||
@@ -22,18 +26,21 @@ async def get_summary(date: str):
|
||||
raise HTTPException(404, "No summary for this date")
|
||||
return {"date": date, "content": row[0], "created_at": row[1]}
|
||||
|
||||
|
||||
@router.get("/messages/{date}")
|
||||
async def get_messages(date: str):
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT group_name, sender_name, text, timestamp FROM messages WHERE date(timestamp)=? ORDER BY timestamp",
|
||||
(date,)
|
||||
(date,),
|
||||
)
|
||||
return [{"group": r[0], "sender": r[1], "text": r[2], "time": r[3]} for r in await cursor.fetchall()]
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
async def trigger_summary():
|
||||
import os
|
||||
|
||||
trigger_path = os.environ.get("CHAT_SUMMARY_TRIGGER", "/app/data/chat-summarizer/trigger")
|
||||
os.makedirs(os.path.dirname(trigger_path), exist_ok=True)
|
||||
with open(trigger_path, "w") as f:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import docker
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from config import DOCKER_HOST
|
||||
from rbac import require_admin
|
||||
|
||||
@@ -7,6 +8,7 @@ router = APIRouter()
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_docker_client():
|
||||
"""Get or create Docker client (lazy initialization)."""
|
||||
global _client
|
||||
|
||||
@@ -2,8 +2,10 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from config import VOLUME_ROOT
|
||||
from rbac import require_admin
|
||||
|
||||
@@ -49,7 +51,7 @@ def _is_safe_symlink(item: Path) -> bool:
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
name = os.path.basename(name)
|
||||
name = name.replace("\x00", "").replace("/", "").replace("\\", "")
|
||||
name = re.sub(r'\.\.+', '.', name)
|
||||
name = re.sub(r"\.\.+", ".", name)
|
||||
name = name.strip(". ")
|
||||
if not name:
|
||||
raise ValueError("invalid filename")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from config import GITEA_URL, GITEA_TOKEN
|
||||
|
||||
from config import GITEA_TOKEN, GITEA_URL
|
||||
|
||||
router = APIRouter()
|
||||
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
||||
_SAFE_NAME = re.compile(r'^[a-zA-Z0-9._-]+$')
|
||||
_SAFE_NAME = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
|
||||
|
||||
@router.get("/repos")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from config import LITELLM_URL, LITELLM_HEALTH_API_KEY
|
||||
|
||||
from config import LITELLM_HEALTH_API_KEY, LITELLM_URL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
"""
|
||||
OPC Agents Router
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import opc_db
|
||||
from rbac import require_page, User, require_admin
|
||||
from rbac import User, require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
system_prompt: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
system_prompt: str | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ExecutionApproval(BaseModel):
|
||||
approved: bool
|
||||
feedback: Optional[str] = None
|
||||
feedback: str | None = None
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
@@ -60,7 +61,7 @@ async def update_agent(
|
||||
name=updates.name,
|
||||
description=updates.description,
|
||||
system_prompt=updates.system_prompt,
|
||||
enabled=updates.enabled
|
||||
enabled=updates.enabled,
|
||||
)
|
||||
return agent
|
||||
except ValueError as e:
|
||||
@@ -75,30 +76,21 @@ async def trigger_agent(
|
||||
):
|
||||
"""Manually trigger agent execution"""
|
||||
user: User = request.state.user
|
||||
execution = await opc_db.create_agent_execution(
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
actor=user.username
|
||||
)
|
||||
execution = await opc_db.create_agent_execution(task_id=task_id, agent_id=agent_id, actor=user.username)
|
||||
return execution
|
||||
|
||||
|
||||
@router.get("/executions")
|
||||
async def list_executions(
|
||||
request: Request,
|
||||
task_id: Optional[int] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
task_id: int | None = None,
|
||||
agent_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""List agent executions"""
|
||||
user: User = request.state.user
|
||||
executions = await opc_db.get_agent_executions(
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
status=status,
|
||||
limit=limit
|
||||
)
|
||||
executions = await opc_db.get_agent_executions(task_id=task_id, agent_id=agent_id, status=status, limit=limit)
|
||||
return {"items": executions}
|
||||
|
||||
|
||||
@@ -125,10 +117,7 @@ async def approve_execution(
|
||||
user: User = request.state.user
|
||||
try:
|
||||
execution = await opc_db.approve_agent_execution(
|
||||
execution_id=execution_id,
|
||||
approved=approval.approved,
|
||||
approved_by=user.username,
|
||||
feedback=approval.feedback
|
||||
execution_id=execution_id, approved=approval.approved, approved_by=user.username, feedback=approval.feedback
|
||||
)
|
||||
return execution
|
||||
except ValueError as e:
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
"""
|
||||
OPC Projects and Invoicing Router
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, Request
|
||||
from typing import Optional
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import opc_db
|
||||
from rbac import User
|
||||
from services import pdf_service
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
client_id: Optional[int] = None
|
||||
description: str | None = None
|
||||
client_id: int | None = None
|
||||
|
||||
|
||||
class InvoiceGenerate(BaseModel):
|
||||
project_id: int
|
||||
client_id: int
|
||||
invoice_number: Optional[str] = None
|
||||
invoice_number: str | None = None
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
@@ -45,11 +47,16 @@ async def create_project(
|
||||
user: User = request.state.user
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO projects (name, description, client_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
""", project.name, project.description, project.client_id)
|
||||
""",
|
||||
project.name,
|
||||
project.description,
|
||||
project.client_id,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@@ -69,18 +76,23 @@ async def list_clients(
|
||||
async def create_client(
|
||||
request: Request,
|
||||
name: str,
|
||||
email: Optional[str] = None,
|
||||
company: Optional[str] = None,
|
||||
email: str | None = None,
|
||||
company: str | None = None,
|
||||
):
|
||||
"""Create a new client"""
|
||||
user: User = request.state.user
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO clients (name, email, company)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
""", name, email, company)
|
||||
""",
|
||||
name,
|
||||
email,
|
||||
company,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@@ -100,11 +112,14 @@ async def generate_invoice(
|
||||
client = dict(client_row)
|
||||
|
||||
# Get time entries for project
|
||||
time_rows = await conn.fetch("""
|
||||
time_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM time_entries
|
||||
WHERE project_id = $1 AND billable = TRUE
|
||||
ORDER BY started_at
|
||||
""", invoice.project_id)
|
||||
""",
|
||||
invoice.project_id,
|
||||
)
|
||||
|
||||
if not time_rows:
|
||||
raise HTTPException(status_code=400, detail="No billable time entries found")
|
||||
@@ -116,7 +131,7 @@ async def generate_invoice(
|
||||
project_id=invoice.project_id,
|
||||
client=client,
|
||||
time_entries=time_entries,
|
||||
invoice_number=invoice.invoice_number
|
||||
invoice_number=invoice.invoice_number,
|
||||
)
|
||||
|
||||
# Return PDF
|
||||
@@ -124,9 +139,7 @@ async def generate_invoice(
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}"
|
||||
}
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
@@ -146,5 +159,5 @@ async def get_project_time(
|
||||
"total_minutes": total_minutes,
|
||||
"total_hours": round(total_minutes / 60, 2),
|
||||
"billable_minutes": billable_minutes,
|
||||
"billable_hours": round(billable_minutes / 60, 2)
|
||||
"billable_hours": round(billable_minutes / 60, 2),
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
OPC Task Management Router
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import opc_db
|
||||
from rbac import require_page, User
|
||||
from rbac import User
|
||||
from routers import opc_ws
|
||||
|
||||
router = APIRouter()
|
||||
@@ -14,27 +16,27 @@ router = APIRouter()
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
status: str = "parking_lot"
|
||||
priority: str = "medium"
|
||||
project_id: Optional[int] = None
|
||||
assigned_to: Optional[str] = None
|
||||
project_id: int | None = None
|
||||
assigned_to: str | None = None
|
||||
assigned_type: str = "human"
|
||||
tags: Optional[List[str]] = None
|
||||
due_date: Optional[datetime] = None
|
||||
tags: list[str] | None = None
|
||||
due_date: datetime | None = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
project_id: Optional[int] = None
|
||||
assigned_to: Optional[str] = None
|
||||
assigned_type: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
due_date: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
priority: str | None = None
|
||||
project_id: int | None = None
|
||||
assigned_to: str | None = None
|
||||
assigned_type: str | None = None
|
||||
tags: list[str] | None = None
|
||||
due_date: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskMove(BaseModel):
|
||||
@@ -49,11 +51,11 @@ class TaskAssign(BaseModel):
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(
|
||||
request: Request,
|
||||
status: Optional[str] = Query(None),
|
||||
assigned_to: Optional[str] = Query(None),
|
||||
project_id: Optional[int] = Query(None),
|
||||
priority: Optional[str] = Query(None),
|
||||
tags: Optional[str] = Query(None),
|
||||
status: str | None = Query(None),
|
||||
assigned_to: str | None = Query(None),
|
||||
project_id: int | None = Query(None),
|
||||
priority: str | None = Query(None),
|
||||
tags: str | None = Query(None),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
@@ -67,7 +69,7 @@ async def list_tasks(
|
||||
priority=priority,
|
||||
tags=tag_list,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
offset=offset,
|
||||
)
|
||||
return {"items": tasks, "total": len(tasks)}
|
||||
|
||||
@@ -91,16 +93,12 @@ async def create_task(
|
||||
assigned_type=task.assigned_type,
|
||||
tags=task.tags,
|
||||
due_date=due_date,
|
||||
actor=user.username
|
||||
actor=user.username,
|
||||
)
|
||||
|
||||
# Start automatic timer if status is in_progress
|
||||
if task.status == "in_progress":
|
||||
await opc_db.start_time_entry(
|
||||
task_id=created_task["id"],
|
||||
project_id=task.project_id,
|
||||
actor=user.username
|
||||
)
|
||||
await opc_db.start_time_entry(task_id=created_task["id"], project_id=task.project_id, actor=user.username)
|
||||
|
||||
# Broadcast WebSocket update
|
||||
await opc_ws.broadcast_task_update(created_task["id"], "created", created_task)
|
||||
@@ -144,11 +142,7 @@ async def update_task(
|
||||
|
||||
# Start timer when moving to in_progress
|
||||
if old_status != "in_progress" and new_status == "in_progress":
|
||||
await opc_db.start_time_entry(
|
||||
task_id=task_id,
|
||||
project_id=current_task.get("project_id"),
|
||||
actor=user.username
|
||||
)
|
||||
await opc_db.start_time_entry(task_id=task_id, project_id=current_task.get("project_id"), actor=user.username)
|
||||
|
||||
# Stop timer when moving away from in_progress
|
||||
if old_status == "in_progress" and new_status != "in_progress":
|
||||
@@ -164,11 +158,7 @@ async def update_task(
|
||||
if "completed_at" in updates and updates["completed_at"] is not None:
|
||||
updates["completed_at"] = updates["completed_at"].replace(tzinfo=None)
|
||||
|
||||
updated_task = await opc_db.update_task(
|
||||
task_id=task_id,
|
||||
updates=updates,
|
||||
actor=user.username
|
||||
)
|
||||
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||
|
||||
# Broadcast WebSocket update
|
||||
await opc_ws.broadcast_task_update(task_id, "updated", updated_task)
|
||||
@@ -209,16 +199,14 @@ async def move_task(
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
old_status = current_task["status"]
|
||||
print(f"[Move] Task {task_id}: old_status={old_status}, new_status={move.status}, assigned_to={current_task.get('assigned_to')}")
|
||||
print(
|
||||
f"[Move] Task {task_id}: old_status={old_status}, new_status={move.status}, assigned_to={current_task.get('assigned_to')}"
|
||||
)
|
||||
|
||||
# Handle automatic time tracking
|
||||
if old_status != "in_progress" and move.status == "in_progress":
|
||||
print(f"[Move] Task {task_id} moving to in_progress, old_status={old_status}")
|
||||
await opc_db.start_time_entry(
|
||||
task_id=task_id,
|
||||
project_id=current_task.get("project_id"),
|
||||
actor=user.username
|
||||
)
|
||||
await opc_db.start_time_entry(task_id=task_id, project_id=current_task.get("project_id"), actor=user.username)
|
||||
|
||||
# Auto-assign to default agent (PM) if not already assigned
|
||||
if not current_task.get("assigned_to"):
|
||||
@@ -232,18 +220,12 @@ async def move_task(
|
||||
print(f"[Move] Task {task_id} assigned to agent {agent_id}, checking for existing execution")
|
||||
|
||||
# Check if there's already a pending execution
|
||||
existing_executions = await opc_db.get_agent_executions(
|
||||
task_id=task_id,
|
||||
status="pending",
|
||||
limit=1
|
||||
)
|
||||
existing_executions = await opc_db.get_agent_executions(task_id=task_id, status="pending", limit=1)
|
||||
|
||||
if not existing_executions:
|
||||
print(f"[Move] No pending execution found, creating one for agent {agent_id}")
|
||||
execution_id = await opc_db.create_agent_execution(
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
actor=user.username
|
||||
task_id=task_id, agent_id=agent_id, actor=user.username
|
||||
)
|
||||
print(f"[Move] Created agent execution {execution_id} for task {task_id}")
|
||||
else:
|
||||
@@ -255,11 +237,7 @@ async def move_task(
|
||||
if move.status == "done":
|
||||
updates["completed_at"] = datetime.utcnow().replace(tzinfo=None)
|
||||
|
||||
updated_task = await opc_db.update_task(
|
||||
task_id=task_id,
|
||||
updates=updates,
|
||||
actor=user.username
|
||||
)
|
||||
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||
return updated_task
|
||||
|
||||
|
||||
@@ -271,24 +249,13 @@ async def assign_task(
|
||||
):
|
||||
"""Assign task to human or agent"""
|
||||
user: User = request.state.user
|
||||
updates = {
|
||||
"assigned_to": assign.assigned_to,
|
||||
"assigned_type": assign.assigned_type
|
||||
}
|
||||
updates = {"assigned_to": assign.assigned_to, "assigned_type": assign.assigned_type}
|
||||
|
||||
updated_task = await opc_db.update_task(
|
||||
task_id=task_id,
|
||||
updates=updates,
|
||||
actor=user.username
|
||||
)
|
||||
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||
|
||||
# If assigning to agent, create execution record
|
||||
if assign.assigned_type == "agent":
|
||||
await opc_db.create_agent_execution(
|
||||
task_id=task_id,
|
||||
agent_id=assign.assigned_to,
|
||||
actor=user.username
|
||||
)
|
||||
await opc_db.create_agent_execution(task_id=task_id, agent_id=assign.assigned_to, actor=user.username)
|
||||
|
||||
return updated_task
|
||||
|
||||
@@ -313,8 +280,4 @@ async def get_task_time(
|
||||
user: User = request.state.user
|
||||
entries = await opc_db.get_time_entries(task_id=task_id)
|
||||
total_minutes = sum(e["duration_minutes"] for e in entries)
|
||||
return {
|
||||
"entries": entries,
|
||||
"total_minutes": total_minutes,
|
||||
"total_hours": round(total_minutes / 60, 2)
|
||||
}
|
||||
return {"entries": entries, "total_minutes": total_minutes, "total_hours": round(total_minutes / 60, 2)}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
"""
|
||||
OPC WebSocket Router - Real-time updates for tasks and agent executions
|
||||
"""
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
|
||||
from typing import Set, Any, Dict
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
import logging
|
||||
from auth_service import get_current_user_ws
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Active WebSocket connections
|
||||
active_connections: Set[WebSocket] = set()
|
||||
active_connections: set[WebSocket] = set()
|
||||
|
||||
|
||||
def serialize_datetime(obj: Any) -> Any:
|
||||
@@ -33,7 +32,7 @@ class ConnectionManager:
|
||||
"""Manages WebSocket connections"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: Set[WebSocket] = set()
|
||||
self.active_connections: set[WebSocket] = set()
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
"""Accept and register a new connection"""
|
||||
@@ -95,7 +94,7 @@ async def broadcast_task_update(task_id: int, action: str, task_data: dict):
|
||||
"task_id": task_id,
|
||||
"action": action, # created, updated, moved, deleted
|
||||
"data": serialized_data,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
await manager.broadcast(message)
|
||||
|
||||
@@ -110,7 +109,7 @@ async def broadcast_agent_execution(execution_id: int, status: str, execution_da
|
||||
"execution_id": execution_id,
|
||||
"status": status, # pending, running, completed, failed, pending_approval
|
||||
"data": serialized_data,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
await manager.broadcast(message)
|
||||
|
||||
@@ -121,6 +120,6 @@ async def broadcast_agent_status(agent_id: str, status: str, message_text: str =
|
||||
"type": "agent_status",
|
||||
"agent_id": agent_id,
|
||||
"status": status, # idle, working, error
|
||||
"message": message_text
|
||||
"message": message_text,
|
||||
}
|
||||
await manager.broadcast(message)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
"""WebAuthn Passkey authentication endpoints."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from webauthn import (
|
||||
generate_registration_options,
|
||||
verify_registration_response,
|
||||
generate_authentication_options,
|
||||
verify_authentication_response
|
||||
generate_registration_options,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url, options_to_json
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json
|
||||
|
||||
import auth_service as auth
|
||||
import config
|
||||
|
||||
@@ -23,6 +26,7 @@ limiter = Limiter(key_func=get_remote_address)
|
||||
# In-memory challenge store with TTL
|
||||
_challenges = {}
|
||||
|
||||
|
||||
def _store_challenge(challenge: bytes):
|
||||
"""Store a WebAuthn challenge with timestamp for TTL tracking."""
|
||||
_challenges[challenge] = time.time()
|
||||
@@ -32,12 +36,13 @@ def _store_challenge(challenge: bytes):
|
||||
for k in expired:
|
||||
_challenges.pop(k, None)
|
||||
|
||||
|
||||
def _get_challenge(client_data_b64: str) -> bytes:
|
||||
"""Extract and validate challenge from clientDataJSON."""
|
||||
if not client_data_b64:
|
||||
raise HTTPException(status_code=400, detail="Missing clientDataJSON")
|
||||
try:
|
||||
data = json.loads(base64url_to_bytes(client_data_b64).decode('utf-8'))
|
||||
data = json.loads(base64url_to_bytes(client_data_b64).decode("utf-8"))
|
||||
chal_bytes = base64url_to_bytes(data.get("challenge", ""))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid clientDataJSON")
|
||||
@@ -47,14 +52,12 @@ def _get_challenge(client_data_b64: str) -> bytes:
|
||||
raise HTTPException(status_code=400, detail="Challenge expired or invalid")
|
||||
return chal_bytes
|
||||
|
||||
|
||||
@router.post("/passkey/register/options")
|
||||
async def passkey_register_options(current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_register_options(current_user=Depends(auth.get_current_user)):
|
||||
"""Generate WebAuthn registration options for adding a new passkey."""
|
||||
existing = auth.load_passkey_credentials()
|
||||
exclude = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
||||
for c in existing
|
||||
]
|
||||
exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing]
|
||||
options = generate_registration_options(
|
||||
rp_id=config.WEBAUTHN_RP_ID,
|
||||
rp_name="NAS Dashboard",
|
||||
@@ -66,8 +69,9 @@ async def passkey_register_options(current_user = Depends(auth.get_current_user)
|
||||
_store_challenge(options.challenge)
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/passkey/register/verify")
|
||||
async def passkey_register_verify(request: Request, current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_register_verify(request: Request, current_user=Depends(auth.get_current_user)):
|
||||
"""Verify and save a new passkey registration."""
|
||||
body = await request.json()
|
||||
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
||||
@@ -81,15 +85,18 @@ async def passkey_register_verify(request: Request, current_user = Depends(auth.
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
auth.save_passkey_credential({
|
||||
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||
"sign_count": verification.sign_count,
|
||||
"name": body.get("name", "Passkey"),
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
})
|
||||
auth.save_passkey_credential(
|
||||
{
|
||||
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||
"sign_count": verification.sign_count,
|
||||
"name": body.get("name", "Passkey"),
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/passkey/login/options")
|
||||
@limiter.limit("10/minute")
|
||||
async def passkey_login_options(request: Request):
|
||||
@@ -98,10 +105,7 @@ async def passkey_login_options(request: Request):
|
||||
if not creds:
|
||||
raise HTTPException(status_code=404, detail="No passkeys registered")
|
||||
|
||||
allow = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
||||
for c in creds
|
||||
]
|
||||
allow = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in creds]
|
||||
options = generate_authentication_options(
|
||||
rp_id=config.WEBAUTHN_RP_ID,
|
||||
allow_credentials=allow,
|
||||
@@ -110,6 +114,7 @@ async def passkey_login_options(request: Request):
|
||||
_store_challenge(options.challenge)
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/passkey/login/verify")
|
||||
@limiter.limit("10/minute")
|
||||
async def passkey_login_verify(request: Request, response: Response):
|
||||
@@ -152,26 +157,24 @@ async def passkey_login_verify(request: Request, response: Response):
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": username,
|
||||
"role": "admin"
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/passkey/list")
|
||||
async def passkey_list(current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_list(current_user=Depends(auth.get_current_user)):
|
||||
"""List all registered passkeys for the current user."""
|
||||
creds = auth.load_passkey_credentials()
|
||||
return {
|
||||
"passkeys": [
|
||||
{
|
||||
"id": c["credential_id"],
|
||||
"name": c.get("name", "Passkey"),
|
||||
"created_at": c.get("created_at", "")
|
||||
}
|
||||
{"id": c["credential_id"], "name": c.get("name", "Passkey"), "created_at": c.get("created_at", "")}
|
||||
for c in creds
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/passkey/delete")
|
||||
async def passkey_delete(request: Request, current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_delete(request: Request, current_user=Depends(auth.get_current_user)):
|
||||
"""Delete a specific passkey by credential ID."""
|
||||
body = await request.json()
|
||||
cred_id = body.get("id")
|
||||
@@ -181,8 +184,9 @@ async def passkey_delete(request: Request, current_user = Depends(auth.get_curre
|
||||
auth._save_auth_data(data)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/passkey/clear")
|
||||
async def passkey_clear(current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_clear(current_user=Depends(auth.get_current_user)):
|
||||
"""Clear all passkeys for the current user."""
|
||||
auth.clear_passkey_credentials()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import docker
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta, time
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
|
||||
import docker
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from config import DOCKER_HOST
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_docker_client():
|
||||
"""Get or create Docker client (lazy initialization)."""
|
||||
global _client
|
||||
@@ -17,6 +20,7 @@ def get_docker_client():
|
||||
_client = docker.DockerClient(base_url=DOCKER_HOST, timeout=10)
|
||||
return _client
|
||||
|
||||
|
||||
_tz_cst = timezone(timedelta(hours=8))
|
||||
|
||||
# Suspicious path patterns (scanners, exploits, etc.)
|
||||
@@ -67,9 +71,8 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]:
|
||||
level = obj.get("level", "")
|
||||
msg = obj.get("msg", "")
|
||||
|
||||
is_failed = (
|
||||
level in ("error", "warning")
|
||||
and any(kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied"))
|
||||
is_failed = level in ("error", "warning") and any(
|
||||
kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied")
|
||||
)
|
||||
# Also catch "not authorized" for anonymous access attempts
|
||||
is_unauthorized = "is not authorized" in msg.lower()
|
||||
@@ -83,14 +86,16 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]:
|
||||
except Exception:
|
||||
ts = ts_raw[:19] if ts_raw else ""
|
||||
|
||||
entries.append({
|
||||
"type": "auth_failure" if is_failed else "unauthorized",
|
||||
"timestamp": ts,
|
||||
"message": msg,
|
||||
"username": obj.get("username", obj.get("user", "")),
|
||||
"ip": obj.get("remote_ip", obj.get("ip", "")),
|
||||
"level": level,
|
||||
})
|
||||
entries.append(
|
||||
{
|
||||
"type": "auth_failure" if is_failed else "unauthorized",
|
||||
"timestamp": ts,
|
||||
"message": msg,
|
||||
"username": obj.get("username", obj.get("user", "")),
|
||||
"ip": obj.get("remote_ip", obj.get("ip", "")),
|
||||
"level": level,
|
||||
}
|
||||
)
|
||||
|
||||
return entries[-limit:]
|
||||
|
||||
@@ -122,18 +127,24 @@ def _parse_caddy_logs(lines: str, limit: int) -> list[dict]:
|
||||
if isinstance(ts_raw, (int, float)):
|
||||
ts = datetime.fromtimestamp(ts_raw, tz=_tz_cst).strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")).astimezone(_tz_cst).strftime("%Y-%m-%d %H:%M:%S")
|
||||
ts = (
|
||||
datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
||||
.astimezone(_tz_cst)
|
||||
.strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
except Exception:
|
||||
ts = str(ts_raw)[:19]
|
||||
|
||||
entries.append({
|
||||
"type": "suspicious_request",
|
||||
"timestamp": ts,
|
||||
"message": f"{status} {obj.get('request', {}).get('method', 'GET')} {uri}",
|
||||
"ip": remote_ip,
|
||||
"status": status,
|
||||
"path": uri,
|
||||
})
|
||||
entries.append(
|
||||
{
|
||||
"type": "suspicious_request",
|
||||
"timestamp": ts,
|
||||
"message": f"{status} {obj.get('request', {}).get('method', 'GET')} {uri}",
|
||||
"ip": remote_ip,
|
||||
"status": status,
|
||||
"path": uri,
|
||||
}
|
||||
)
|
||||
|
||||
return entries[-limit:]
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import os
|
||||
import shutil
|
||||
import psutil
|
||||
import platform
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query, Request, HTTPException
|
||||
import psutil
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
import config
|
||||
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN
|
||||
from config import OPENCLAW_GATEWAY_TOKEN, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -36,7 +38,15 @@ def system_stats():
|
||||
seen_devices.add(mount.device)
|
||||
try:
|
||||
u = shutil.disk_usage(mount.mountpoint)
|
||||
volumes.append({"mount": mount.mountpoint, "total": u.total, "used": u.used, "free": u.free, "percent": round(u.used / u.total * 100, 1)})
|
||||
volumes.append(
|
||||
{
|
||||
"mount": mount.mountpoint,
|
||||
"total": u.total,
|
||||
"used": u.used,
|
||||
"free": u.free,
|
||||
"percent": round(u.used / u.total * 100, 1),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
mem = psutil.virtual_memory()
|
||||
@@ -90,7 +100,18 @@ def audit_log(lines: int = Query(100, le=500)):
|
||||
level = _classify(method, path, status, user)
|
||||
if level == "noise":
|
||||
continue
|
||||
entries.append({"ts": ts, "ip": ip, "user": user, "method": method, "path": path, "status": status, "duration": dur, "level": level})
|
||||
entries.append(
|
||||
{
|
||||
"ts": ts,
|
||||
"ip": ip,
|
||||
"user": user,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"status": status,
|
||||
"duration": dur,
|
||||
"level": level,
|
||||
}
|
||||
)
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import asyncio
|
||||
import struct
|
||||
import logging
|
||||
import shlex
|
||||
import jwt
|
||||
import struct
|
||||
from collections import Counter
|
||||
from fastapi import WebSocket, Query
|
||||
|
||||
import asyncssh
|
||||
import config
|
||||
import jwt
|
||||
from fastapi import Query, WebSocket
|
||||
|
||||
import auth_service as auth
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,16 +63,18 @@ def build_host_command(host_config: dict) -> str | None:
|
||||
f"bash -l -c 'cd /repos/nas-tools && tmux new-session -A -s {quoted_session}'"
|
||||
)
|
||||
|
||||
script = " ".join([
|
||||
"if",
|
||||
f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;",
|
||||
"then",
|
||||
f"printf '%sattached\\n' {quoted_marker};",
|
||||
"else",
|
||||
f"printf '%screated\\n' {quoted_marker};",
|
||||
"fi;",
|
||||
f"exec {attach_cmd}",
|
||||
])
|
||||
script = " ".join(
|
||||
[
|
||||
"if",
|
||||
f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;",
|
||||
"then",
|
||||
f"printf '%sattached\\n' {quoted_marker};",
|
||||
"else",
|
||||
f"printf '%screated\\n' {quoted_marker};",
|
||||
"fi;",
|
||||
f"exec {attach_cmd}",
|
||||
]
|
||||
)
|
||||
return f"bash -lc {shlex.quote(script)}"
|
||||
|
||||
|
||||
@@ -140,15 +144,17 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
try:
|
||||
conn = await asyncio.wait_for(
|
||||
asyncssh.connect(
|
||||
h["host"], username=h["user"],
|
||||
client_keys=[h["key"]], known_hosts=_known_hosts,
|
||||
h["host"],
|
||||
username=h["user"],
|
||||
client_keys=[h["key"]],
|
||||
known_hosts=_known_hosts,
|
||||
keepalive_interval=15,
|
||||
keepalive_count_max=8,
|
||||
),
|
||||
timeout=10.0
|
||||
timeout=10.0,
|
||||
)
|
||||
logger.info("SSH connection established to %s", host)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
logger.error("SSH connection to %s timed out after 10s", host)
|
||||
await _release_session(session_id)
|
||||
await websocket.close(code=1011, reason="SSH connection timeout")
|
||||
@@ -161,7 +167,9 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
|
||||
try:
|
||||
process = await conn.create_process(
|
||||
build_host_command(h), term_type="xterm-256color", term_size=(80, 24),
|
||||
build_host_command(h),
|
||||
term_type="xterm-256color",
|
||||
term_size=(80, 24),
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
@@ -212,7 +220,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
read_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(read_task, timeout=2.0)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
except (TimeoutError, asyncio.CancelledError):
|
||||
pass
|
||||
process.close()
|
||||
finally:
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
"""TOTP (Time-based One-Time Password) 2FA endpoints."""
|
||||
|
||||
import pyotp
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import pyotp
|
||||
|
||||
import auth_service as auth
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class Setup2FAResponse(BaseModel):
|
||||
secret: str
|
||||
uri: str
|
||||
|
||||
|
||||
class Verify2FARequest(BaseModel):
|
||||
secret: str
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
||||
async def generate_2fa(current_user = Depends(auth.get_current_user)):
|
||||
async def generate_2fa(current_user=Depends(auth.get_current_user)):
|
||||
"""Generate a new TOTP secret and provisioning URI for 2FA setup."""
|
||||
secret = pyotp.random_base32()
|
||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(
|
||||
name=current_user.username,
|
||||
issuer_name="NAS Dashboard"
|
||||
)
|
||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user.username, issuer_name="NAS Dashboard")
|
||||
return {"secret": secret, "uri": uri}
|
||||
|
||||
|
||||
@router.post("/setup-2fa/verify")
|
||||
async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(auth.get_current_user)):
|
||||
async def verify_2fa_setup(request: Verify2FARequest, current_user=Depends(auth.get_current_user)):
|
||||
"""Verify TOTP code and enable 2FA for the user."""
|
||||
totp = pyotp.TOTP(request.secret)
|
||||
if not totp.verify(request.code):
|
||||
@@ -34,8 +37,9 @@ async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(aut
|
||||
auth.save_totp_secret(request.secret)
|
||||
return {"message": "2FA enabled successfully"}
|
||||
|
||||
|
||||
@router.post("/setup-2fa/disable")
|
||||
async def disable_2fa(current_user = Depends(auth.get_current_user)):
|
||||
async def disable_2fa(current_user=Depends(auth.get_current_user)):
|
||||
"""Disable 2FA for the user."""
|
||||
auth.save_totp_secret("")
|
||||
return {"message": "2FA disabled"}
|
||||
|
||||
Reference in New Issue
Block a user