Files
ServerKit/backend/app/api/auth.py
T
Juan Denis f6f08ae9d1 Fleet monitor + security and agent hardening
Adds cross-server Fleet Monitor endpoints and models, plus broad security and robustness improvements across agent, backend, and tooling.

Key changes:
- New fleet monitor API (endpoints for heatmap, comparison, alerts, thresholds, anomalies, forecast, search, exports, and Prometheus) and service skeleton.
- New DB models for ServerAlertThreshold and MetricAlert to support metric alerts.
- Agent discovery/registration hardening: HMAC-signed discovery requests, signature/timestamp validation, minimal discovery responses, HMAC-based unregister headers, and enforced command timeout cap in agent command handling.
- WebSocket and agent client TLS handling gated by SERVERKIT_INSECURE_TLS env var; added auth rate limiting on agent gateway.
- Docker-compose: parameterize agent image via AGENT_VERSION and avoid running as root by adding docker group access.
- Backend security & policy: tightened CSP script/style sources, request size limit, 2FA pending-token rejection on non-2FA endpoints, registration flow/guards, admin-only SSO admin routes via middleware, rate limits on sensitive endpoints, and logging for auth/2FA events.
- Database service hardening: identifier validation, avoid passing passwords on CLI (use env vars), safer subprocess usage, and docker exec handling improvements.
- CronService: command validation to prevent shell injection and run commands without shell=True; build_service and other subprocess usages updated similarly.
- Misc: .env.example notes clarified for encryption key generation, invitation token generation improved, agent registry timestamp window reduced, various logging and input validations added, frontend FleetMonitor page and styles stubbed in, and Vite/config/style updates.

Overall this commit implements fleet monitoring scaffolding and a set of security, validation, and operational improvements to make agent discovery, registration, command execution, and backend services safer and more robust.
2026-03-23 01:58:28 -04:00

338 lines
11 KiB
Python

