409 Zeilen
12 KiB
Python
409 Zeilen
12 KiB
Python
import os
|
|
import sys
|
|
from flask import Flask, request, jsonify
|
|
from flask_cors import CORS
|
|
import jwt
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
from functools import wraps
|
|
from marshmallow import Schema, fields, ValidationError
|
|
|
|
# Add parent directory to path for imports
|
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
|
|
from config import get_config
|
|
from repositories.license_repo import LicenseRepository
|
|
from repositories.cache_repo import CacheRepository
|
|
from events.event_bus import EventBus, Event, EventTypes
|
|
from models import EventType, ValidationRequest, ValidationResponse
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Initialize Flask app
|
|
app = Flask(__name__)
|
|
config = get_config()
|
|
app.config.from_object(config)
|
|
CORS(app)
|
|
|
|
# Initialize dependencies
|
|
license_repo = LicenseRepository(config.DATABASE_URL)
|
|
cache_repo = CacheRepository(config.REDIS_URL)
|
|
event_bus = EventBus(config.RABBITMQ_URL)
|
|
|
|
# Validation schemas
|
|
class ValidateSchema(Schema):
|
|
license_key = fields.Str(required=True)
|
|
hardware_id = fields.Str(required=True)
|
|
app_version = fields.Str()
|
|
|
|
class ActivateSchema(Schema):
|
|
license_key = fields.Str(required=True)
|
|
hardware_id = fields.Str(required=True)
|
|
device_name = fields.Str()
|
|
os_info = fields.Dict()
|
|
|
|
class HeartbeatSchema(Schema):
|
|
session_data = fields.Dict()
|
|
|
|
class OfflineTokenSchema(Schema):
|
|
duration_hours = fields.Int(missing=24, validate=lambda x: 0 < x <= 72)
|
|
|
|
def require_api_key(f):
|
|
"""Decorator to require API key"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
api_key = request.headers.get('X-API-Key')
|
|
|
|
if not api_key:
|
|
return jsonify({"error": "Missing API key"}), 401
|
|
|
|
# For now, accept any API key starting with 'sk_'
|
|
# In production, validate against database
|
|
if not api_key.startswith('sk_'):
|
|
return jsonify({"error": "Invalid API key"}), 401
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
return decorated_function
|
|
|
|
def require_auth_token(f):
|
|
"""Decorator to require JWT token"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
auth_header = request.headers.get('Authorization')
|
|
|
|
if not auth_header or not auth_header.startswith('Bearer '):
|
|
return jsonify({"error": "Missing or invalid authorization header"}), 401
|
|
|
|
token = auth_header.split(' ')[1]
|
|
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
config.JWT_SECRET,
|
|
algorithms=[config.JWT_ALGORITHM]
|
|
)
|
|
request.token_payload = payload
|
|
return f(*args, **kwargs)
|
|
except jwt.ExpiredSignatureError:
|
|
return jsonify({"error": "Token has expired"}), 401
|
|
except jwt.InvalidTokenError:
|
|
return jsonify({"error": "Invalid token"}), 401
|
|
|
|
return decorated_function
|
|
|
|
def get_client_ip():
|
|
"""Get client IP address"""
|
|
if request.headers.get('X-Forwarded-For'):
|
|
return request.headers.get('X-Forwarded-For').split(',')[0]
|
|
return request.remote_addr
|
|
|
|
@app.route('/health', methods=['GET'])
|
|
def health_check():
|
|
"""Health check endpoint"""
|
|
return jsonify({
|
|
"status": "healthy",
|
|
"service": "license-api",
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
@app.route('/api/v1/license/validate', methods=['POST'])
|
|
@require_api_key
|
|
def validate_license():
|
|
"""Validate license key with hardware ID"""
|
|
schema = ValidateSchema()
|
|
|
|
try:
|
|
data = schema.load(request.get_json())
|
|
except ValidationError as e:
|
|
return jsonify({"error": "Invalid request", "details": e.messages}), 400
|
|
|
|
license_key = data['license_key']
|
|
hardware_id = data['hardware_id']
|
|
app_version = data.get('app_version')
|
|
|
|
# Check cache first
|
|
cached_result = cache_repo.get_license_validation(license_key, hardware_id)
|
|
if cached_result:
|
|
logger.info(f"Cache hit for license validation: {license_key[:8]}...")
|
|
return jsonify(cached_result)
|
|
|
|
# Get license from database
|
|
license = license_repo.get_license_by_key(license_key)
|
|
|
|
if not license:
|
|
event_bus.publish(Event(
|
|
EventTypes.LICENSE_VALIDATION_FAILED,
|
|
{"license_key": license_key, "reason": "not_found"},
|
|
"license-api"
|
|
))
|
|
return jsonify({
|
|
"valid": False,
|
|
"error": "License not found",
|
|
"error_code": "LICENSE_NOT_FOUND"
|
|
}), 404
|
|
|
|
# Check if license is active
|
|
if not license['is_active']:
|
|
event_bus.publish(Event(
|
|
EventTypes.LICENSE_VALIDATION_FAILED,
|
|
{"license_id": license['id'], "reason": "inactive"},
|
|
"license-api"
|
|
))
|
|
return jsonify({
|
|
"valid": False,
|
|
"error": "License is not active",
|
|
"error_code": "LICENSE_INACTIVE"
|
|
}), 403
|
|
|
|
# Check expiration
|
|
if license['expires_at'] and datetime.utcnow() > license['expires_at']:
|
|
event_bus.publish(Event(
|
|
EventTypes.LICENSE_EXPIRED,
|
|
{"license_id": license['id']},
|
|
"license-api"
|
|
))
|
|
return jsonify({
|
|
"valid": False,
|
|
"error": "License has expired",
|
|
"error_code": "LICENSE_EXPIRED"
|
|
}), 403
|
|
|
|
# Check device limit
|
|
device_count = license_repo.get_device_count(license['id'])
|
|
if device_count >= license['max_devices']:
|
|
# Check if this device is already registered
|
|
if not license_repo.check_hardware_id_exists(license['id'], hardware_id):
|
|
return jsonify({
|
|
"valid": False,
|
|
"error": "Device limit exceeded",
|
|
"error_code": "DEVICE_LIMIT_EXCEEDED",
|
|
"current_devices": device_count,
|
|
"max_devices": license['max_devices']
|
|
}), 403
|
|
|
|
# Record heartbeat
|
|
license_repo.record_heartbeat(
|
|
license_id=license['id'],
|
|
hardware_id=hardware_id,
|
|
ip_address=get_client_ip(),
|
|
user_agent=request.headers.get('User-Agent'),
|
|
app_version=app_version
|
|
)
|
|
|
|
# Create response
|
|
response = {
|
|
"valid": True,
|
|
"license_id": license['id'],
|
|
"expires_at": license['expires_at'].isoformat() if license['expires_at'] else None,
|
|
"features": license.get('features', []),
|
|
"limits": {
|
|
"max_devices": license['max_devices'],
|
|
"current_devices": device_count
|
|
}
|
|
}
|
|
|
|
# Cache the result
|
|
cache_repo.set_license_validation(
|
|
license_key,
|
|
hardware_id,
|
|
response,
|
|
config.CACHE_TTL_VALIDATION
|
|
)
|
|
|
|
# Publish success event
|
|
event_bus.publish(Event(
|
|
EventTypes.LICENSE_VALIDATED,
|
|
{
|
|
"license_id": license['id'],
|
|
"hardware_id": hardware_id,
|
|
"ip_address": get_client_ip()
|
|
},
|
|
"license-api"
|
|
))
|
|
|
|
return jsonify(response)
|
|
|
|
@app.route('/api/v1/license/activate', methods=['POST'])
|
|
@require_api_key
|
|
def activate_license():
|
|
"""Activate license on a new device"""
|
|
schema = ActivateSchema()
|
|
|
|
try:
|
|
data = schema.load(request.get_json())
|
|
except ValidationError as e:
|
|
return jsonify({"error": "Invalid request", "details": e.messages}), 400
|
|
|
|
license_key = data['license_key']
|
|
hardware_id = data['hardware_id']
|
|
device_name = data.get('device_name')
|
|
os_info = data.get('os_info', {})
|
|
|
|
# Get license
|
|
license = license_repo.get_license_by_key(license_key)
|
|
|
|
if not license:
|
|
return jsonify({
|
|
"error": "License not found",
|
|
"error_code": "LICENSE_NOT_FOUND"
|
|
}), 404
|
|
|
|
if not license['is_active']:
|
|
return jsonify({
|
|
"error": "License is not active",
|
|
"error_code": "LICENSE_INACTIVE"
|
|
}), 403
|
|
|
|
# Check if already activated on this device
|
|
if license_repo.check_hardware_id_exists(license['id'], hardware_id):
|
|
return jsonify({
|
|
"error": "License already activated on this device",
|
|
"error_code": "ALREADY_ACTIVATED"
|
|
}), 400
|
|
|
|
# Check device limit
|
|
device_count = license_repo.get_device_count(license['id'])
|
|
if device_count >= license['max_devices']:
|
|
return jsonify({
|
|
"error": "Device limit exceeded",
|
|
"error_code": "DEVICE_LIMIT_EXCEEDED",
|
|
"current_devices": device_count,
|
|
"max_devices": license['max_devices']
|
|
}), 403
|
|
|
|
# Record activation
|
|
license_repo.record_activation_event(
|
|
license_id=license['id'],
|
|
event_type=EventType.ACTIVATION,
|
|
hardware_id=hardware_id,
|
|
ip_address=get_client_ip(),
|
|
user_agent=request.headers.get('User-Agent'),
|
|
success=True,
|
|
metadata={
|
|
"device_name": device_name,
|
|
"os_info": os_info
|
|
}
|
|
)
|
|
|
|
# Invalidate cache
|
|
cache_repo.invalidate_license_cache(license['id'])
|
|
|
|
# Publish event
|
|
event_bus.publish(Event(
|
|
EventTypes.LICENSE_ACTIVATED,
|
|
{
|
|
"license_id": license['id'],
|
|
"hardware_id": hardware_id,
|
|
"device_name": device_name
|
|
},
|
|
"license-api"
|
|
))
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"license_id": license['id'],
|
|
"message": "License activated successfully"
|
|
}), 201
|
|
|
|
@app.route('/api/v1/license/heartbeat', methods=['POST'])
|
|
@require_auth_token
|
|
def heartbeat():
|
|
"""Record license heartbeat"""
|
|
schema = HeartbeatSchema()
|
|
|
|
try:
|
|
data = schema.load(request.get_json() or {})
|
|
except ValidationError as e:
|
|
return jsonify({"error": "Invalid request", "details": e.messages}), 400
|
|
|
|
license_id = request.token_payload['sub']
|
|
hardware_id = request.token_payload.get('hwid')
|
|
|
|
# Record heartbeat
|
|
license_repo.record_heartbeat(
|
|
license_id=license_id,
|
|
hardware_id=hardware_id,
|
|
ip_address=get_client_ip(),
|
|
user_agent=request.headers.get('User-Agent'),
|
|
session_data=data.get('session_data', {})
|
|
)
|
|
|
|
return jsonify({
|
|
"success": True,
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
@app.route('/api/v1/license/offline-token', methods=['POST'])
|
|
@require_auth_token
|
|
def create_offline_token():
|
|
"""Create offline validation token"""
|
|
schema = OfflineTokenSchema()
|
|
|
|
try:
|
|
data = schema.load(request.get_json() or {})
|
|
except ValidationError as e:
|
|
return jsonify({"error": "Invalid request", "details": e.messages}), 400
|
|
|
|
license_id = request.token_payload['sub']
|
|
hardware_id = request.token_payload.get('hwid')
|
|
duration_hours = data['duration_hours']
|
|
|
|
if not hardware_id:
|
|
return jsonify({"error": "Hardware ID required"}), 400
|
|
|
|
# Create offline token
|
|
token = license_repo.create_license_token(
|
|
license_id=license_id,
|
|
hardware_id=hardware_id,
|
|
valid_hours=duration_hours
|
|
)
|
|
|
|
if not token:
|
|
return jsonify({"error": "Failed to create token"}), 500
|
|
|
|
valid_until = datetime.utcnow() + timedelta(hours=duration_hours)
|
|
|
|
return jsonify({
|
|
"token": token,
|
|
"valid_until": valid_until.isoformat(),
|
|
"duration_hours": duration_hours
|
|
})
|
|
|
|
@app.route('/api/v1/license/validate-offline', methods=['POST'])
|
|
def validate_offline_token():
|
|
"""Validate offline token"""
|
|
data = request.get_json()
|
|
|
|
if not data or 'token' not in data:
|
|
return jsonify({"error": "Missing token"}), 400
|
|
|
|
# Validate token
|
|
result = license_repo.validate_token(data['token'])
|
|
|
|
if not result:
|
|
return jsonify({
|
|
"valid": False,
|
|
"error": "Invalid or expired token"
|
|
}), 401
|
|
|
|
return jsonify({
|
|
"valid": True,
|
|
"license_id": result['license_id'],
|
|
"hardware_id": result['hardware_id'],
|
|
"expires_at": result['valid_until'].isoformat()
|
|
})
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(error):
|
|
return jsonify({"error": "Not found"}), 404
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(error):
|
|
logger.error(f"Internal error: {error}")
|
|
return jsonify({"error": "Internal server error"}), 500
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host='0.0.0.0', port=5002, debug=True) |