feat: implement HMAC-based JWT authentication and secure API endpoints with dependency injection
This commit is contained in:
@@ -11,12 +11,17 @@ else:
|
|||||||
# Fallback to load default .env if any variables are not yet defined
|
# Fallback to load default .env if any variables are not yet defined
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
app = FastAPI(title="RMM Central API")
|
app = FastAPI(title="RMM Central API")
|
||||||
|
|
||||||
@@ -29,6 +34,58 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# JWT-like HMAC Secure Token Utilities
|
||||||
|
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "seekright_rmm_secret_key_2026_default")
|
||||||
|
|
||||||
|
def generate_token(username: str) -> str:
|
||||||
|
# Expire in 1 day (24 hours)
|
||||||
|
expiry = (datetime.utcnow() + timedelta(days=1)).isoformat()
|
||||||
|
payload = {
|
||||||
|
"username": username,
|
||||||
|
"expires": expiry
|
||||||
|
}
|
||||||
|
payload_json = json.dumps(payload)
|
||||||
|
payload_b64 = base64.urlsafe_b64encode(payload_json.encode('utf-8')).decode('utf-8').rstrip('=')
|
||||||
|
|
||||||
|
sig = hmac.new(JWT_SECRET_KEY.encode('utf-8'), payload_b64.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||||
|
return f"{payload_b64}.{sig}"
|
||||||
|
|
||||||
|
def verify_token(token: str) -> bool:
|
||||||
|
try:
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 2:
|
||||||
|
return False
|
||||||
|
payload_b64, sig = parts
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
expected_sig = hmac.new(JWT_SECRET_KEY.encode('utf-8'), payload_b64.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(sig, expected_sig):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Decode and check expiry
|
||||||
|
padding = 4 - (len(payload_b64) % 4)
|
||||||
|
if padding < 4:
|
||||||
|
payload_b64 += "=" * padding
|
||||||
|
payload_json = base64.urlsafe_b64decode(payload_b64.encode('utf-8')).decode('utf-8')
|
||||||
|
payload = json.loads(payload_json)
|
||||||
|
|
||||||
|
expires_dt = datetime.fromisoformat(payload["expires"])
|
||||||
|
if datetime.utcnow() > expires_dt:
|
||||||
|
return False # Expired
|
||||||
|
|
||||||
|
return payload["username"] == "root"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# FastAPI dependency to secure UI endpoints
|
||||||
|
async def get_current_user(authorization: str = Header(None)):
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing or invalid authentication credentials")
|
||||||
|
token = authorization.split(" ")[1]
|
||||||
|
if not verify_token(token):
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication token is invalid or has expired")
|
||||||
|
return "root"
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -239,6 +296,17 @@ class CommandResultPayload(BaseModel):
|
|||||||
stdout: str
|
stdout: str
|
||||||
stderr: str
|
stderr: str
|
||||||
|
|
||||||
|
class LoginPayload(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@app.post("/api/login")
|
||||||
|
async def login(payload: LoginPayload):
|
||||||
|
if payload.username == "root" and payload.password == "seekright159@":
|
||||||
|
token = generate_token("root")
|
||||||
|
return {"token": token}
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
@app.get("/api/get-commands")
|
@app.get("/api/get-commands")
|
||||||
async def get_whitelisted_commands(platform: str):
|
async def get_whitelisted_commands(platform: str):
|
||||||
"""
|
"""
|
||||||
@@ -284,7 +352,7 @@ async def get_command(client_id: str):
|
|||||||
return {"command": cmd}
|
return {"command": cmd}
|
||||||
|
|
||||||
@app.post("/api/schedule-command")
|
@app.post("/api/schedule-command")
|
||||||
async def schedule_command(client_id: str, command: str):
|
async def schedule_command(client_id: str, command: str, current_user: str = Depends(get_current_user)):
|
||||||
"""
|
"""
|
||||||
Endpoint for your Dashboard/UI to schedule a new command.
|
Endpoint for your Dashboard/UI to schedule a new command.
|
||||||
Supports a comma-separated list of client IDs for batch fleet updates.
|
Supports a comma-separated list of client IDs for batch fleet updates.
|
||||||
@@ -401,7 +469,7 @@ async def receive_telemetry(client_id: str, payload: TelemetryPayload):
|
|||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
@app.get("/api/telemetry")
|
@app.get("/api/telemetry")
|
||||||
async def get_all_telemetry():
|
async def get_all_telemetry(current_user: str = Depends(get_current_user)):
|
||||||
"""
|
"""
|
||||||
Endpoint to view the live dashboard data of all clients.
|
Endpoint to view the live dashboard data of all clients.
|
||||||
"""
|
"""
|
||||||
@@ -409,14 +477,14 @@ async def get_all_telemetry():
|
|||||||
return {cid: info.get("telemetry", {}) for cid, info in clients.items() if info.get("telemetry")}
|
return {cid: info.get("telemetry", {}) for cid, info in clients.items() if info.get("telemetry")}
|
||||||
|
|
||||||
@app.get("/api/clients")
|
@app.get("/api/clients")
|
||||||
async def get_clients_api():
|
async def get_clients_api(current_user: str = Depends(get_current_user)):
|
||||||
"""
|
"""
|
||||||
Endpoint for the React UI to fetch the live active client registry with computed statuses.
|
Endpoint for the React UI to fetch the live active client registry with computed statuses.
|
||||||
"""
|
"""
|
||||||
return load_clients()
|
return load_clients()
|
||||||
|
|
||||||
@app.get("/api/logs")
|
@app.get("/api/logs")
|
||||||
async def get_logs_history():
|
async def get_logs_history(current_user: str = Depends(get_current_user)):
|
||||||
"""
|
"""
|
||||||
Endpoint to retrieve logs history grouped by client_id.
|
Endpoint to retrieve logs history grouped by client_id.
|
||||||
"""
|
"""
|
||||||
@@ -453,7 +521,7 @@ async def receive_command_result(client_id: str, payload: CommandResultPayload):
|
|||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
@app.get("/api/get-raw-commands")
|
@app.get("/api/get-raw-commands")
|
||||||
async def get_raw_commands():
|
async def get_raw_commands(current_user: str = Depends(get_current_user)):
|
||||||
"""
|
"""
|
||||||
Endpoint for the UI to load the complete commands.json whitelist file.
|
Endpoint for the UI to load the complete commands.json whitelist file.
|
||||||
"""
|
"""
|
||||||
@@ -465,7 +533,7 @@ async def get_raw_commands():
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to read commands: {e}")
|
raise HTTPException(status_code=500, detail=f"Failed to read commands: {e}")
|
||||||
|
|
||||||
@app.post("/api/save-commands")
|
@app.post("/api/save-commands")
|
||||||
async def save_commands(commands: Dict[str, Any]):
|
async def save_commands(commands: Dict[str, Any], current_user: str = Depends(get_current_user)):
|
||||||
"""
|
"""
|
||||||
Endpoint for the UI to save changes back to commands.json.
|
Endpoint for the UI to save changes back to commands.json.
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user