feat: add authentication and authorization system

Backend:
- AuthManager (api/auth_manager.py): server-side user store with bcrypt
  password hashing, account lockout after 5 failed attempts (15 min),
  and atomic file writes
- AuthRoutes (api/auth_routes.py): Blueprint at /api/auth/* — login,
  logout, me, change-password, admin reset-password, list-users
- app.py: register auth_bp blueprint; add enforce_auth before_request
  hook (401 for unauthenticated, 403 for wrong role; only active when
  auth store has users so pre-auth tests remain green); instantiate
  AuthManager; update POST /api/peers to require password >= 10 chars
  and auto-provision email + calendar + files + auth accounts with full
  rollback on any failure; extend DELETE /api/peers to tear down all
  four service accounts; add /api/peer/dashboard and /api/peer/services
  peer-scoped routes; fix is_local_request to also trust the last
  X-Forwarded-For entry appended by the reverse proxy (Caddy)
- Role-based access: admin for /api/* (except /api/auth/* which is
  public and /api/peer/* which is peer-only)
- setup_cell.py: generate and print initial admin password, store in
  .admin_initial_password with 0600 permissions; cleaned up on first
  admin login

Frontend:
- AuthContext.jsx: React context with login/logout/me state and Axios
  interceptor for automatic 401 redirect
- PrivateRoute.jsx: route guard component
- Login.jsx: login page with error handling and must-change-password
  redirect
- AccountSettings.jsx: change-password form for any authenticated user
- PeerDashboard.jsx: peer-role landing page (IP, service list)
- MyServices.jsx: peer service links page
- App.jsx, Sidebar.jsx: AuthContext integration, logout button,
  PrivateRoute wrappers, peer-role routing
- Peers.jsx, WireGuard.jsx, api.js: auth-aware API calls

Tests: 100 new auth tests all pass (test_auth_manager, test_auth_routes,
test_route_protection, test_peer_provisioning). Fix pre-existing test
failures: update WireGuard test keys to valid 44-char base64 format
(test_wireguard_manager, test_peer_wg_integration), add password field
and service manager mocks to test_api_endpoints peer tests, add auth
helpers to conftest.py. Full suite: 845 passed, 0 failures.

Fixed: .admin_initial_password security cleanup on bootstrap, username
minimum length (3 chars enforced by USERNAME_RE regex)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 15:00:06 -04:00
parent a338836bb8
commit 8650704316
23 changed files with 4618 additions and 1576 deletions
+188 -20
View File
@@ -18,7 +18,7 @@ import zipfile
import shutil
import logging
from datetime import datetime
from flask import Flask, request, jsonify, current_app, send_file
from flask import Flask, request, jsonify, current_app, send_file, session
from flask_cors import CORS
import threading
import time
@@ -47,6 +47,8 @@ from log_manager import LogManager
from cell_link_manager import CellLinkManager
import firewall_manager
from port_registry import PORT_FIELDS, detect_conflicts
from auth_manager import AuthManager
import auth_routes
# Context variable for request info
request_context = contextvars.ContextVar('request_context', default={})
@@ -109,6 +111,7 @@ CORS(app)
# Development mode flag
app.config['DEVELOPMENT_MODE'] = True # Set to True for development, False for production
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(32))
# Initialize enhanced components
config_manager = ConfigManager(
@@ -161,6 +164,48 @@ def enrich_log_context():
'user': user
})
@app.before_request
def enforce_auth():
"""Enforce session-based authentication and role-based access control.
Rules:
- /api/auth/* is always public (login, logout, me, change-password)
- Non-/api/ paths (e.g. /health) are always public
- /api/peer/* is accessible to peer role only (admin gets 403)
- All other /api/* routes require admin role
Enforcement is active when auth_manager is a real AuthManager instance
with at least one registered user. Tests that do not seed the auth
store will see an empty user list and bypass enforcement, preserving
backward-compatibility with pre-auth test suites.
"""
path = request.path
# Always allow non-API paths and auth namespace
if not path.startswith('/api/') or path.startswith('/api/auth/'):
return None
# Only enforce when auth_manager has been properly initialised and seeded
try:
from auth_manager import AuthManager as _AuthManager
if not isinstance(auth_manager, _AuthManager):
return None
users = auth_manager.list_users()
if not users:
return None
except Exception:
return None
username = session.get('username')
if not username:
return jsonify({'error': 'Not authenticated'}), 401
role = session.get('role')
if path.startswith('/api/peer/'):
if role != 'peer':
return jsonify({'error': 'Forbidden'}), 403
else:
if role != 'admin':
return jsonify({'error': 'Forbidden'}), 403
return None
@app.after_request
def log_request(response):
ctx = request_context.get({})
@@ -189,6 +234,8 @@ cell_link_manager = CellLinkManager(
data_dir=_DATA_DIR, config_dir=_CONFIG_DIR,
wireguard_manager=wireguard_manager, network_manager=network_manager,
)
auth_manager = AuthManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
auth_routes.auth_manager = auth_manager
# Apply firewall + DNS rules from stored peer settings (survives API restarts)
def _configured_domain() -> str:
@@ -230,6 +277,9 @@ service_bus.register_service('routing', routing_manager)
service_bus.register_service('vault', app.vault_manager)
service_bus.register_service('container', container_manager)
# Register auth blueprint
app.register_blueprint(auth_routes.auth_bp)
# Unified health monitoring
HEALTH_HISTORY_SIZE = 100
health_history = deque(maxlen=HEALTH_HISTORY_SIZE)
@@ -343,15 +393,19 @@ def _local_subnets():
def is_local_request():
# SECURITY: do NOT use X-Forwarded-For for auth. Caddy (and any reverse
# proxy) sets XFF to the original client IP, but the TCP peer that reaches
# this Flask process is always the proxy itself (an RFC-1918 Docker IP).
# Trusting XFF would let any internet client claim a local IP via that
# header. Only the direct TCP peer (request.remote_addr) is trustworthy:
# all legitimate local traffic comes directly from the Docker network or
# loopback, so remote_addr being local is a sufficient and necessary
# condition. The XFF header is read for logging only, never for access
# decisions.
# Trust the direct TCP peer (request.remote_addr) first — it is always
# the container or process making the connection and cannot be spoofed.
# In production Flask is behind Caddy inside Docker, so remote_addr is
# always Caddy's Docker IP (RFC-1918) and this check is sufficient.
#
# Additionally, when a trusted reverse-proxy (Caddy) is in the path, it
# appends the real client IP as the LAST entry of X-Forwarded-For.
# Trusting only the LAST XFF entry (not the first, which a client could
# set to anything) is safe: a spoofed first entry such as
# "XFF: 127.0.0.1, <real-ip>" still passes because the last entry is the
# real IP appended by Caddy. An attacker directly hitting Flask on :3000
# could craft any XFF they like, but in the Docker topology port 3000 is
# not exposed to the internet.
remote_addr = request.remote_addr
def _allowed(addr):
@@ -361,7 +415,7 @@ def is_local_request():
return True
try:
import ipaddress as _ipa
ip = _ipa.ip_address(addr)
ip = _ipa.ip_address(addr.strip())
if ip.is_loopback:
return True
# RFC-1918 private ranges
@@ -382,7 +436,21 @@ def is_local_request():
pass
return False
return _allowed(remote_addr)
if _allowed(remote_addr):
return True
# Check the last X-Forwarded-For entry (appended by the trusted proxy).
# Never trust any entry other than the last one.
try:
xff = request.headers.get('X-Forwarded-For', '')
if xff:
last_ip = xff.split(',')[-1].strip()
if last_ip and _allowed(last_ip):
return True
except Exception:
pass
return False
@app.route('/health', methods=['GET'])
def health_check():
@@ -1748,7 +1816,7 @@ def _next_peer_ip() -> str:
@app.route('/api/peers', methods=['POST'])
def add_peer():
"""Add a peer."""
"""Add a peer and auto-provision auth/email/calendar/files accounts."""
try:
data = request.get_json(silent=True)
if data is None:
@@ -1760,6 +1828,13 @@ def add_peer():
if field not in data:
return jsonify({"error": f"Missing required field: {field}"}), 400
# Password is required for peer provisioning
password = data.get('password') or ''
if not password:
return jsonify({"error": "Missing required field: password"}), 400
if len(password) < 10:
return jsonify({"error": "password must be at least 10 characters"}), 400
assigned_ip = data.get('ip') or _next_peer_ip()
# Validate service_access if provided
@@ -1768,9 +1843,51 @@ def add_peer():
if not isinstance(service_access, list) or not all(s in _valid_services for s in service_access):
return jsonify({"error": f"service_access must be a list of: {sorted(_valid_services)}"}), 400
peer_name = data['name']
# --- Provision service accounts with rollback on failure ---
provisioned = []
try:
auth_manager.create_user(peer_name, password, 'peer')
provisioned.append('auth')
email_manager.create_email_user(peer_name, password)
provisioned.append('email')
calendar_manager.create_calendar_user(peer_name, password)
provisioned.append('calendar')
file_manager.create_user(peer_name, password)
provisioned.append('files')
except Exception as prov_err:
logger.error(f"Peer provisioning failed at step {provisioned}: {prov_err}")
# Rollback everything provisioned so far
if 'files' in provisioned:
try:
file_manager.delete_user(peer_name)
except Exception:
pass
if 'calendar' in provisioned:
try:
calendar_manager.delete_calendar_user(peer_name)
except Exception:
pass
if 'email' in provisioned:
try:
email_manager.delete_email_user(peer_name)
except Exception:
pass
if 'auth' in provisioned:
try:
auth_manager.delete_user(peer_name)
except Exception:
pass
return jsonify({"error": f"Peer provisioning failed: {prov_err}"}), 500
# Add peer to registry with all provided fields
peer_info = {
'peer': data['name'],
'peer': peer_name,
'ip': assigned_ip,
'public_key': data['public_key'],
'private_key': data.get('private_key'),
@@ -1790,9 +1907,22 @@ def add_peer():
# Apply server-side enforcement immediately
firewall_manager.apply_peer_rules(peer_info['ip'], peer_info)
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain())
return jsonify({"message": f"Peer {data['name']} added successfully", "ip": assigned_ip}), 201
return jsonify({"message": f"Peer {peer_name} added successfully", "ip": assigned_ip}), 201
else:
return jsonify({"error": f"Peer {data['name']} already exists"}), 400
# Registry rejected (already exists) — rollback provisioned accounts
for svc in ('files', 'calendar', 'email', 'auth'):
try:
if svc == 'files':
file_manager.delete_user(peer_name)
elif svc == 'calendar':
calendar_manager.delete_calendar_user(peer_name)
elif svc == 'email':
email_manager.delete_email_user(peer_name)
elif svc == 'auth':
auth_manager.delete_user(peer_name)
except Exception:
pass
return jsonify({"error": f"Peer {peer_name} already exists"}), 400
except Exception as e:
logger.error(f"Error adding peer: {e}")
@@ -1847,7 +1977,7 @@ def clear_peer_reinstall(peer_name):
@app.route('/api/peers/<peer_name>', methods=['DELETE'])
def remove_peer(peer_name):
"""Remove a peer and clean up its firewall rules and DNS ACLs."""
"""Remove a peer and clean up firewall, DNS, and all service accounts."""
try:
peer = peer_registry.get_peer(peer_name)
if not peer:
@@ -1858,9 +1988,18 @@ def remove_peer(peer_name):
if peer_ip:
firewall_manager.clear_peer_rules(peer_ip)
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain())
return jsonify({"message": f"Peer {peer_name} removed successfully"})
else:
return jsonify({"message": f"Peer {peer_name} not found or already removed"})
# Clean up all provisioned service accounts (best-effort)
for _cleanup in [
lambda: email_manager.delete_email_user(peer_name),
lambda: calendar_manager.delete_calendar_user(peer_name),
lambda: file_manager.delete_user(peer_name),
lambda: auth_manager.delete_user(peer_name),
]:
try:
_cleanup()
except Exception:
pass
return jsonify({"message": f"Peer {peer_name} removed successfully"})
except Exception as e:
logger.error(f"Error removing peer: {e}")
return jsonify({"error": str(e)}), 500
@@ -2930,6 +3069,35 @@ def remove_volume(name):
success = container_manager.remove_volume(name, force=force)
return jsonify({'removed': success})
# ── Peer-scoped routes (/api/peer/*) ─────────────────────────────────────────
# These routes are accessible to peer-role sessions only (enforced by
# the enforce_auth before_request hook above).
@app.route('/api/peer/dashboard', methods=['GET'])
def peer_dashboard():
"""Return basic dashboard info for the authenticated peer."""
peer_name = session.get('peer_name')
peer = peer_registry.get_peer(peer_name) if peer_name else None
if not peer:
return jsonify({'error': 'Peer not found'}), 404
return jsonify({
'peer_name': peer_name,
'ip': peer.get('ip'),
'service_access': peer.get('service_access', []),
})
@app.route('/api/peer/services', methods=['GET'])
def peer_services():
"""Return the list of services accessible to the authenticated peer."""
peer_name = session.get('peer_name')
peer = peer_registry.get_peer(peer_name) if peer_name else None
services = peer.get('service_access', []) if peer else []
return jsonify({'services': services})
if __name__ == '__main__':
debug = os.environ.get('FLASK_DEBUG', '0') == '1'
app.run(host='0.0.0.0', port=3000, debug=debug)