spawner/api.py
XPS\Micro fab346801d feat: auto-recreate user container on login if missing
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.
2026-01-31 18:16:47 +01:00

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