Initial commit
Dieser Commit ist enthalten in:
409
lizenzserver/services/license_api/app.py
Normale Datei
409
lizenzserver/services/license_api/app.py
Normale Datei
@ -0,0 +1,409 @@
|
||||
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)
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren