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)
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""
AuthManager local user store for PIC API.
Manages admin and peer accounts, password hashing (bcrypt),
account lockout, and bootstrap of the initial admin password.
"""
import os
import json
import re
import threading
import tempfile
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
import bcrypt
from base_service_manager import BaseServiceManager
USERNAME_RE = re.compile(r'^[a-z][a-z0-9_.-]{2,31}$')
LOCKOUT_THRESHOLD = 5
LOCKOUT_DURATION = timedelta(minutes=15)
def _utcnow_iso() -> str:
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
class AuthManager(BaseServiceManager):
"""Local authentication / authorization store."""
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
super().__init__('auth', data_dir=data_dir, config_dir=config_dir)
self._users_file = os.path.join(data_dir, 'auth_users.json')
self._lock = threading.RLock()
self._ensure_file()
try:
self._bootstrap_admin_if_needed()
except Exception as e:
self.logger.warning(f'Admin bootstrap failed (non-fatal): {e}')
# ── filesystem helpers ────────────────────────────────────────────────
def _ensure_file(self):
try:
os.makedirs(os.path.dirname(self._users_file), exist_ok=True)
except Exception:
pass
if not os.path.exists(self._users_file):
try:
with open(self._users_file, 'w') as f:
f.write('[]')
try:
os.chmod(self._users_file, 0o600)
except Exception:
pass
except Exception as e:
self.logger.error(f'Could not create users file: {e}')
def _load_users(self) -> List[Dict[str, Any]]:
with self._lock:
try:
with open(self._users_file, 'r') as f:
data = json.load(f)
if isinstance(data, list):
return data
return []
except FileNotFoundError:
return []
except Exception as e:
self.logger.error(f'Failed to load users: {e}')
return []
def _save_users(self, users: List[Dict[str, Any]]):
with self._lock:
directory = os.path.dirname(self._users_file) or '.'
fd, tmp_path = tempfile.mkstemp(prefix='.auth_users.', dir=directory)
try:
with os.fdopen(fd, 'w') as f:
json.dump(users, f, indent=2)
try:
os.chmod(tmp_path, 0o600)
except Exception:
pass
os.replace(tmp_path, self._users_file)
except Exception:
try:
os.unlink(tmp_path)
except Exception:
pass
raise
# ── bootstrap ─────────────────────────────────────────────────────────
def _bootstrap_admin_if_needed(self):
users = self._load_users()
init_pw_path = os.path.join(self.data_dir, '.admin_initial_password')
has_admin = any(u.get('role') == 'admin' for u in users)
if has_admin:
# Remove plaintext file even when admin already exists (security hygiene)
if os.path.exists(init_pw_path):
try:
os.unlink(init_pw_path)
except Exception:
pass
return
if not os.path.exists(init_pw_path):
return
try:
with open(init_pw_path, 'r') as f:
password = f.read().strip()
if not password:
return
ok = self.create_user('admin', password, 'admin')
if ok:
self.logger.info('Bootstrapped initial admin user from .admin_initial_password')
try:
os.unlink(init_pw_path)
except Exception as e:
self.logger.warning(f'Could not delete init password file: {e}')
except Exception as e:
self.logger.error(f'Admin bootstrap failed: {e}')
# ── user CRUD ─────────────────────────────────────────────────────────
@staticmethod
def _strip_hash(user: Dict[str, Any]) -> Dict[str, Any]:
clean = {k: v for k, v in user.items() if k != 'password_hash'}
return clean
def _hash_password(self, password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12)).decode('utf-8')
def create_user(self, username: str, password: str, role: str,
peer_name: Optional[str] = None) -> bool:
if role not in ('admin', 'peer'):
self.logger.warning(f'Invalid role: {role}')
return False
if not username or not USERNAME_RE.match(username):
self.logger.warning(f'Invalid username: {username}')
return False
if not password or len(password) < 1:
self.logger.warning('Empty password rejected')
return False
with self._lock:
users = self._load_users()
if any(u.get('username') == username for u in users):
self.logger.warning(f'Duplicate username: {username}')
return False
now = _utcnow_iso()
if role == 'peer':
peer_name = username
must_change = True
else:
peer_name = None
must_change = False
user = {
'username': username,
'role': role,
'peer_name': peer_name,
'password_hash': self._hash_password(password),
'created_at': now,
'updated_at': now,
'last_login_at': None,
'failed_attempts': 0,
'locked_until': None,
'must_change_password': must_change,
}
users.append(user)
try:
self._save_users(users)
self.logger.info(f'Created user: {username} (role={role})')
return True
except Exception as e:
self.logger.error(f'create_user save failed: {e}')
return False
def delete_user(self, username: str) -> bool:
with self._lock:
users = self._load_users()
target = next((u for u in users if u.get('username') == username), None)
if not target:
return False
if target.get('role') == 'admin':
admins = [u for u in users if u.get('role') == 'admin']
if len(admins) <= 1:
self.logger.warning('Refusing to delete last admin user')
return False
new_users = [u for u in users if u.get('username') != username]
try:
self._save_users(new_users)
self.logger.info(f'Deleted user: {username}')
return True
except Exception as e:
self.logger.error(f'delete_user save failed: {e}')
return False
def get_user(self, username: str) -> Optional[Dict[str, Any]]:
users = self._load_users()
for u in users:
if u.get('username') == username:
return self._strip_hash(u)
return None
def list_users(self) -> List[Dict[str, Any]]:
return [self._strip_hash(u) for u in self._load_users()]
# ── auth operations ───────────────────────────────────────────────────
def _is_locked(self, user: Dict[str, Any]) -> bool:
locked_until = user.get('locked_until')
if not locked_until:
return False
try:
until = datetime.strptime(locked_until, '%Y-%m-%dT%H:%M:%SZ')
except Exception:
return False
return datetime.utcnow() < until
def verify_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
with self._lock:
users = self._load_users()
idx = next((i for i, u in enumerate(users) if u.get('username') == username), None)
if idx is None:
return None
user = users[idx]
if self._is_locked(user):
self.logger.warning(f'Login blocked — account locked: {username}')
return None
stored = user.get('password_hash', '')
ok = False
try:
if stored:
ok = bcrypt.checkpw(password.encode('utf-8'), stored.encode('utf-8'))
except Exception as e:
self.logger.error(f'bcrypt check failed for {username}: {e}')
ok = False
now = _utcnow_iso()
if ok:
user['failed_attempts'] = 0
user['locked_until'] = None
user['last_login_at'] = now
users[idx] = user
try:
self._save_users(users)
except Exception as e:
self.logger.error(f'save after success failed: {e}')
return self._strip_hash(user)
# failure
user['failed_attempts'] = int(user.get('failed_attempts', 0)) + 1
if user['failed_attempts'] >= LOCKOUT_THRESHOLD:
user['locked_until'] = (datetime.utcnow() + LOCKOUT_DURATION).strftime('%Y-%m-%dT%H:%M:%SZ')
self.logger.warning(f'Account locked: {username}')
users[idx] = user
try:
self._save_users(users)
except Exception as e:
self.logger.error(f'save after failure failed: {e}')
return None
def change_password(self, username: str, old_password: str, new_password: str) -> bool:
if not new_password:
return False
with self._lock:
users = self._load_users()
idx = next((i for i, u in enumerate(users) if u.get('username') == username), None)
if idx is None:
return False
user = users[idx]
if self._is_locked(user):
return False
stored = user.get('password_hash', '')
try:
if not stored or not bcrypt.checkpw(old_password.encode('utf-8'), stored.encode('utf-8')):
return False
except Exception:
return False
user['password_hash'] = self._hash_password(new_password)
user['updated_at'] = _utcnow_iso()
user['must_change_password'] = False
user['failed_attempts'] = 0
user['locked_until'] = None
users[idx] = user
try:
self._save_users(users)
self.logger.info(f'Password changed: {username}')
return True
except Exception as e:
self.logger.error(f'change_password save failed: {e}')
return False
def set_password_admin(self, username: str, new_password: str) -> bool:
if not new_password:
return False
with self._lock:
users = self._load_users()
idx = next((i for i, u in enumerate(users) if u.get('username') == username), None)
if idx is None:
return False
user = users[idx]
user['password_hash'] = self._hash_password(new_password)
user['updated_at'] = _utcnow_iso()
user['failed_attempts'] = 0
user['locked_until'] = None
user['must_change_password'] = True
users[idx] = user
try:
self._save_users(users)
self.logger.info(f'Admin reset password for: {username}')
return True
except Exception as e:
self.logger.error(f'set_password_admin save failed: {e}')
return False
# ── BaseServiceManager interface ──────────────────────────────────────
def get_status(self) -> Dict[str, Any]:
users = self._load_users()
return {
'users': len(users),
'has_admin': any(u.get('role') == 'admin' for u in users),
}
def test_connectivity(self) -> Dict[str, Any]:
return {'ok': True}
def get_config(self) -> Dict[str, Any]:
return {}
def update_config(self, config: Dict[str, Any]) -> bool:
return True
def validate_config(self, config: Dict[str, Any]) -> bool:
return True
def get_logs(self, lines: int = 50) -> List[str]:
return []
def restart_service(self) -> bool:
return True
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
Auth-related Flask routes (login, logout, change-password, etc).
The Blueprint expects ``auth_manager`` (an instance of
``auth_manager.AuthManager``) to be assigned at module level by app.py
after instantiation. A ``require_auth(role=None)`` decorator is also
exported so individual routes can opt-in to specific role requirements.
"""
from functools import wraps
from flask import Blueprint, request, jsonify, session
# Set by app.py after AuthManager is constructed.
auth_manager = None # type: ignore
auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')
def require_auth(role=None):
"""Decorator that enforces session authentication and an optional role."""
def deco(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
username = session.get('username')
if not username:
return jsonify({'error': 'Not authenticated'}), 401
if role == 'admin' and session.get('role') != 'admin':
return jsonify({'error': 'Forbidden'}), 403
if role == 'peer' and session.get('role') != 'peer':
return jsonify({'error': 'Forbidden'}), 403
request.auth_user = {
'username': username,
'role': session.get('role'),
'peer_name': session.get('peer_name'),
}
return fn(*args, **kwargs)
return wrapper
return deco
@auth_bp.route('/login', methods=['POST'])
def login():
if auth_manager is None:
return jsonify({'error': 'Auth not initialised'}), 500
data = request.get_json(silent=True) or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
return jsonify({'error': 'username and password required'}), 400
# Detect lockout up-front so we can return 423 instead of generic 401.
pre = auth_manager.get_user(username)
if pre and pre.get('locked_until'):
try:
from datetime import datetime
until = datetime.strptime(pre['locked_until'], '%Y-%m-%dT%H:%M:%SZ')
if datetime.utcnow() < until:
return jsonify({'error': 'Account locked', 'locked_until': pre['locked_until']}), 423
except Exception:
pass
user = auth_manager.verify_password(username, password)
if not user:
# Re-check lockout after the attempt (this attempt may have triggered it).
post = auth_manager.get_user(username)
if post and post.get('locked_until'):
try:
from datetime import datetime
until = datetime.strptime(post['locked_until'], '%Y-%m-%dT%H:%M:%SZ')
if datetime.utcnow() < until:
return jsonify({'error': 'Account locked', 'locked_until': post['locked_until']}), 423
except Exception:
pass
return jsonify({'error': 'Invalid credentials'}), 401
session.permanent = True
session['username'] = user['username']
session['role'] = user.get('role')
session['peer_name'] = user.get('peer_name')
return jsonify({
'username': user['username'],
'role': user.get('role'),
'peer_name': user.get('peer_name'),
'must_change_password': bool(user.get('must_change_password', False)),
})
@auth_bp.route('/logout', methods=['POST'])
def logout():
session.clear()
return jsonify({'ok': True})
@auth_bp.route('/me', methods=['GET'])
def me():
username = session.get('username')
if not username:
return jsonify({'error': 'Not authenticated'}), 401
return jsonify({
'username': username,
'role': session.get('role'),
'peer_name': session.get('peer_name'),
})
@auth_bp.route('/change-password', methods=['POST'])
@require_auth()
def change_password():
if auth_manager is None:
return jsonify({'error': 'Auth not initialised'}), 500
data = request.get_json(silent=True) or {}
old_pw = data.get('old_password') or ''
new_pw = data.get('new_password') or ''
if not old_pw or not new_pw:
return jsonify({'error': 'old_password and new_password required'}), 400
if len(new_pw) < 10:
return jsonify({'error': 'new_password must be at least 10 characters'}), 400
username = session.get('username')
ok = auth_manager.change_password(username, old_pw, new_pw)
if not ok:
return jsonify({'error': 'Password change failed'}), 400
return jsonify({'ok': True})
@auth_bp.route('/admin/reset-password', methods=['POST'])
@require_auth('admin')
def admin_reset_password():
if auth_manager is None:
return jsonify({'error': 'Auth not initialised'}), 500
data = request.get_json(silent=True) or {}
username = (data.get('username') or '').strip()
new_pw = data.get('new_password') or ''
if not username or not new_pw:
return jsonify({'error': 'username and new_password required'}), 400
if len(new_pw) < 10:
return jsonify({'error': 'new_password must be at least 10 characters'}), 400
ok = auth_manager.set_password_admin(username, new_pw)
if not ok:
return jsonify({'error': 'Reset failed (user not found?)'}), 400
return jsonify({'ok': True})
@auth_bp.route('/users', methods=['GET'])
@require_auth('admin')
def list_users():
if auth_manager is None:
return jsonify({'error': 'Auth not initialised'}), 500
return jsonify(auth_manager.list_users())