Changes: - If container exists but is not running: restart it - If container_id exists but container was deleted: spawn new container - If no container_id at all: spawn new container - Adds detailed logging for container lifecycle This ensures users always have a working container after login, even if the old one was deleted or didn't exist yet.
445 lines
14 KiB
Python
445 lines
14 KiB
Python
from flask import Blueprint, jsonify, request, current_app, redirect
|
|
from flask_jwt_extended import (
|
|
create_access_token,
|
|
jwt_required,
|
|
get_jwt_identity,
|
|
get_jwt
|
|
)
|
|
from datetime import timedelta, datetime
|
|
from models import db, User, UserState, MagicLinkToken
|
|
from container_manager import ContainerManager
|
|
from email_service import (
|
|
generate_slug_from_email,
|
|
generate_magic_link_token,
|
|
send_magic_link_email,
|
|
check_rate_limit
|
|
)
|
|
from config import Config
|
|
import re
|
|
|
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
|
|
|
# Token-Blacklist für Logout
|
|
token_blacklist = set()
|
|
|
|
|
|
@api_bp.route('/auth/login', methods=['POST'])
|
|
def api_login():
|
|
"""API-Login mit Magic Link (Passwordless)"""
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
|
|
|
|
email = data.get('email', '').strip().lower()
|
|
|
|
if not email:
|
|
return jsonify({'error': 'Email ist erforderlich'}), 400
|
|
|
|
# Prüfe ob User existiert
|
|
user = User.query.filter_by(email=email).first()
|
|
if not user:
|
|
# Security: Gleiche Nachricht wie bei Erfolg (verhindert User-Enumeration)
|
|
return jsonify({
|
|
'message': 'Falls diese Email registriert ist, wurde ein Login-Link gesendet.'
|
|
}), 200
|
|
|
|
# Prüfe ob User blockiert
|
|
if user.is_blocked:
|
|
return jsonify({'error': 'Dein Account wurde gesperrt'}), 403
|
|
|
|
# Rate-Limiting
|
|
if not check_rate_limit(email):
|
|
return jsonify({'error': 'Zu viele Anfragen. Bitte versuche es später erneut.'}), 429
|
|
|
|
# Generiere Magic Link Token
|
|
token = generate_magic_link_token()
|
|
expires_at = datetime.utcnow() + timedelta(seconds=Config.MAGIC_LINK_TOKEN_EXPIRY)
|
|
|
|
magic_token = MagicLinkToken(
|
|
user_id=user.id,
|
|
token=token,
|
|
token_type='login',
|
|
expires_at=expires_at,
|
|
ip_address=request.remote_addr
|
|
)
|
|
db.session.add(magic_token)
|
|
db.session.commit()
|
|
|
|
# Sende Email
|
|
try:
|
|
send_magic_link_email(email, token, 'login')
|
|
except Exception as e:
|
|
current_app.logger.error(f"Email-Versand fehlgeschlagen: {str(e)}")
|
|
return jsonify({'error': 'Email konnte nicht gesendet werden'}), 500
|
|
|
|
current_app.logger.info(f"[LOGIN] Magic Link gesendet an {email}")
|
|
|
|
return jsonify({
|
|
'message': 'Login-Link wurde an deine Email gesendet. Bitte ueberprueafe dein Postfach.'
|
|
}), 200
|
|
|
|
|
|
@api_bp.route('/auth/signup', methods=['POST'])
|
|
def api_signup():
|
|
"""API-Registrierung mit Magic Link (Passwordless)"""
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
|
|
|
|
email = data.get('email', '').strip().lower()
|
|
|
|
# Validierung
|
|
if not email:
|
|
return jsonify({'error': 'Email ist erforderlich'}), 400
|
|
|
|
# Email-Format prüfen (einfache Regex)
|
|
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
|
|
return jsonify({'error': 'Ungueltige Email-Adresse'}), 400
|
|
|
|
# Prüfe ob Email bereits registriert
|
|
existing_user = User.query.filter_by(email=email).first()
|
|
if existing_user:
|
|
return jsonify({'error': 'Diese Email-Adresse ist bereits registriert'}), 409
|
|
|
|
# Rate-Limiting
|
|
if not check_rate_limit(email):
|
|
return jsonify({'error': 'Zu viele Anfragen. Bitte versuche es später erneut.'}), 429
|
|
|
|
# Erstelle User (initial mit status=REGISTERED)
|
|
slug = generate_slug_from_email(email)
|
|
|
|
# Prüfe ob Slug bereits existiert (unwahrscheinlich, aber möglich)
|
|
slug_exists = User.query.filter_by(slug=slug).first()
|
|
if slug_exists:
|
|
# Füge Random-Suffix hinzu
|
|
slug = slug + generate_magic_link_token()[:4]
|
|
|
|
# Prüfe ob dies der erste User ist -> wird Admin
|
|
is_first_user = User.query.count() == 0
|
|
|
|
user = User(email=email, slug=slug)
|
|
user.state = UserState.REGISTERED.value
|
|
user.is_admin = is_first_user
|
|
db.session.add(user)
|
|
db.session.flush() # Damit user.id verfügbar ist
|
|
|
|
# Generiere Magic Link Token
|
|
token = generate_magic_link_token()
|
|
expires_at = datetime.utcnow() + timedelta(seconds=Config.MAGIC_LINK_TOKEN_EXPIRY)
|
|
|
|
magic_token = MagicLinkToken(
|
|
user_id=user.id,
|
|
token=token,
|
|
token_type='signup',
|
|
expires_at=expires_at,
|
|
ip_address=request.remote_addr
|
|
)
|
|
db.session.add(magic_token)
|
|
db.session.commit()
|
|
|
|
# Sende Email
|
|
try:
|
|
send_magic_link_email(email, token, 'signup')
|
|
except Exception as e:
|
|
current_app.logger.error(f"Email-Versand fehlgeschlagen: {str(e)}")
|
|
# Cleanup: Lösche User und Token
|
|
db.session.delete(magic_token)
|
|
db.session.delete(user)
|
|
db.session.commit()
|
|
return jsonify({'error': 'Email konnte nicht gesendet werden'}), 500
|
|
|
|
current_app.logger.info(f"[SIGNUP] Magic Link gesendet an {email}")
|
|
|
|
return jsonify({
|
|
'message': 'Registrierungs-Link wurde an deine Email gesendet. Bitte überprüfe dein Postfach.'
|
|
}), 200
|
|
|
|
|
|
@api_bp.route('/auth/verify-signup', methods=['GET'])
|
|
def api_verify_signup():
|
|
"""Verifiziert Signup Magic Link und erstellt JWT"""
|
|
token = request.args.get('token')
|
|
|
|
if not token:
|
|
return jsonify({'error': 'Token fehlt'}), 400
|
|
|
|
# Suche Token in Datenbank
|
|
magic_token = MagicLinkToken.query.filter_by(
|
|
token=token,
|
|
token_type='signup'
|
|
).first()
|
|
|
|
if not magic_token:
|
|
return jsonify({'error': 'Ungültiger oder abgelaufener Link'}), 400
|
|
|
|
# Prüfe Gültigkeit
|
|
if not magic_token.is_valid():
|
|
return jsonify({'error': 'Dieser Link ist abgelaufen oder wurde bereits verwendet'}), 400
|
|
|
|
# Hole User
|
|
user = magic_token.user
|
|
|
|
# Setze User-Status auf VERIFIED
|
|
user.state = UserState.VERIFIED.value
|
|
magic_token.mark_as_used()
|
|
db.session.commit()
|
|
|
|
# Container spawnen (nur beim ersten Signup)
|
|
if not user.container_id:
|
|
try:
|
|
container_mgr = ContainerManager()
|
|
container_id, port = container_mgr.spawn_container(user.id, user.slug)
|
|
user.container_id = container_id
|
|
user.container_port = port
|
|
db.session.commit()
|
|
current_app.logger.info(f"[SPAWNER] Container erstellt für User {user.id} (slug: {user.slug})")
|
|
except Exception as e:
|
|
current_app.logger.error(f"Container-Spawn fehlgeschlagen: {str(e)}")
|
|
# User ist trotzdem erstellt, Container kann später manuell erstellt werden
|
|
|
|
# JWT erstellen
|
|
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
|
access_token = create_access_token(
|
|
identity=str(user.id),
|
|
expires_delta=expires,
|
|
additional_claims={'is_admin': user.is_admin}
|
|
)
|
|
|
|
current_app.logger.info(f"[SIGNUP] User {user.email} erfolgreich registriert")
|
|
|
|
return jsonify({
|
|
'access_token': access_token,
|
|
'token_type': 'Bearer',
|
|
'expires_in': int(expires.total_seconds()),
|
|
'user': {
|
|
'id': user.id,
|
|
'email': user.email,
|
|
'slug': user.slug,
|
|
'is_admin': user.is_admin,
|
|
'state': user.state,
|
|
'container_id': user.container_id
|
|
}
|
|
}), 200
|
|
|
|
|
|
@api_bp.route('/auth/verify-login', methods=['GET'])
|
|
def api_verify_login():
|
|
"""Verifiziert Login Magic Link und erstellt JWT"""
|
|
token = request.args.get('token')
|
|
|
|
if not token:
|
|
return jsonify({'error': 'Token fehlt'}), 400
|
|
|
|
# Suche Token
|
|
magic_token = MagicLinkToken.query.filter_by(
|
|
token=token,
|
|
token_type='login'
|
|
).first()
|
|
|
|
if not magic_token:
|
|
return jsonify({'error': 'Ungültiger oder abgelaufener Link'}), 400
|
|
|
|
# Prüfe Gültigkeit
|
|
if not magic_token.is_valid():
|
|
return jsonify({'error': 'Dieser Link ist abgelaufen oder wurde bereits verwendet'}), 400
|
|
|
|
# Hole User
|
|
user = magic_token.user
|
|
|
|
# Prüfe ob User blockiert
|
|
if user.is_blocked:
|
|
return jsonify({'error': 'Dein Account wurde gesperrt'}), 403
|
|
|
|
# Prüfe ob Email verifiziert
|
|
if user.state == UserState.REGISTERED.value:
|
|
return jsonify({'error': 'Bitte verifiziere zuerst deine Email-Adresse'}), 403
|
|
|
|
# Markiere Token als verwendet
|
|
magic_token.mark_as_used()
|
|
|
|
# Container Management - starten oder neu erstellen
|
|
container_mgr = ContainerManager()
|
|
|
|
if user.container_id:
|
|
try:
|
|
status = container_mgr.get_container_status(user.container_id)
|
|
if status != 'running':
|
|
# Container neu starten
|
|
container_mgr.start_container(user.container_id)
|
|
current_app.logger.info(f"[LOGIN] Container {user.container_id[:12]} neu gestartet für User {user.email}")
|
|
except Exception as e:
|
|
# Container existiert nicht mehr - neuen erstellen
|
|
current_app.logger.warning(f"Container {user.container_id[:12]} nicht gefunden, erstelle neuen: {str(e)}")
|
|
try:
|
|
container_id, port = container_mgr.spawn_container(user.id, user.slug)
|
|
user.container_id = container_id
|
|
user.container_port = port
|
|
current_app.logger.info(f"[LOGIN] Neuer Container erstellt für User {user.email} (slug: {user.slug})")
|
|
except Exception as spawn_error:
|
|
current_app.logger.error(f"Container-Spawn fehlgeschlagen: {str(spawn_error)}")
|
|
else:
|
|
# Kein Container vorhanden - neu erstellen
|
|
try:
|
|
container_id, port = container_mgr.spawn_container(user.id, user.slug)
|
|
user.container_id = container_id
|
|
user.container_port = port
|
|
current_app.logger.info(f"[LOGIN] Container erstellt für User {user.email} (slug: {user.slug})")
|
|
except Exception as e:
|
|
current_app.logger.error(f"Container-Spawn fehlgeschlagen: {str(e)}")
|
|
|
|
user.last_used = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
# JWT erstellen
|
|
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
|
access_token = create_access_token(
|
|
identity=str(user.id),
|
|
expires_delta=expires,
|
|
additional_claims={'is_admin': user.is_admin}
|
|
)
|
|
|
|
current_app.logger.info(f"[LOGIN] User {user.email} erfolgreich eingeloggt")
|
|
|
|
return jsonify({
|
|
'access_token': access_token,
|
|
'token_type': 'Bearer',
|
|
'expires_in': int(expires.total_seconds()),
|
|
'user': {
|
|
'id': user.id,
|
|
'email': user.email,
|
|
'slug': user.slug,
|
|
'is_admin': user.is_admin,
|
|
'state': user.state,
|
|
'container_id': user.container_id
|
|
}
|
|
}), 200
|
|
|
|
|
|
@api_bp.route('/auth/logout', methods=['POST'])
|
|
@jwt_required()
|
|
def api_logout():
|
|
"""API-Logout - invalidiert Token"""
|
|
jti = get_jwt()['jti']
|
|
token_blacklist.add(jti)
|
|
return jsonify({'message': 'Erfolgreich abgemeldet'}), 200
|
|
|
|
|
|
@api_bp.route('/user/me', methods=['GET'])
|
|
@jwt_required()
|
|
def api_user_me():
|
|
"""Gibt aktuellen User und Container-Info zurueck"""
|
|
user_id = get_jwt_identity()
|
|
user = User.query.get(int(user_id))
|
|
|
|
if not user:
|
|
return jsonify({'error': 'User nicht gefunden'}), 404
|
|
|
|
# Service-URL berechnen
|
|
scheme = current_app.config['PREFERRED_URL_SCHEME']
|
|
spawner_domain = f"{current_app.config['SPAWNER_SUBDOMAIN']}.{current_app.config['BASE_DOMAIN']}"
|
|
service_url = f"{scheme}://{spawner_domain}/{user.slug}"
|
|
|
|
# Container-Status abrufen
|
|
container_status = 'unknown'
|
|
if user.container_id:
|
|
try:
|
|
container_mgr = ContainerManager()
|
|
container_status = container_mgr.get_container_status(user.container_id)
|
|
except Exception:
|
|
container_status = 'error'
|
|
|
|
return jsonify({
|
|
'user': {
|
|
'id': user.id,
|
|
'email': user.email,
|
|
'slug': user.slug,
|
|
'is_admin': user.is_admin,
|
|
'state': user.state,
|
|
'last_used': user.last_used.isoformat() if user.last_used else None,
|
|
'created_at': user.created_at.isoformat() if user.created_at else None
|
|
},
|
|
'container': {
|
|
'id': user.container_id,
|
|
'port': user.container_port,
|
|
'status': container_status,
|
|
'service_url': service_url
|
|
}
|
|
}), 200
|
|
|
|
|
|
@api_bp.route('/container/status', methods=['GET'])
|
|
@jwt_required()
|
|
def api_container_status():
|
|
"""Gibt Container-Status zurück"""
|
|
user_id = get_jwt_identity()
|
|
user = User.query.get(int(user_id))
|
|
|
|
if not user:
|
|
return jsonify({'error': 'User nicht gefunden'}), 404
|
|
|
|
container_status = 'no_container'
|
|
if user.container_id:
|
|
try:
|
|
container_mgr = ContainerManager()
|
|
container_status = container_mgr.get_container_status(user.container_id)
|
|
except Exception as e:
|
|
container_status = f'error: {str(e)}'
|
|
|
|
return jsonify({
|
|
'container_id': user.container_id,
|
|
'status': container_status
|
|
}), 200
|
|
|
|
|
|
@api_bp.route('/container/restart', methods=['POST'])
|
|
@jwt_required()
|
|
def api_container_restart():
|
|
"""Startet Container neu"""
|
|
user_id = get_jwt_identity()
|
|
user = User.query.get(int(user_id))
|
|
|
|
if not user:
|
|
return jsonify({'error': 'User nicht gefunden'}), 404
|
|
|
|
container_mgr = ContainerManager()
|
|
|
|
# Alten Container stoppen falls vorhanden
|
|
if user.container_id:
|
|
try:
|
|
container_mgr.stop_container(user.container_id)
|
|
container_mgr.remove_container(user.container_id)
|
|
except Exception as e:
|
|
current_app.logger.warning(f"Alter Container konnte nicht gestoppt werden: {str(e)}")
|
|
|
|
# Neuen Container starten
|
|
try:
|
|
container_id, port = container_mgr.spawn_container(user.id, user.slug)
|
|
user.container_id = container_id
|
|
user.container_port = port
|
|
|
|
# State auf ACTIVE setzen bei Container-Start (falls noch VERIFIED)
|
|
if user.state == UserState.VERIFIED.value:
|
|
user.state = UserState.ACTIVE.value
|
|
|
|
# last_used aktualisieren
|
|
user.last_used = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Container erfolgreich neugestartet',
|
|
'container_id': container_id,
|
|
'status': 'running'
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Container-Restart fehlgeschlagen: {str(e)}")
|
|
return jsonify({'error': f'Container-Restart fehlgeschlagen: {str(e)}'}), 500
|
|
|
|
|
|
def check_if_token_revoked(jwt_header, jwt_payload):
|
|
"""Callback für flask-jwt-extended um revoked Tokens zu prüfen"""
|
|
jti = jwt_payload['jti']
|
|
return jti in token_blacklist
|