feat: Add authentication endpoints and JWT support

- Implemented JWT authentication with auto-generated secret if not provided.
- Added API token management functionality.
- Created authentication endpoints for login, token refresh, verification, and password change.
- Introduced API documentation endpoints for Swagger UI and OpenAPI spec.
- Enhanced CORS support for API and documentation endpoints.
- Updated OpenAPI specification to include new authentication and system endpoints.
This commit is contained in:
Lloyd
2025-12-30 00:10:48 +00:00
parent 98b425f444
commit 7112da98c2
12 changed files with 2201 additions and 68 deletions
+4
View File
@@ -52,6 +52,10 @@ repeater:
# Allow read-only access for clients without password/not in ACL
allow_read_only: false
# JWT secret key for signing tokens (auto-generated if not provided)
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
jwt_secret: ""
# Mesh Network Configuration
mesh:
+10
View File
@@ -37,6 +37,7 @@ dependencies = [
"paho-mqtt>=1.6.0",
"cherrypy-cors==1.7.0",
"psutil>=5.9.0",
"pyjwt>=2.8.0",
]
@@ -56,6 +57,15 @@ pymc-repeater = "repeater.main:main"
[tool.setuptools]
packages = ["repeater"]
[tool.setuptools.package-data]
repeater = [
"web/html/*.html",
"web/html/*.ico",
"web/html/assets/**/*",
"web/*.yaml",
"web/*.html",
]
[tool.black]
line-length = 100
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
+117
View File
@@ -91,6 +91,16 @@ class SQLiteHandler:
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at REAL NOT NULL,
last_used REAL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_type ON packets(type)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(packet_hash)")
@@ -209,11 +219,118 @@ class SQLiteHandler:
)
logger.info(f"Migration '{migration_name}' applied successfully")
# Migration 3: Add api_tokens table
migration_name = "add_api_tokens_table"
existing = conn.execute(
"SELECT migration_name FROM migrations WHERE migration_name = ?",
(migration_name,)
).fetchone()
if not existing:
# Check if api_tokens table already exists
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'"
)
if not cursor.fetchone():
conn.execute("""
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at REAL NOT NULL,
last_used REAL
)
""")
logger.info("Created api_tokens table")
# Mark migration as applied
conn.execute(
"INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)",
(migration_name, time.time())
)
logger.info(f"Migration '{migration_name}' applied successfully")
conn.commit()
except Exception as e:
logger.error(f"Failed to run migrations: {e}")
# API Token methods
def create_api_token(self, name: str, token_hash: str) -> int:
"""Create a new API token entry"""
try:
with sqlite3.connect(self.sqlite_path) as conn:
cursor = conn.execute(
"INSERT INTO api_tokens (name, token_hash, created_at) VALUES (?, ?, ?)",
(name, token_hash, time.time())
)
return cursor.lastrowid
except Exception as e:
logger.error(f"Failed to create API token: {e}")
raise
def verify_api_token(self, token_hash: str) -> Optional[Dict[str, Any]]:
"""Verify API token and update last_used timestamp"""
try:
with sqlite3.connect(self.sqlite_path) as conn:
cursor = conn.execute(
"SELECT id, name, created_at FROM api_tokens WHERE token_hash = ?",
(token_hash,)
)
row = cursor.fetchone()
if row:
token_id, name, created_at = row
# Update last_used timestamp
conn.execute(
"UPDATE api_tokens SET last_used = ? WHERE id = ?",
(time.time(), token_id)
)
conn.commit()
return {
'id': token_id,
'name': name,
'created_at': created_at
}
return None
except Exception as e:
logger.error(f"Failed to verify API token: {e}")
return None
def revoke_api_token(self, token_id: int) -> bool:
"""Revoke (delete) an API token"""
try:
with sqlite3.connect(self.sqlite_path) as conn:
cursor = conn.execute("DELETE FROM api_tokens WHERE id = ?", (token_id,))
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Failed to revoke API token: {e}")
return False
def list_api_tokens(self) -> List[Dict[str, Any]]:
"""List all API tokens (without sensitive data)"""
try:
with sqlite3.connect(self.sqlite_path) as conn:
cursor = conn.execute(
"SELECT id, name, created_at, last_used FROM api_tokens ORDER BY created_at DESC"
)
tokens = []
for row in cursor.fetchall():
tokens.append({
'id': row[0],
'name': row[1],
'created_at': row[2],
'last_used': row[3]
})
return tokens
except Exception as e:
logger.error(f"Failed to list API tokens: {e}")
return []
def store_packet(self, record: dict):
try:
with sqlite3.connect(self.sqlite_path) as conn:
+56
View File
@@ -6,6 +6,7 @@ which are used for network diagnostics to track the path and SNR
of packets through the mesh network.
"""
import asyncio
import logging
import time
from typing import Dict, Any
@@ -34,6 +35,9 @@ class TraceHelper:
self.repeater_handler = repeater_handler
self.packet_injector = packet_injector # Function to inject packets into router
# Ping callback system - track pending ping requests by tag
self.pending_pings = {} # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}}
# Create TraceHandler internally as a parsing utility
self.trace_handler = TraceHandler(log_fn=log_fn or logger.info)
@@ -64,6 +68,21 @@ class TraceHelper:
trace_path = parsed_data["trace_path"]
trace_path_len = len(trace_path)
# Check if this is a response to one of our pings
trace_tag = parsed_data.get("tag")
if trace_tag in self.pending_pings:
ping_info = self.pending_pings[trace_tag]
# Store response data
ping_info['result'] = {
'path': trace_path,
'snr': packet.get_snr(),
'rssi': getattr(packet, "rssi", 0),
'received_at': time.time()
}
# Signal the waiting coroutine
ping_info['event'].set()
logger.info(f"Ping response received for tag {trace_tag}")
# Record the trace packet for dashboard/statistics
if self.repeater_handler:
packet_record = self._create_trace_record(packet, trace_path, parsed_data)
@@ -269,3 +288,40 @@ class TraceHelper:
logger.info(f"Not our turn (next hop: 0x{expected_hash:02x})")
elif self.repeater_handler and self.repeater_handler.is_duplicate(packet):
logger.info("Duplicate packet, ignoring")
def register_ping(self, tag: int, target_hash: int) -> asyncio.Event:
"""Register a ping request and return an event to wait on.
Args:
tag: The unique trace tag for this ping
target_hash: The hash of the target node
Returns:
asyncio.Event that will be set when response is received
"""
event = asyncio.Event()
self.pending_pings[tag] = {
'event': event,
'result': None,
'target': target_hash,
'sent_at': time.time()
}
logger.debug(f"Registered ping with tag {tag} for target 0x{target_hash:02x}")
return event
def cleanup_stale_pings(self, max_age_seconds: int = 30):
"""Remove pending pings older than max_age_seconds.
Args:
max_age_seconds: Maximum age in seconds before a ping is considered stale
"""
current_time = time.time()
stale_tags = [
tag for tag, info in self.pending_pings.items()
if current_time - info['sent_at'] > max_age_seconds
]
for tag in stale_tags:
self.pending_pings.pop(tag)
logger.debug(f"Cleaned up stale ping with tag {tag}")
if stale_tags:
logger.info(f"Cleaned up {len(stale_tags)} stale ping(s)")
+280 -62
View File
@@ -8,74 +8,112 @@ import cherrypy
from repeater import __version__
from repeater.config import update_global_flood_policy
from .cad_calibration_engine import CADCalibrationEngine
from .auth.middleware import require_auth
from .auth_endpoints import AuthAPIEndpoints
from pymc_core.protocol import CryptoUtils
logger = logging.getLogger("HTTPServer")
# ============================================================================
# API ENDPOINT DOCUMENTATION
# ============================================================================
# Authentication (see auth_endpoints.py for implementation)
# POST /auth/login - Authenticate and get JWT token
# POST /auth/refresh - Refresh JWT token
# GET /auth/verify - Verify current authentication
# POST /auth/change_password - Change admin password
# GET /api/auth/tokens - List all API tokens (RESTful)
# POST /api/auth/tokens - Create new API token (RESTful)
# DELETE /api/auth/tokens/{token_id} - Revoke API token (RESTful)
# System
# GET /api/stats
# GET /api/logs
# Packets
# GET /api/packet_stats?hours=24
# GET /api/recent_packets?limit=100
# GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000
# GET /api/packet_by_hash?packet_hash=abc123
# GET /api/packet_type_stats?hours=24
# Charts & RRD
# GET /api/rrd_data?start_time=X&end_time=Y&resolution=average
# GET /api/packet_type_graph_data?hours=24&resolution=average&types=all
# GET /api/metrics_graph_data?hours=24&resolution=average&metrics=all
# Noise Floor
# GET /api/noise_floor_history?hours=24
# GET /api/noise_floor_stats?hours=24
# GET /api/noise_floor_chart_data?hours=24
# GET /api/stats - Get system statistics
# GET /api/logs - Get system logs
# GET /api/hardware_stats - Get hardware statistics
# GET /api/hardware_processes - Get process information
# POST /api/restart_service - Restart the repeater service
# GET /api/openapi - Get OpenAPI specification
# Repeater Control
# POST /api/send_advert
# POST /api/set_mode {"mode": "forward|monitor"}
# POST /api/set_duty_cycle {"enabled": true|false}
# POST /api/restart_service
# POST /api/send_advert - Send repeater advertisement
# POST /api/set_mode {"mode": "forward|monitor"} - Set repeater mode
# POST /api/set_duty_cycle {"enabled": true|false} - Enable/disable duty cycle
# POST /api/update_duty_cycle_config {"enabled": true, "on_time": 300, "off_time": 60} - Update duty cycle config
# POST /api/update_radio_config - Update radio configuration
# Packets
# GET /api/packet_stats?hours=24 - Get packet statistics
# GET /api/packet_type_stats?hours=24 - Get packet type statistics
# GET /api/route_stats?hours=24 - Get route statistics
# GET /api/recent_packets?limit=100 - Get recent packets
# GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000 - Get filtered packets
# GET /api/packet_by_hash?packet_hash=abc123 - Get specific packet by hash
# Charts & RRD
# GET /api/rrd_data?start_time=X&end_time=Y&resolution=average - Get RRD data
# GET /api/packet_type_graph_data?hours=24&resolution=average&types=all - Get packet type graph data
# GET /api/metrics_graph_data?hours=24&resolution=average&metrics=all - Get metrics graph data
# Noise Floor
# GET /api/noise_floor_history?hours=24 - Get noise floor history
# GET /api/noise_floor_stats?hours=24 - Get noise floor statistics
# GET /api/noise_floor_chart_data?hours=24 - Get noise floor chart data
# CAD Calibration
# POST /api/cad_calibration_start {"samples": 8, "delay": 100}
# POST /api/cad_calibration_stop
# POST /api/save_cad_settings {"peak": 127, "min_val": 64}
# GET /api/cad_calibration_stream (SSE)
# POST /api/cad_calibration_start {"samples": 8, "delay": 100} - Start CAD calibration
# POST /api/cad_calibration_stop - Stop CAD calibration
# POST /api/save_cad_settings {"peak": 127, "min_val": 64} - Save CAD settings
# GET /api/cad_calibration_stream - CAD calibration SSE stream
# Adverts & Contacts
# GET /api/adverts_by_contact_type?contact_type=X&limit=100&hours=24 - Get adverts by contact type
# GET /api/advert?advert_id=123 - Get specific advert
# Transport Keys
# GET /api/transport_keys - List all transport keys
# POST /api/transport_keys - Create new transport key
# GET /api/transport_key?key_id=X - Get specific transport key
# DELETE /api/transport_key?key_id=X - Delete transport key
# Network Policy
# GET /api/global_flood_policy - Get global flood policy
# POST /api/global_flood_policy - Update global flood policy
# POST /api/ping_neighbor - Ping a neighbor node
# Identity Management
# GET /api/identities - List all identities
# GET /api/identity?name=<name> - Get specific identity
# POST /api/create_identity {"name": "...", "identity_key": "...", "type": "room_server", "settings": {...}}
# PUT /api/update_identity {"name": "...", "new_name": "...", "identity_key": "...", "settings": {...}}
# DELETE /api/delete_identity?name=<name>
# POST /api/send_room_server_advert {"name": "..."} - Send advert for room server
# POST /api/create_identity {"name": "...", "identity_key": "...", "type": "room_server", "settings": {...}} - Create identity
# PUT /api/update_identity {"name": "...", "new_name": "...", "identity_key": "...", "settings": {...}} - Update identity
# DELETE /api/delete_identity?name=<name> - Delete identity
# POST /api/send_room_server_advert {"name": "...", "node_name": "...", "latitude": 0.0, "longitude": 0.0} - Send room server advert
# ACL (Access Control List)
# GET /api/acl_info - Get ACL configuration and stats for all identities
# GET /api/acl_clients?identity_hash=0x42&identity_name=repeater - List authenticated clients
# POST /api/acl_remove_client {"public_key": "...", "identity_hash": "0x42"} - Remove client from ACL
# GET /api/acl_stats - Overall ACL statistics
# GET /api/acl_info - Get ACL configuration and stats for all identities
# GET /api/acl_clients?identity_hash=0x42&identity_name=repeater - List authenticated clients
# POST /api/acl_remove_client {"public_key": "...", "identity_hash": "0x42"} - Remove client from ACL
# GET /api/acl_stats - Overall ACL statistics
# Room Server
# GET /api/room_messages?room_name=General&limit=50&offset=0 - Get messages from room
# GET /api/room_messages?room_hash=0x42&limit=50 - Get messages by hash
# POST /api/room_post_message {"room_name": "General", "message": "Hello", "author_pubkey": "abc123"} - Post message
# GET /api/room_stats?room_name=General - Get room statistics
# GET /api/room_stats - Get all rooms statistics
# GET /api/room_clients?room_name=General - Get clients synced to room
# GET /api/room_messages?room_name=General&limit=50&offset=0&since_timestamp=X - Get messages from room
# GET /api/room_messages?room_hash=0x42&limit=50 - Get messages by room hash
# POST /api/room_post_message {"room_name": "General", "message": "Hello", "author_pubkey": "abc123"} - Post message
# GET /api/room_stats?room_name=General - Get room statistics
# GET /api/room_stats - Get all rooms statistics
# GET /api/room_clients?room_name=General - Get clients synced to room
# DELETE /api/room_message?room_name=General&message_id=123 - Delete specific message
# DELETE /api/room_messages?room_name=General - Clear all messages in room
# DELETE /api/room_messages_clear?room_name=General - Clear all messages in room
# Common Parameters
# hours - Time range (default: 24)
# resolution - 'average', 'max', 'min' (default: 'average')
# limit - Max results (default varies)
# hours - Time range in hours (default: 24)
# resolution - Data resolution: 'average', 'max', 'min' (default: 'average')
# limit - Maximum results (default varies by endpoint)
# offset - Result offset for pagination (default: 0)
# type - Packet type 0-15
# route - Route type 1-3
# ============================================================================
@@ -97,6 +135,9 @@ class APIEndpoints:
config=self.config,
daemon_instance=daemon_instance
)
# Create nested auth object for /api/auth/* routes
self.auth = AuthAPIEndpoints()
def _is_cors_enabled(self):
return self.config.get("web", {}).get("cors_enabled", False)
@@ -271,6 +312,67 @@ class APIEndpoints:
logger.error(f"Error setting duty cycle: {e}", exc_info=True)
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def update_duty_cycle_config(self):
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json or {}
applied = []
# Ensure config section exists
if "duty_cycle" not in self.config:
self.config["duty_cycle"] = {}
# Update max airtime percentage
if "max_airtime_percent" in data:
percent = float(data["max_airtime_percent"])
if percent < 0.1 or percent > 100.0:
return self._error("Max airtime percent must be 0.1-100.0")
# Convert percent to milliseconds per minute
max_airtime_ms = int((percent / 100) * 60000)
self.config["duty_cycle"]["max_airtime_per_minute"] = max_airtime_ms
applied.append(f"max_airtime={percent}%")
# Update enforcement enabled/disabled
if "enforcement_enabled" in data:
enabled = bool(data["enforcement_enabled"])
self.config["duty_cycle"]["enforcement_enabled"] = enabled
applied.append(f"enforcement={'enabled' if enabled else 'disabled'}")
if not applied:
return self._error("No valid settings provided")
# Save to config file and live update daemon
result = self.config_manager.update_and_save(
updates={},
live_update=True,
live_update_sections=['duty_cycle']
)
logger.info(f"Duty cycle config updated: {', '.join(applied)}")
return self._success({
"applied": applied,
"persisted": result.get("saved", False),
"live_update": result.get("live_updated", False),
"restart_required": False,
"message": "Duty cycle settings applied immediately."
})
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"Error updating duty cycle config: {e}")
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
@@ -1184,31 +1286,120 @@ class APIEndpoints:
else:
return self._error("Method not supported")
@cherrypy.expose
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def ping_neighbor(self):
# Enable CORS for this endpoint only if configured
self._set_cors_headers()
# Handle OPTIONS request for CORS preflight
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json or {}
target_id = data.get("target_id")
timeout = int(data.get("timeout", 10))
if not target_id:
return self._error("Missing target_id parameter")
# TODO: Implement actual ping functionality when available
# For now, return success to indicate the endpoint works
logger.info(f"Ping request for neighbor: {target_id}")
return self._success({"target_id": target_id}, message="Ping sent successfully")
# Parse target hash (accepts hex string like "0xA5" or "a5")
try:
target_hash = int(target_id, 16) if isinstance(target_id, str) else int(target_id)
if target_hash < 0 or target_hash > 255:
return self._error("target_id must be a valid byte (0x00-0xFF)")
except ValueError:
return self._error(f"Invalid target_id format: {target_id}")
# Check if router and trace_helper are available
if not hasattr(self.daemon_instance, 'router'):
return self._error("Packet router not available")
router = self.daemon_instance.router
if not hasattr(self.daemon_instance, 'trace_helper'):
return self._error("Trace helper not available")
trace_helper = self.daemon_instance.trace_helper
# Generate unique tag for this ping
import random
trace_tag = random.randint(0, 0xFFFFFFFF)
# Create trace packet
from pymc_core.protocol import PacketBuilder
packet = PacketBuilder.create_trace(
tag=trace_tag,
auth_code=0x12345678,
flags=0x00,
path=[target_hash]
)
# Wait for response with timeout
import asyncio
async def send_and_wait():
"""Async helper to send ping and wait for response"""
# Register ping with TraceHelper (must be done in async context)
event = trace_helper.register_ping(trace_tag, target_hash)
# Send packet via router
await router.inject_packet(packet)
logger.info(f"Ping sent to 0x{target_hash:02x} with tag {trace_tag}")
try:
await asyncio.wait_for(event.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
# Run the async send and wait in the daemon's event loop
try:
if self.event_loop is None:
return self._error("Event loop not available")
future = asyncio.run_coroutine_threadsafe(send_and_wait(), self.event_loop)
response_received = future.result(timeout=timeout + 1)
except Exception as e:
logger.error(f"Error waiting for ping response: {e}")
trace_helper.pending_pings.pop(trace_tag, None)
return self._error(f"Error waiting for response: {str(e)}")
if response_received:
# Get result
ping_info = trace_helper.pending_pings.pop(trace_tag, None)
if not ping_info:
return self._error("Ping info not found after response")
result = ping_info.get('result')
if result:
# Calculate round-trip time
rtt_ms = (result['received_at'] - ping_info['sent_at']) * 1000
return self._success({
"target_id": f"0x{target_hash:02x}",
"rtt_ms": round(rtt_ms, 2),
"snr_db": result['snr'],
"rssi": result['rssi'],
"path": [f"0x{h:02x}" for h in result['path']],
"tag": trace_tag
}, message="Ping successful")
else:
return self._error("Received response but no data")
else:
# Timeout
trace_helper.pending_pings.pop(trace_tag, None)
return self._error(f"Ping timeout after {timeout}s")
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"Error pinging neighbor: {e}")
return self._error(e)
logger.error(f"Error pinging neighbor: {e}", exc_info=True)
return self._error(str(e))
# ========== Identity Management Endpoints ==========
@@ -2776,15 +2967,42 @@ class APIEndpoints:
@cherrypy.expose
def docs(self):
"""Serve Swagger UI for interactive API documentation."""
# Load HTML template from file
template_path = os.path.join(os.path.dirname(__file__), 'html', 'swagger-ui.html')
try:
with open(template_path, 'r', encoding='utf-8') as f:
swagger_html = f.read()
except FileNotFoundError:
logger.error(f"Swagger UI template not found at {template_path}")
cherrypy.response.status = 500
return b"Swagger UI template not found"
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pyMC Repeater API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.0/swagger-ui.css">
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.10.0/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.10.0/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
window.ui = SwaggerUIBundle({
url: '/api/openapi',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
};
</script>
</body>
</html>"""
cherrypy.response.headers['Content-Type'] = 'text/html'
return swagger_html.encode('utf-8')
return html.encode('utf-8')
+9
View File
@@ -0,0 +1,9 @@
from .jwt_handler import JWTHandler
from .api_tokens import APITokenManager
from .middleware import require_auth
__all__ = [
'JWTHandler',
'APITokenManager',
'require_auth'
]
+49
View File
@@ -0,0 +1,49 @@
import secrets
import hmac
import hashlib
from typing import Optional, List, Dict
import logging
logger = logging.getLogger(__name__)
class APITokenManager:
def __init__(self, sqlite_handler, secret_key: str):
self.db = sqlite_handler
self.secret_key = secret_key.encode('utf-8')
def generate_api_token(self) -> str:
return secrets.token_hex(32)
def hash_token(self, token: str) -> str:
return hmac.new(
self.secret_key,
token.encode('utf-8'),
hashlib.sha256
).hexdigest()
def create_token(self, name: str) -> tuple[int, str]:
plaintext_token = self.generate_api_token()
token_hash = self.hash_token(plaintext_token)
token_id = self.db.create_api_token(name, token_hash)
logger.info(f"Created API token '{name}' with ID {token_id}")
return token_id, plaintext_token
def verify_token(self, token: str) -> Optional[Dict]:
token_hash = self.hash_token(token)
return self.db.verify_api_token(token_hash)
def revoke_token(self, token_id: int) -> bool:
deleted = self.db.revoke_api_token(token_id)
if deleted:
logger.info(f"Revoked API token ID {token_id}")
return deleted
def list_tokens(self) -> List[Dict]:
return self.db.list_api_tokens()
+66
View File
@@ -0,0 +1,66 @@
import logging
import cherrypy
logger = logging.getLogger("HTTPServer")
def check_auth():
"""
CherryPy tool to check authentication before processing request.
Checks for either JWT in Authorization header or API token in X-API-Key header.
Sets cherrypy.request.user on success.
Returns 401 JSON response on failure.
"""
# Skip auth check for OPTIONS requests (CORS preflight)
if cherrypy.request.method == "OPTIONS":
return
# Skip auth check for /auth/login endpoint
if cherrypy.request.path_info == "/auth/login":
return
# Get auth handlers from config
jwt_handler = cherrypy.config.get("jwt_handler")
token_manager = cherrypy.config.get("token_manager")
if not jwt_handler or not token_manager:
logger.error("Auth handlers not initialized in cherrypy.config")
cherrypy.response.status = 500
return {"success": False, "error": "Authentication system not configured"}
# Check for JWT token first
auth_header = cherrypy.request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # Remove "Bearer " prefix
payload = jwt_handler.verify_jwt(token)
if payload:
cherrypy.request.user = {
"username": payload.get("sub"),
"client_id": payload.get("client_id"),
"auth_type": "jwt"
}
return
# Check for API token
api_key = cherrypy.request.headers.get("X-API-Key", "")
if api_key:
token_info = token_manager.verify_token(api_key)
if token_info:
cherrypy.request.user = {
"token_id": token_info["id"],
"token_name": token_info["name"],
"auth_type": "api_token"
}
return
# No valid authentication found
logger.warning(f"Unauthorized access attempt to {cherrypy.request.path_info}")
raise cherrypy.HTTPError(401, "Unauthorized - Valid JWT or API token required")
# Register the tool
cherrypy.tools.require_auth = cherrypy.Tool('before_handler', check_auth)
logger.info("CherryPy require_auth tool registered")
+38
View File
@@ -0,0 +1,38 @@
import jwt
import time
from typing import Dict, Optional
import logging
logger = logging.getLogger(__name__)
class JWTHandler:
def __init__(self, secret: str, expiry_minutes: int = 15):
self.secret = secret
self.expiry_minutes = expiry_minutes
def create_jwt(self, username: str, client_id: str) -> str:
now = int(time.time())
expiry = now + (self.expiry_minutes * 60)
payload = {
'sub': username,
'exp': expiry,
'iat': now,
'client_id': client_id
}
token = jwt.encode(payload, self.secret, algorithm='HS256')
logger.info(f"Created JWT for user '{username}' with client_id '{client_id[:8]}...'")
return token
def verify_jwt(self, token: str) -> Optional[Dict]:
try:
payload = jwt.decode(token, self.secret, algorithms=['HS256'])
return payload
except jwt.ExpiredSignatureError:
logger.warning("JWT token expired")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid JWT token: {e}")
return None
+444
View File
@@ -0,0 +1,444 @@
"""
Authentication endpoints for login and token management
"""
import cherrypy
import logging
from .auth.middleware import require_auth
logger = logging.getLogger(__name__)
class AuthAPIEndpoints:
"""Nested endpoint for /api/auth/* RESTful routes"""
def __init__(self):
# Create tokens nested endpoint for /api/auth/tokens
self.tokens = TokensAPIEndpoint()
class TokensAPIEndpoint:
"""RESTful token management endpoints for /api/auth/tokens"""
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def index(self):
# Handle CORS preflight
if cherrypy.request.method == 'OPTIONS':
return {}
# Get token manager from cherrypy config
token_manager = cherrypy.config.get('token_manager')
if not token_manager:
cherrypy.response.status = 500
return {'success': False, 'error': 'Token manager not available'}
if cherrypy.request.method == 'GET':
try:
tokens = token_manager.list_tokens()
return {
'success': True,
'tokens': tokens
}
except Exception as e:
logger.error(f"Token list error: {e}")
cherrypy.response.status = 500
return {
'success': False,
'error': 'Failed to list tokens'
}
elif cherrypy.request.method == 'POST':
try:
import json
body = cherrypy.request.body.read().decode('utf-8')
data = json.loads(body) if body else {}
name = data.get('name', '').strip()
if not name:
cherrypy.response.status = 400
return {
'success': False,
'error': 'Token name is required'
}
# Create the token
token_id, plaintext_token = token_manager.create_token(name)
logger.info(f"Generated API token '{name}' (ID: {token_id}) by user {cherrypy.request.user['username']}")
return {
'success': True,
'token': plaintext_token,
'token_id': token_id,
'name': name,
'warning': 'Save this token securely - it will not be shown again'
}
except Exception as e:
logger.error(f"Token generation error: {e}")
cherrypy.response.status = 500
return {
'success': False,
'error': 'Failed to generate token'
}
else:
raise cherrypy.HTTPError(405, "Method not allowed")
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def default(self, token_id=None):
# Handle CORS preflight
if cherrypy.request.method == 'OPTIONS':
return {}
# Get token manager from cherrypy config
token_manager = cherrypy.config.get('token_manager')
if not token_manager:
cherrypy.response.status = 500
return {'success': False, 'error': 'Token manager not available'}
if cherrypy.request.method == 'DELETE':
try:
if not token_id:
cherrypy.response.status = 400
return {
'success': False,
'error': 'Token ID is required'
}
# Convert to int
try:
token_id_int = int(token_id)
except ValueError:
cherrypy.response.status = 400
return {
'success': False,
'error': 'Invalid token ID'
}
# Revoke the token
success = token_manager.revoke_token(token_id_int)
if success:
logger.info(f"Revoked API token ID {token_id_int} by user {cherrypy.request.user['username']}")
return {
'success': True,
'message': 'Token revoked successfully'
}
else:
cherrypy.response.status = 404
return {
'success': False,
'error': 'Token not found'
}
except Exception as e:
logger.error(f"Token revocation error: {e}")
cherrypy.response.status = 500
return {
'success': False,
'error': 'Failed to revoke token'
}
else:
raise cherrypy.HTTPError(405, "Method not allowed")
class AuthEndpoints:
def __init__(self, config, jwt_handler, token_manager, config_manager=None):
self.config = config
self.jwt_handler = jwt_handler
self.token_manager = token_manager
self.config_manager = config_manager
@cherrypy.expose
def login(self, **kwargs):
cherrypy.response.headers['Content-Type'] = 'application/json'
# Handle CORS preflight
if cherrypy.request.method == 'OPTIONS':
cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key'
return b''
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405, "Method not allowed")
try:
# Parse JSON body manually since we can't use json_in decorator with OPTIONS
import json
body = cherrypy.request.body.read().decode('utf-8')
data = json.loads(body) if body else {}
username = data.get('username', '').strip()
password = data.get('password', '')
client_id = data.get('client_id', '').strip()
if not username or not password or not client_id:
return json.dumps({
'success': False,
'error': 'Missing required fields: username, password, client_id'
}).encode('utf-8')
# Validate credentials against config
# Check if username is 'admin' and password matches config
security_config = self.config.get('security', {})
config_password = security_config.get('admin_password', 'admin123')
if username == 'admin' and password == config_password:
# Create JWT token
token = self.jwt_handler.create_jwt(username, client_id)
logger.info(f"Successful login for user '{username}' from client '{client_id[:8]}...'")
return json.dumps({
'success': True,
'token': token,
'expires_in': self.jwt_handler.expiry_minutes * 60,
'username': username
}).encode('utf-8')
else:
logger.warning(f"Failed login attempt for user '{username}'")
# Don't reveal which part was wrong
return json.dumps({
'success': False,
'error': 'Invalid username or password'
}).encode('utf-8')
except Exception as e:
logger.error(f"Login error: {e}")
return json.dumps({
'success': False,
'error': 'Internal server error'
}).encode('utf-8')
@cherrypy.expose
@cherrypy.tools.json_out()
@require_auth
def verify(self):
if cherrypy.request.method != 'GET':
raise cherrypy.HTTPError(405, "Method not allowed")
return {
'success': True,
'authenticated': True,
'user': cherrypy.request.user
}
@cherrypy.expose
def refresh(self, **kwargs):
cherrypy.response.headers['Content-Type'] = 'application/json'
# Handle CORS preflight
if cherrypy.request.method == 'OPTIONS':
cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key'
return b''
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405, "Method not allowed")
try:
import json
# Manual authentication check (can't use @require_auth since we need to handle OPTIONS)
auth_header = cherrypy.request.headers.get('Authorization', '')
api_key = cherrypy.request.headers.get('X-API-Key', '')
jwt_handler = cherrypy.config.get('jwt_handler')
token_manager = cherrypy.config.get('token_manager')
user_info = None
# Check JWT first
if auth_header.startswith('Bearer '):
token = auth_header[7:]
payload = jwt_handler.verify_jwt(token)
if payload:
user_info = {
'username': payload['sub'],
'client_id': payload.get('client_id'),
'auth_method': 'jwt'
}
# Check API token
if not user_info and api_key:
token_data = token_manager.verify_token(api_key)
if token_data:
user_info = {
'username': 'admin',
'token_id': token_data['id'],
'auth_method': 'api_token'
}
if not user_info:
return json.dumps({
'success': False,
'error': 'Unauthorized - Valid JWT or API token required'
}).encode('utf-8')
# Parse request body
body = cherrypy.request.body.read().decode('utf-8')
data = json.loads(body) if body else {}
client_id = data.get('client_id', user_info.get('client_id', '')).strip()
if not client_id:
return json.dumps({
'success': False,
'error': 'Client ID is required'
}).encode('utf-8')
# Create new JWT token (refreshes expiry time)
new_token = self.jwt_handler.create_jwt(user_info['username'], client_id)
logger.info(f"Token refreshed for user '{user_info['username']}' from client '{client_id[:8]}...'")
return json.dumps({
'success': True,
'token': new_token,
'expires_in': self.jwt_handler.expiry_minutes * 60,
'username': user_info['username']
}).encode('utf-8')
except Exception as e:
logger.error(f"Token refresh error: {e}")
return json.dumps({
'success': False,
'error': 'Failed to refresh token'
}).encode('utf-8')
@cherrypy.expose
def change_password(self):
import json
cherrypy.response.headers['Content-Type'] = 'application/json'
# Handle CORS preflight
if cherrypy.request.method == 'OPTIONS':
cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key'
return b''
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405, "Method not allowed")
# Require authentication for POST
# Get auth handlers from global cherrypy config
jwt_handler = cherrypy.config.get('jwt_handler')
token_manager = cherrypy.config.get('token_manager')
if not jwt_handler or not token_manager:
logger.error("Auth handlers not configured")
raise cherrypy.HTTPError(500, "Authentication not configured")
# Try JWT authentication first
auth_header = cherrypy.request.headers.get('Authorization', '')
user = None
if auth_header.startswith('Bearer '):
token = auth_header[7:] # Remove 'Bearer ' prefix
payload = jwt_handler.verify_jwt(token)
if payload:
user = {
'username': payload['sub'],
'client_id': payload['client_id'],
'auth_type': 'jwt'
}
# Try API token authentication if JWT failed
if not user:
api_key = cherrypy.request.headers.get('X-API-Key', '')
if api_key:
token_info = token_manager.verify_token(api_key)
if token_info:
user = {
'username': 'api_token',
'token_name': token_info['name'],
'token_id': token_info['id'],
'auth_type': 'api_token'
}
if not user:
cherrypy.response.status = 401
return json.dumps({
'success': False,
'error': 'Unauthorized - Valid JWT or API token required'
}).encode('utf-8')
try:
# Parse JSON body manually
body = cherrypy.request.body.read().decode('utf-8')
data = json.loads(body) if body else {}
current_password = data.get('current_password', '')
new_password = data.get('new_password', '')
if not current_password or not new_password:
cherrypy.response.status = 400
return json.dumps({
'success': False,
'error': 'Both current_password and new_password are required'
}).encode('utf-8')
# Validate new password strength
if len(new_password) < 8:
cherrypy.response.status = 400
return json.dumps({
'success': False,
'error': 'New password must be at least 8 characters long'
}).encode('utf-8')
# Verify current password
security_config = self.config.get('security', {})
config_password = security_config.get('admin_password', 'admin123')
if current_password != config_password:
cherrypy.response.status = 401
return json.dumps({
'success': False,
'error': 'Current password is incorrect'
}).encode('utf-8')
# Update password in config
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['admin_password'] = new_password
# Save to config file using ConfigManager
if self.config_manager:
if self.config_manager.save_to_file():
logger.info(f"Admin password changed successfully by user {user['username']}")
return json.dumps({
'success': True,
'message': 'Password changed successfully. Please log in again with your new password.'
}).encode('utf-8')
else:
cherrypy.response.status = 500
return json.dumps({
'success': False,
'error': 'Failed to save password to config file'
}).encode('utf-8')
else:
cherrypy.response.status = 500
return json.dumps({
'success': False,
'error': 'Config manager not available'
}).encode('utf-8')
except Exception as e:
logger.error(f"Password change error: {e}")
cherrypy.response.status = 500
return json.dumps({
'success': False,
'error': 'Failed to change password'
}).encode('utf-8')
+201 -3
View File
@@ -2,6 +2,7 @@ import json
import logging
import os
import re
import secrets
from collections import deque
from datetime import datetime
from typing import Callable, Optional
@@ -12,6 +13,10 @@ from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES
from repeater import __version__
from .api_endpoints import APIEndpoints
from .auth_endpoints import AuthEndpoints
from .auth.jwt_handler import JWTHandler
from .auth.api_tokens import APITokenManager
from .auth import cherrypy_tool # Import to register the tool
logger = logging.getLogger("HTTPServer")
@@ -42,6 +47,45 @@ class LogBuffer(logging.Handler):
# Global log buffer instance
_log_buffer = LogBuffer(max_lines=100)
class DocEndpoint:
"""Simple wrapper to serve API docs at /doc"""
def __init__(self, api_endpoints):
self.api_endpoints = api_endpoints
@cherrypy.expose
def index(self):
"""Serve Swagger UI at /doc"""
return self.api_endpoints.docs()
@cherrypy.expose
def docs(self):
"""Serve Swagger UI at /doc/docs"""
return self.api_endpoints.docs()
@cherrypy.expose
def openapi_json(self):
"""Serve OpenAPI spec in JSON format at /doc/openapi.json"""
import os
import yaml
import json
spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml')
try:
with open(spec_path, 'r') as f:
spec_content = yaml.safe_load(f)
cherrypy.response.headers['Content-Type'] = 'application/json'
return json.dumps(spec_content).encode('utf-8')
except FileNotFoundError:
cherrypy.response.status = 404
return json.dumps({"error": "OpenAPI spec not found"}).encode('utf-8')
except Exception as e:
cherrypy.response.status = 500
return json.dumps({"error": f"Error loading OpenAPI spec: {e}"}).encode('utf-8')
class StatsApp:
def __init__(
@@ -69,6 +113,9 @@ class StatsApp:
# Create nested API object for routing
self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path)
# Create doc endpoint for API documentation
self.doc = DocEndpoint(self.api)
@cherrypy.expose
def index(self):
@@ -117,18 +164,84 @@ class HTTPStatsServer:
self.host = host
self.port = port
self.config = config or {}
self.config_path = config_path
# Initialize authentication handlers
self._init_auth_handlers()
self.app = StatsApp(
stats_getter, node_name, pub_key, send_advert_func, config, event_loop, daemon_instance, config_path
)
# Create auth endpoints (APIEndpoints has the config_manager)
self.auth_app = AuthEndpoints(self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager)
# Create documentation endpoints as separate app
self.doc_app = DocEndpoint(self.app.api)
# Set up CORS at the server level if enabled
self._cors_enabled = self.config.get("web", {}).get("cors_enabled", False)
logger.info(f"CORS enabled: {self._cors_enabled}")
def _init_auth_handlers(self):
"""Initialize JWT handler and API token manager."""
# Get or generate JWT secret
security_config = self.config.get("security", {})
jwt_secret = security_config.get("jwt_secret", "")
if not jwt_secret:
# Auto-generate JWT secret
jwt_secret = secrets.token_hex(32)
logger.warning("No JWT secret found in config, auto-generated one. Please save this to config.yaml:")
logger.warning(f"security.jwt_secret: {jwt_secret}")
# Try to save to config if config_path is available
if self.config_path:
try:
import yaml
with open(self.config_path, 'r') as f:
config_data = yaml.safe_load(f) or {}
if 'security' not in config_data:
config_data['security'] = {}
config_data['security']['jwt_secret'] = jwt_secret
with open(self.config_path, 'w') as f:
yaml.dump(config_data, f, default_flow_style=False)
logger.info(f"Saved auto-generated JWT secret to {self.config_path}")
except Exception as e:
logger.error(f"Failed to save JWT secret to config: {e}")
# Initialize JWT handler (15 minute expiry)
self.jwt_handler = JWTHandler(jwt_secret, expiry_minutes=15)
logger.info("JWT handler initialized")
# Initialize API token manager
storage_dir = self.config.get("storage", {}).get("storage_dir", ".")
# Ensure storage directory exists
os.makedirs(storage_dir, exist_ok=True)
db_path = os.path.join(storage_dir, "api_tokens.db")
self.token_manager = APITokenManager(db_path, jwt_secret)
logger.info(f"API token manager initialized with database at {db_path}")
def _setup_server_cors(self):
"""Set up CORS using cherrypy_cors.install()"""
# Configure CORS to allow Authorization header
# cherrypy-cors will handle preflight requests automatically
cherrypy_cors.install()
logger.info("CORS support enabled")
logger.info("CORS support enabled with Authorization header")
def _json_error_handler(self, status, message, traceback, version):
"""Return JSON error responses instead of HTML for API endpoints"""
cherrypy.response.headers["Content-Type"] = "application/json"
return json.dumps({
"success": False,
"error": message
})
def start(self):
@@ -157,12 +270,42 @@ class HTTPStatsServer:
'txt': 'text/plain'
},
},
# Require authentication for all /api endpoints
"/api": {
"tools.require_auth.on": True,
},
# Public documentation endpoints (no auth required)
"/api/openapi": {
"tools.require_auth.on": False,
},
"/api/docs": {
"tools.require_auth.on": False,
},
"/favicon.ico": {
"tools.staticfile.on": True,
"tools.staticfile.filename": os.path.join(html_dir, "favicon.ico"),
},
}
# Add CORS configuration if enabled
if self._cors_enabled:
cors_config = {
"cors.expose.on": True,
"tools.response_headers.on": True,
"tools.response_headers.headers": [
('Access-Control-Allow-Origin', '*'),
('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'),
('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'),
('Access-Control-Allow-Credentials', 'true'),
],
# Disable automatic trailing slash redirects to prevent CORS issues
"tools.trailing_slash.on": False,
}
# Apply CORS to paths
config["/"].update(cors_config)
config["/api"].update(cors_config)
# Add Vue.js assets support only if assets directory exists
if os.path.isdir(assets_dir):
config["/assets"] = {
@@ -189,9 +332,8 @@ class HTTPStatsServer:
},
}
# Only add CORS config entries if CORS is enabled
# Only add CORS to static assets if CORS is enabled
if self._cors_enabled:
config["/"]["cors.expose.on"] = True
if "/assets" in config:
config["/assets"]["cors.expose.on"] = True
if "/_next" in config:
@@ -206,10 +348,66 @@ class HTTPStatsServer:
"log.screen": False,
"log.access_file": "", # Disable access log file
"log.error_file": "", # Disable error log file
# Disable automatic trailing slash redirects globally
"tools.trailing_slash.on": False,
# Custom error handler to return JSON for API endpoints
"error_page.401": self._json_error_handler,
}
)
# Mount main app
cherrypy.tree.mount(self.app, "/", config)
# Mount auth endpoints
auth_config = {
"/": {
"tools.response_headers.on": True,
"tools.response_headers.headers": [
('Content-Type', 'application/json'),
],
# Disable automatic trailing slash redirects
"tools.trailing_slash.on": False,
}
}
if self._cors_enabled:
auth_config["/"]["cors.expose.on"] = True
# Add CORS headers for OPTIONS requests
auth_config["/"]["tools.response_headers.headers"].extend([
('Access-Control-Allow-Origin', '*'),
('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'),
('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'),
('Access-Control-Allow-Credentials', 'true'),
])
cherrypy.tree.mount(self.auth_app, "/auth", auth_config)
# Mount documentation endpoints as separate app (no auth required for docs)
doc_config = {
"/": {
"tools.require_auth.on": False, # Docs are publicly accessible
"tools.response_headers.on": True,
"tools.response_headers.headers": [
('Content-Type', 'text/html; charset=utf-8'),
],
"tools.trailing_slash.on": False,
}
}
if self._cors_enabled:
doc_config["/"]["cors.expose.on"] = True
doc_config["/"]["tools.response_headers.headers"].extend([
('Access-Control-Allow-Origin', '*'),
('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'),
('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'),
])
cherrypy.tree.mount(self.doc_app, "/doc", doc_config)
# Store auth handlers in cherrypy config for middleware access
cherrypy.config.update({
"jwt_handler": self.jwt_handler,
"token_manager": self.token_manager,
"security_config": self.config.get("security", {}),
})
# Completely disable access logging
cherrypy.log.access_log.propagate = False
File diff suppressed because it is too large Load Diff