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>
426 lines
13 KiB
Python
426 lines
13 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 starten falls gestoppt
|
|
if user.container_id:
|
|
try:
|
|
container_mgr = ContainerManager()
|
|
status = container_mgr.get_container_status(user.container_id)
|
|
if status != 'running':
|
|
# Container neu starten
|
|
container_mgr.start_container(user.container_id)
|
|
except Exception as e:
|
|
current_app.logger.warning(f"Container-Start 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
|