spawner/api.py
XPS\Micro 5115e37b47 feat: improve signup UX - send login link if email already registered
When user tries to signup with an already registered email, the system
now sends a login link instead of showing an error. This improves UX and
prevents user enumeration attacks. The response message is identical for
both new registrations and existing emails.
2026-02-01 17:04:07 +01:00

616 lines
22 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, UserContainer
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:
# Statt Fehler: Sende Login-Link (bessere UX, verhindert User-Enumeration)
if existing_user.is_blocked:
return jsonify({'error': 'Dein Account wurde gesperrt'}), 403
# Rate-Limiting prüfen
if not check_rate_limit(email):
return jsonify({'error': 'Zu viele Anfragen. Bitte versuche es später erneut.'}), 429
# Generiere Magic Link Token für Login
token = generate_magic_link_token()
expires_at = datetime.utcnow() + timedelta(seconds=Config.MAGIC_LINK_TOKEN_EXPIRY)
magic_token = MagicLinkToken(
user_id=existing_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"[SIGNUP] Email {email} existiert bereits - Login-Link gesendet")
# Gleiche Nachricht wie bei Neuregistrierung (verhindert User-Enumeration)
return jsonify({
'message': 'Registrierungs-Link wurde an deine Email gesendet. Bitte überprüfe dein Postfach.'
}), 200
# 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
# ============================================================
# Multi-Container Support Endpoints
# ============================================================
@api_bp.route('/user/containers', methods=['GET'])
@jwt_required()
def api_user_containers():
"""Gibt alle Container des Users 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-Liste erstellen
containers = []
for container_type, template in current_app.config['CONTAINER_TEMPLATES'].items():
# Suche existierenden Container
user_container = UserContainer.query.filter_by(
user_id=user.id,
container_type=container_type
).first()
# Service-URL
scheme = current_app.config['PREFERRED_URL_SCHEME']
spawner_domain = f"{current_app.config['SPAWNER_SUBDOMAIN']}.{current_app.config['BASE_DOMAIN']}"
slug_with_suffix = f"{user.slug}-{container_type}"
service_url = f"{scheme}://{spawner_domain}/{slug_with_suffix}"
# Status ermitteln
status = 'not_created'
if user_container and user_container.container_id:
try:
container_mgr = ContainerManager()
status = container_mgr.get_container_status(user_container.container_id)
except Exception:
status = 'error'
containers.append({
'type': container_type,
'display_name': template['display_name'],
'description': template['description'],
'status': status,
'service_url': service_url,
'container_id': user_container.container_id if user_container else None,
'created_at': user_container.created_at.isoformat() if user_container and user_container.created_at else None,
'last_used': user_container.last_used.isoformat() if user_container and user_container.last_used else None
})
return jsonify({'containers': containers}), 200
@api_bp.route('/container/launch/<container_type>', methods=['POST'])
@jwt_required()
def api_container_launch(container_type):
"""Erstellt Container on-demand und gibt Service-URL zurück"""
user_id = get_jwt_identity()
user = User.query.get(int(user_id))
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
# Prüfe ob Typ valide
if container_type not in current_app.config['CONTAINER_TEMPLATES']:
return jsonify({'error': f'Ungültiger Container-Typ: {container_type}'}), 400
# Prüfe ob Container bereits existiert
user_container = UserContainer.query.filter_by(
user_id=user.id,
container_type=container_type
).first()
container_mgr = ContainerManager()
if user_container and user_container.container_id:
# Container existiert - Status prüfen
try:
status = container_mgr.get_container_status(user_container.container_id)
if status != 'running':
# Container neu starten
container_mgr.start_container(user_container.container_id)
current_app.logger.info(f"[MULTI-CONTAINER] Container {user_container.container_id[:12]} neu gestartet")
# last_used aktualisieren
user_container.last_used = datetime.utcnow()
db.session.commit()
except Exception as e:
# Container existiert nicht mehr - neu erstellen
current_app.logger.warning(f"Container {user_container.container_id[:12]} nicht gefunden, erstelle neuen: {str(e)}")
try:
template = current_app.config['CONTAINER_TEMPLATES'][container_type]
container_id, port = container_mgr.spawn_multi_container(user.id, user.slug, container_type)
user_container.container_id = container_id
user_container.container_port = port
user_container.last_used = datetime.utcnow()
db.session.commit()
current_app.logger.info(f"[MULTI-CONTAINER] Neuer {container_type} Container erstellt für {user.email}")
except Exception as spawn_error:
current_app.logger.error(f"Container-Spawn fehlgeschlagen: {str(spawn_error)}")
return jsonify({'error': 'Container konnte nicht erstellt werden'}), 500
else:
# Container existiert noch nicht - neu erstellen
try:
template = current_app.config['CONTAINER_TEMPLATES'][container_type]
container_id, port = container_mgr.spawn_multi_container(user.id, user.slug, container_type)
user_container = UserContainer(
user_id=user.id,
container_type=container_type,
container_id=container_id,
container_port=port,
template_image=template['image'],
last_used=datetime.utcnow()
)
db.session.add(user_container)
db.session.commit()
current_app.logger.info(f"[MULTI-CONTAINER] {container_type} Container erstellt für {user.email}")
except Exception as e:
current_app.logger.error(f"Container-Spawn fehlgeschlagen: {str(e)}")
return jsonify({'error': f'Container konnte nicht erstellt werden: {str(e)}'}), 500
# Service-URL generieren
scheme = current_app.config['PREFERRED_URL_SCHEME']
spawner_domain = f"{current_app.config['SPAWNER_SUBDOMAIN']}.{current_app.config['BASE_DOMAIN']}"
slug_with_suffix = f"{user.slug}-{container_type}"
service_url = f"{scheme}://{spawner_domain}/{slug_with_suffix}"
return jsonify({
'message': 'Container bereit',
'service_url': service_url,
'container_id': user_container.container_id,
'status': 'running'
}), 200