spawner/container_manager.py
XPS\Micro 20a0f3d6af feat: Implement passwordless authentication with Magic Links
Major changes:
- Remove username and password_hash from User model
- Add MagicLinkToken table for one-time-use email authentication
- Implement Magic Link email sending with 15-minute expiration
- Update all auth endpoints (/login, /signup) to use email only
- Create verify-signup and verify-login pages for token verification
- Container URLs now use slug instead of username (e.g., /u-a3f9c2d1)
- Add rate limiting: max 3 Magic Links per email per hour
- Remove password reset functionality (no passwords to reset)

Backend changes:
- api.py: Complete rewrite of auth routes (magic link based)
- models.py: Remove username/password, add slug and MagicLinkToken
- email_service.py: Add Magic Link generation and email sending
- admin_api.py: Remove password reset, update to use email identifiers
- container_manager.py: Use slug instead of username for routing
- config.py: Add MAGIC_LINK_TOKEN_EXPIRY and MAGIC_LINK_RATE_LIMIT

Frontend changes:
- src/lib/api.ts: Update auth functions and User interface
- src/hooks/use-auth.tsx: Implement verifySignup/verifyLogin
- src/app/login/page.tsx: Email-only login form
- src/app/signup/page.tsx: Email-only signup form
- src/app/verify-signup/page.tsx: NEW - Signup token verification
- src/app/verify-login/page.tsx: NEW - Login token verification
- src/app/dashboard/page.tsx: Display slug instead of username

Infrastructure:
- install.sh: Simplified, no migration needed (db.create_all handles it)
- .env.example: Add MAGIC_LINK_TOKEN_EXPIRY and MAGIC_LINK_RATE_LIMIT
- Add IMPLEMENTATION-GUIDE.md with detailed setup instructions

Security improvements:
- No password storage = no password breaches
- One-time-use tokens prevent replay attacks
- 15-minute token expiration limits attack window
- Rate limiting prevents email flooding

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-31 16:19:22 +01:00

140 lines
5.5 KiB
Python

import requests_unixsocket
import docker
from config import Config
class ContainerManager:
def __init__(self):
self.client = None
def _get_client(self):
"""Lazy initialization of Docker client"""
if self.client is None:
try:
# Nutze from_env() - DOCKER_HOST aus Umgebungsvariable
self.client = docker.from_env()
except Exception as e:
raise Exception(f"Docker connection failed: {str(e)}")
return self.client
def spawn_container(self, user_id, slug):
"""Spawnt einen neuen Container für den User"""
try:
existing = self._get_user_container(slug)
if existing and existing.status == 'running':
return existing.id, self._get_container_port(existing)
# Pfad-basiertes Routing: User unter coder.domain.org/<slug>
base_host = f"{Config.SPAWNER_SUBDOMAIN}.{Config.BASE_DOMAIN}"
# Labels vorbereiten
labels = {
'traefik.enable': 'true',
'traefik.docker.network': Config.TRAEFIK_NETWORK,
# HTTPS Router mit PathPrefix
f'traefik.http.routers.user{user_id}.rule':
f'Host(`{base_host}`) && PathPrefix(`/{slug}`)',
f'traefik.http.routers.user{user_id}.entrypoints': Config.TRAEFIK_ENTRYPOINT,
f'traefik.http.routers.user{user_id}.priority': '100',
# StripPrefix Middleware - entfernt /{slug} bevor Container Request erhält
f'traefik.http.routers.user{user_id}.middlewares': f'user{user_id}-strip',
f'traefik.http.middlewares.user{user_id}-strip.stripprefix.prefixes': f'/{slug}',
# TLS für HTTPS
f'traefik.http.routers.user{user_id}.tls': 'true',
f'traefik.http.routers.user{user_id}.tls.certresolver': Config.TRAEFIK_CERTRESOLVER,
# Service
f'traefik.http.services.user{user_id}.loadbalancer.server.port': '8080',
# Metadata
'spawner.user_id': str(user_id),
'spawner.slug': slug,
'spawner.managed': 'true'
}
# Logging: Traefik-Labels ausgeben
print(f"[SPAWNER] Creating container user-{slug}-{user_id}")
print(f"[SPAWNER] Traefik Labels:")
for key, value in labels.items():
if 'traefik' in key:
print(f"[SPAWNER] {key}: {value}")
container = self._get_client().containers.run(
Config.USER_TEMPLATE_IMAGE,
name=f"user-{slug}-{user_id}",
detach=True,
network=Config.TRAEFIK_NETWORK,
labels=labels,
environment={
'USER_ID': str(user_id),
'USER_SLUG': slug
},
restart_policy={'Name': 'unless-stopped'},
mem_limit=Config.DEFAULT_MEMORY_LIMIT,
cpu_quota=Config.DEFAULT_CPU_QUOTA
)
print(f"[SPAWNER] Container created: {container.id[:12]}")
print(f"[SPAWNER] URL: https://{base_host}/{slug}")
return container.id, 8080
except docker.errors.ImageNotFound as e:
error_msg = f"Template-Image '{Config.USER_TEMPLATE_IMAGE}' nicht gefunden"
print(f"[SPAWNER] ERROR: {error_msg}")
raise Exception(error_msg)
except docker.errors.APIError as e:
error_msg = f"Docker API Fehler: {str(e)}"
print(f"[SPAWNER] ERROR: {error_msg}")
raise Exception(error_msg)
except Exception as e:
print(f"[SPAWNER] ERROR: {str(e)}")
raise
def start_container(self, container_id):
"""Startet einen gestoppten User-Container"""
try:
container = self._get_client().containers.get(container_id)
if container.status != 'running':
container.start()
print(f"[SPAWNER] Container {container_id[:12]} gestartet")
return True
except docker.errors.NotFound:
return False
def stop_container(self, container_id):
"""Stoppt einen User-Container"""
try:
container = self._get_client().containers.get(container_id)
container.stop(timeout=10)
return True
except docker.errors.NotFound:
return False
def remove_container(self, container_id):
"""Entfernt einen User-Container komplett"""
try:
container = self._get_client().containers.get(container_id)
container.remove(force=True)
return True
except docker.errors.NotFound:
return False
def get_container_status(self, container_id):
"""Gibt Status eines Containers zurück"""
try:
container = self._get_client().containers.get(container_id)
return container.status
except docker.errors.NotFound:
return 'not_found'
def _get_user_container(self, slug):
"""Findet existierenden Container für User"""
filters = {'label': f'spawner.slug={slug}'}
containers = self._get_client().containers.list(all=True, filters=filters)
return containers[0] if containers else None
def _get_container_port(self, container):
"""Extrahiert Port aus Container-Config"""
return 8080