import logging
from datetime import datetime
from flask import Blueprint, request, jsonify
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
jwt_required,
get_jwt_identity,
get_jwt
)
from app import db, limiter
from app.models import User, AuditLog, SystemSettings
from app.services.settings_service import SettingsService
from app.services.audit_service import AuditService
logger = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/setup-status', methods=['GET'])
def get_setup_status():
"""Check if initial setup is needed and if registration is enabled."""
needs_setup = SettingsService.needs_setup()
registration_enabled = SettingsService.is_registration_enabled()
# SSO info for login page
from app.services import sso_service
sso_providers = sso_service.get_enabled_providers()
password_login_enabled = sso_service.is_password_login_allowed()
# Migration status
from app.services.migration_service import MigrationService
migration_status = MigrationService.get_status()
return jsonify({
'needs_setup': needs_setup,
'registration_enabled': registration_enabled,
'sso_providers': sso_providers,
'password_login_enabled': password_login_enabled,
'needs_migration': migration_status['needs_migration'],
'migration_info': {
'pending_count': migration_status['pending_count'],
'current_revision': migration_status['current_revision'],
'head_revision': migration_status['head_revision'],
},
}), 200
@auth_bp.route('/register', methods=['POST'])
@limiter.limit("3 per minute")
def register():
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
invite_token = data.get('invite_token')
invitation = None
# Validate invitation if provided
if invite_token:
from app.services.invitation_service import InvitationService
invitation = InvitationService.validate_token(invite_token)
if not invitation:
return jsonify({'error': 'Invalid or expired invitation'}), 400
# Check if registration is allowed
is_first_user = User.query.count() == 0
if not is_first_user and not invitation:
if not SettingsService.needs_setup() and not SettingsService.is_registration_enabled():
logger.warning(f"Registration attempt blocked - setup already completed. IP: {request.remote_addr}")
return jsonify({'error': 'Registration is disabled'}), 403
if not SettingsService.is_registration_enabled():
return jsonify({'error': 'Registration is disabled'}), 403
email = data.get('email')
username = data.get('username')
password = data.get('password')
if not all([email, username, password]):
return jsonify({'error': 'Missing required fields'}), 400
if User.query.filter_by(email=email).first():
return jsonify({'error': 'This email or username is unavailable'}), 409
if User.query.filter_by(username=username).first():
return jsonify({'error': 'This email or username is unavailable'}), 409
if len(password) < 8:
return jsonify({'error': 'Password must be at least 8 characters'}), 400
# Determine role and permissions from invitation or defaults
if is_first_user:
role = User.ROLE_ADMIN
elif invitation:
role = invitation.role
else:
role = User.ROLE_DEVELOPER
user = User(
email=email,
username=username,
role=role
)
user.set_password(password)
# Apply custom permissions from invitation
if invitation and invitation.get_permissions():
user.set_permissions(invitation.get_permissions())
db.session.add(user)
db.session.commit()
# Mark invitation accepted
if invitation:
from app.services.invitation_service import InvitationService
InvitationService.accept_invitation(invite_token, user.id)
AuditService.log(
action=AuditLog.ACTION_INVITATION_ACCEPT,
user_id=user.id,
target_type='invitation',
target_id=invitation.id,
details={'role': role}
)
# Log the user creation
AuditService.log_user_action(
action=AuditLog.ACTION_USER_CREATE,
user_id=user.id,
target_user_id=user.id,
details={
'username': username,
'role': user.role,
'self_registration': True,
'via_invitation': invitation is not None
}
)
db.session.commit()
access_token = create_access_token(identity=user.id)
refresh_token = create_refresh_token(identity=user.id)
return jsonify({
'message': 'User registered successfully',
'user': user.to_dict(),
'access_token': access_token,
'refresh_token': refresh_token,
'is_first_user': is_first_user
}), 201
ALLOWED_USE_CASES = {'wordpress', 'web-apps', 'self-hosted', 'devops'}
@auth_bp.route('/complete-onboarding', methods=['POST'])
@jwt_required()
def complete_onboarding():
"""Complete the onboarding wizard and mark setup as done."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user or user.role != User.ROLE_ADMIN:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
use_cases = data.get('use_cases', [])
# Validate use_cases
if not isinstance(use_cases, list):
return jsonify({'error': 'use_cases must be a list'}), 400
invalid = set(use_cases) - ALLOWED_USE_CASES
if invalid:
return jsonify({'error': f'Invalid use cases: {", ".join(invalid)}'}), 400
# Save onboarding use cases
SettingsService.set('onboarding_use_cases', use_cases, user_id=user.id)
# Mark setup as complete
SettingsService.complete_setup(user_id=user.id)
return jsonify({'message': 'Onboarding completed successfully'}), 200
@auth_bp.route('/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
# Check if password login is disabled (SSO-only mode)
from app.services import sso_service
if not sso_service.is_password_login_allowed():
return jsonify({'error': 'Password login is disabled. Please use SSO.'}), 403
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
login_id = data.get('email') # Can be email or username
password = data.get('password')
if not all([login_id, password]):
return jsonify({'error': 'Missing email/username or password'}), 400
# Try to find user by email or username
user = User.query.filter(
(User.email == login_id) | (User.username == login_id)
).first()
# Check if account is locked
if user and user.is_locked:
remaining = int((user.locked_until - datetime.utcnow()).total_seconds() / 60)
return jsonify({
'error': f'Account is locked. Try again in {remaining} minute(s).'
}), 429
if not user or not user.check_password(password):
# Record failed login attempt
if user:
user.record_failed_login()
AuditService.log_login(user.id, success=False, details={'reason': 'invalid_password'})
db.session.commit()
return jsonify({'error': 'Invalid username/email or password'}), 401
if not user.is_active:
AuditService.log_login(user.id, success=False, details={'reason': 'account_deactivated'})
db.session.commit()
return jsonify({'error': 'Account is deactivated'}), 403
# Check if 2FA is enabled
if user.totp_enabled:
# Create a short-lived temporary token for 2FA verification
# This token can only be used to complete 2FA, not to access resources
temp_token = create_access_token(
identity=user.id,
additional_claims={'2fa_pending': True},
expires_delta=False # Use default (short) expiry
)
return jsonify({
'requires_2fa': True,
'temp_token': temp_token,
'message': 'Two-factor authentication required'
}), 200
# No 2FA - proceed with normal login
user.reset_failed_login()
user.last_login_at = datetime.utcnow()
db.session.commit()
# Log successful login
AuditService.log_login(user.id, success=True)
db.session.commit()
access_token = create_access_token(identity=user.id)
refresh_token = create_refresh_token(identity=user.id)
return jsonify({
'user': user.to_dict(),
'access_token': access_token,
'refresh_token': refresh_token
}), 200
@auth_bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user or not user.is_active:
return jsonify({'error': 'Invalid user'}), 401
access_token = create_access_token(identity=current_user_id)
return jsonify({
'access_token': access_token
}), 200
@auth_bp.route('/me', methods=['GET'])
@jwt_required()
def get_current_user():
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify({'user': user.to_dict()}), 200
@auth_bp.route('/me', methods=['PUT'])
@limiter.limit("3 per minute")
@jwt_required()
def update_current_user():
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
data = request.get_json()
if 'username' in data:
existing = User.query.filter_by(username=data['username']).first()
if existing and existing.id != user.id:
return jsonify({'error': 'Username already taken'}), 409
user.username = data['username']
if 'email' in data:
existing = User.query.filter_by(email=data['email']).first()
if existing and existing.id != user.id:
return jsonify({'error': 'Email already registered'}), 409
user.email = data['email']
if 'password' in data:
if len(data['password']) < 8:
return jsonify({'error': 'Password must be at least 8 characters'}), 400
user.set_password(data['password'])
if 'sidebar_config' in data:
config = data['sidebar_config']
if isinstance(config, dict):
preset = config.get('preset', 'full')
valid_presets = ['full', 'web', 'email', 'devops', 'minimal', 'custom']
if preset not in valid_presets:
return jsonify({'error': f'Invalid sidebar preset: {preset}'}), 400
hidden = config.get('hiddenItems', [])
if not isinstance(hidden, list):
return jsonify({'error': 'hiddenItems must be a list'}), 400
user.set_sidebar_config({'preset': preset, 'hiddenItems': hidden})
db.session.commit()
return jsonify({'user': user.to_dict()}), 200