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
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""
Flask test-client tests for auth routes (api/auth_routes.py).
The auth_routes Blueprint is expected to be registered on the Flask app at
/api/auth/... The module-level `auth_manager` in app is patched to an
in-process AuthManager backed by a tmp_path so tests run without Docker.
Route contract tested here:
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/me
POST /api/auth/change-password
POST /api/auth/admin/reset-password
GET /api/auth/users
"""
import os
import sys
import json
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from app import app
from auth_manager import AuthManager
# ── helpers ───────────────────────────────────────────────────────────────────
def _make_auth_manager(tmp_path):
data_dir = str(tmp_path / 'data')
config_dir = str(tmp_path / 'config')
os.makedirs(data_dir, exist_ok=True)
os.makedirs(config_dir, exist_ok=True)
mgr = AuthManager(data_dir=data_dir, config_dir=config_dir)
mgr.create_user('admin', 'AdminPass123!', 'admin')
mgr.create_user('alice', 'AlicePass123!', 'peer')
return mgr
def _login(client, username, password):
return client.post(
'/api/auth/login',
data=json.dumps({'username': username, 'password': password}),
content_type='application/json',
)
# ── fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def auth_mgr(tmp_path):
return _make_auth_manager(tmp_path)
@pytest.fixture
def app_client(auth_mgr):
"""Raw test client — not logged in. auth_manager is patched to auth_mgr."""
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
with patch('app.auth_manager', auth_mgr):
# also patch inside auth_routes module if it imports auth_manager separately
try:
import auth_routes
with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True):
with app.test_client() as client:
yield client
except (ImportError, AttributeError):
with app.test_client() as client:
yield client
@pytest.fixture
def admin_client(auth_mgr):
"""Test client already authenticated as admin."""
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
with patch('app.auth_manager', auth_mgr):
try:
import auth_routes
with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True):
with app.test_client() as client:
r = _login(client, 'admin', 'AdminPass123!')
assert r.status_code == 200, (
f'admin login failed with {r.status_code}: {r.data}'
)
yield client
except (ImportError, AttributeError):
with app.test_client() as client:
r = _login(client, 'admin', 'AdminPass123!')
assert r.status_code == 200, (
f'admin login failed with {r.status_code}: {r.data}'
)
yield client
@pytest.fixture
def peer_client(auth_mgr):
"""Test client already authenticated as alice (peer)."""
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
with patch('app.auth_manager', auth_mgr):
try:
import auth_routes
with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True):
with app.test_client() as client:
r = _login(client, 'alice', 'AlicePass123!')
assert r.status_code == 200, (
f'alice login failed with {r.status_code}: {r.data}'
)
yield client
except (ImportError, AttributeError):
with app.test_client() as client:
r = _login(client, 'alice', 'AlicePass123!')
assert r.status_code == 200, (
f'alice login failed with {r.status_code}: {r.data}'
)
yield client
@pytest.fixture
def anon_client(auth_mgr):
"""Test client with no session (anonymous)."""
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
with patch('app.auth_manager', auth_mgr):
try:
import auth_routes
with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True):
with app.test_client() as client:
yield client
except (ImportError, AttributeError):
with app.test_client() as client:
yield client
# ── login ─────────────────────────────────────────────────────────────────────
def test_login_success(app_client):
r = _login(app_client, 'admin', 'AdminPass123!')
assert r.status_code == 200
data = json.loads(r.data)
assert 'username' in data
assert 'role' in data
assert data['username'] == 'admin'
def test_login_success_sets_session_cookie(app_client):
r = _login(app_client, 'admin', 'AdminPass123!')
assert r.status_code == 200
assert 'session' in (r.headers.get('Set-Cookie', '') or '')
def test_login_wrong_password(app_client):
r = _login(app_client, 'admin', 'WrongPassword!')
assert r.status_code == 401
def test_login_unknown_user(app_client):
r = _login(app_client, 'nobody', 'SomePassword1!')
assert r.status_code == 401
def test_login_missing_username(app_client):
r = app_client.post(
'/api/auth/login',
data=json.dumps({'password': 'AdminPass123!'}),
content_type='application/json',
)
assert r.status_code in (400, 401)
def test_login_missing_password(app_client):
r = app_client.post(
'/api/auth/login',
data=json.dumps({'username': 'admin'}),
content_type='application/json',
)
assert r.status_code in (400, 401)
def test_login_empty_body(app_client):
r = app_client.post('/api/auth/login', content_type='application/json')
assert r.status_code in (400, 401)
def test_login_locked_account(app_client, auth_mgr):
"""After enough failed attempts alice's account locks; subsequent login → 423."""
from auth_manager import LOCKOUT_THRESHOLD
for _ in range(LOCKOUT_THRESHOLD):
auth_mgr.verify_password('alice', 'wrong')
r = _login(app_client, 'alice', 'AlicePass123!')
assert r.status_code == 423
# ── logout ────────────────────────────────────────────────────────────────────
def test_logout_returns_200(admin_client):
r = admin_client.post('/api/auth/logout')
assert r.status_code == 200
def test_logout_then_me_returns_401(admin_client):
admin_client.post('/api/auth/logout')
r = admin_client.get('/api/auth/me')
assert r.status_code == 401
# ── /api/auth/me ──────────────────────────────────────────────────────────────
def test_me_authenticated_returns_200(admin_client):
r = admin_client.get('/api/auth/me')
assert r.status_code == 200
def test_me_authenticated_returns_username(admin_client):
r = admin_client.get('/api/auth/me')
data = json.loads(r.data)
assert data.get('username') == 'admin'
def test_me_unauthenticated_returns_401(anon_client):
r = anon_client.get('/api/auth/me')
assert r.status_code == 401
# ── change-password ───────────────────────────────────────────────────────────
def test_change_password_success(peer_client):
r = peer_client.post(
'/api/auth/change-password',
data=json.dumps({'old_password': 'AlicePass123!', 'new_password': 'AliceNew99!'}),
content_type='application/json',
)
assert r.status_code == 200
def test_change_password_new_password_works(peer_client, auth_mgr):
peer_client.post(
'/api/auth/change-password',
data=json.dumps({'old_password': 'AlicePass123!', 'new_password': 'AliceNew99!'}),
content_type='application/json',
)
result = auth_mgr.verify_password('alice', 'AliceNew99!')
assert result is not None
def test_change_password_wrong_old_returns_400(peer_client):
r = peer_client.post(
'/api/auth/change-password',
data=json.dumps({'old_password': 'WrongOld!', 'new_password': 'AliceNew99!'}),
content_type='application/json',
)
assert r.status_code == 400
def test_change_password_unauthenticated_returns_401(anon_client):
r = anon_client.post(
'/api/auth/change-password',
data=json.dumps({'old_password': 'AlicePass123!', 'new_password': 'AliceNew99!'}),
content_type='application/json',
)
assert r.status_code == 401
# ── admin reset-password ──────────────────────────────────────────────────────
def test_admin_reset_password_success(admin_client):
r = admin_client.post(
'/api/auth/admin/reset-password',
data=json.dumps({'username': 'alice', 'new_password': 'AdminSet99!'}),
content_type='application/json',
)
assert r.status_code == 200
def test_admin_reset_password_peer_forbidden(peer_client):
r = peer_client.post(
'/api/auth/admin/reset-password',
data=json.dumps({'username': 'admin', 'new_password': 'HackedPass1!'}),
content_type='application/json',
)
assert r.status_code == 403
def test_admin_reset_password_unknown_user(admin_client):
r = admin_client.post(
'/api/auth/admin/reset-password',
data=json.dumps({'username': 'nobody', 'new_password': 'SomePass1!'}),
content_type='application/json',
)
assert r.status_code in (400, 404)
def test_admin_reset_password_unauthenticated(anon_client):
r = anon_client.post(
'/api/auth/admin/reset-password',
data=json.dumps({'username': 'alice', 'new_password': 'SomePass1!'}),
content_type='application/json',
)
assert r.status_code == 401
# ── /api/auth/users ───────────────────────────────────────────────────────────
def test_list_users_admin_returns_200(admin_client):
r = admin_client.get('/api/auth/users')
assert r.status_code == 200
def test_list_users_contains_admin_and_alice(admin_client):
r = admin_client.get('/api/auth/users')
users = json.loads(r.data)
assert isinstance(users, list)
names = [u['username'] for u in users]
assert 'admin' in names
assert 'alice' in names
def test_list_users_no_hashes_in_response(admin_client):
r = admin_client.get('/api/auth/users')
users = json.loads(r.data)
for u in users:
assert 'password_hash' not in u
def test_list_users_peer_forbidden(peer_client):
r = peer_client.get('/api/auth/users')
assert r.status_code == 403
def test_list_users_unauthenticated(anon_client):
r = anon_client.get('/api/auth/users')
assert r.status_code == 401