spawner/api.py
XPS\Micro 4b8cd3eb4a fix: email verification improvements
- FRONTEND_URL now generates correct URL from BASE_DOMAIN and SPAWNER_SUBDOMAIN
- Fixed German umlaut in email button: 'bestaetigen' → 'bestätigen'
- Added 'verified=true' parameter to backend redirect for hybrid approach
- Frontend now checks 'verified' parameter and shows error if not set
- Removed unused token logic from verify-success page (backend handles verification)
- Added warning UI for unverified emails with resend link
2026-01-31 11:57:52 +01:00

354 lines
11 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
from container_manager import ContainerManager
from email_service import generate_verification_token, send_verification_email
from config import Config
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 - gibt JWT-Token zurueck"""
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({'error': 'Username und Passwort erforderlich'}), 400
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
return jsonify({'error': 'Ungueltige Anmeldedaten'}), 401
# Blockade-Check
if user.is_blocked:
return jsonify({'error': 'Konto gesperrt. Kontaktiere einen Administrator.'}), 403
# Verifizierungs-Check
if user.state == UserState.REGISTERED.value:
return jsonify({
'error': 'Email nicht verifiziert. Bitte pruefe dein Postfach.',
'needs_verification': True
}), 403
# Container spawnen wenn noch nicht vorhanden
if not user.container_id:
try:
container_mgr = ContainerManager()
container_id, port = container_mgr.spawn_container(user.id, user.username)
user.container_id = container_id
user.container_port = port
# State auf ACTIVE setzen bei erstem Container-Start
if user.state == UserState.VERIFIED.value:
user.state = UserState.ACTIVE.value
user.last_used = datetime.utcnow()
db.session.commit()
except Exception as e:
current_app.logger.error(f"Container-Start fehlgeschlagen: {str(e)}")
return jsonify({'error': f'Container-Start fehlgeschlagen: {str(e)}'}), 500
else:
# last_used aktualisieren
user.last_used = datetime.utcnow()
db.session.commit()
# JWT-Token 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={'username': user.username, 'is_admin': user.is_admin}
)
return jsonify({
'access_token': access_token,
'token_type': 'Bearer',
'expires_in': int(expires.total_seconds()),
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'is_admin': user.is_admin,
'state': user.state
}
}), 200
@api_bp.route('/auth/signup', methods=['POST'])
def api_signup():
"""API-Registrierung - erstellt User und sendet Verifizierungs-Email"""
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
username = data.get('username')
email = data.get('email')
password = data.get('password')
if not username or not email or not password:
return jsonify({'error': 'Username, Email und Passwort erforderlich'}), 400
# Validierung
if len(username) < 3:
return jsonify({'error': 'Username muss mindestens 3 Zeichen lang sein'}), 400
if len(password) < 6:
return jsonify({'error': 'Passwort muss mindestens 6 Zeichen lang sein'}), 400
# Username-Validierung (nur alphanumerisch und Bindestrich)
import re
if not re.match(r'^[a-zA-Z0-9-]+$', username):
return jsonify({'error': 'Username darf nur Buchstaben, Zahlen und Bindestriche enthalten'}), 400
# Pruefe ob User existiert
if User.query.filter_by(username=username).first():
return jsonify({'error': 'Username bereits vergeben'}), 409
if User.query.filter_by(email=email).first():
return jsonify({'error': 'Email bereits registriert'}), 409
# Pruefe ob dies der erste User ist -> wird Admin
is_first_user = User.query.count() == 0
# Neuen User anlegen
user = User(username=username, email=email)
user.set_password(password)
user.is_admin = is_first_user
user.state = UserState.REGISTERED.value
user.verification_token = generate_verification_token()
user.verification_sent_at = datetime.utcnow()
db.session.add(user)
db.session.commit()
# Verifizierungs-Email senden
frontend_url = Config.FRONTEND_URL
email_sent = send_verification_email(
user.email,
user.username,
user.verification_token,
frontend_url
)
if not email_sent:
current_app.logger.warning(f"Verifizierungs-Email konnte nicht gesendet werden an {user.email}")
return jsonify({
'message': 'Registrierung erfolgreich. Bitte pruefe dein Postfach und bestatige deine Email-Adresse.',
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'is_admin': user.is_admin
},
'email_sent': email_sent
}), 201
@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('/auth/verify', methods=['GET'])
def api_verify_email():
"""Email-Verifizierung ueber Token-Link"""
token = request.args.get('token')
frontend_url = Config.FRONTEND_URL
if not token:
return redirect(f"{frontend_url}/verify-error?reason=missing_token")
user = User.query.filter_by(verification_token=token).first()
if not user:
return redirect(f"{frontend_url}/verify-error?reason=invalid_token")
# Token invalidieren und Status aktualisieren
user.verification_token = None
user.state = UserState.VERIFIED.value
db.session.commit()
current_app.logger.info(f"User {user.username} hat Email verifiziert")
return redirect(f"{frontend_url}/verify-success?verified=true")
@api_bp.route('/auth/resend-verification', methods=['POST'])
def api_resend_verification():
"""Sendet Verifizierungs-Email erneut"""
data = request.get_json()
if not data:
return jsonify({'error': 'Keine Daten uebermittelt'}), 400
email = data.get('email')
if not email:
return jsonify({'error': 'Email erforderlich'}), 400
user = User.query.filter_by(email=email).first()
if not user:
# Aus Sicherheitsgruenden kein Fehler wenn User nicht existiert
return jsonify({'message': 'Falls die Email registriert ist, wurde eine neue Verifizierungs-Email gesendet.'}), 200
if user.state != UserState.REGISTERED.value:
return jsonify({'error': 'Email bereits verifiziert'}), 400
# Neuen Token generieren
user.verification_token = generate_verification_token()
user.verification_sent_at = datetime.utcnow()
db.session.commit()
# Email senden
frontend_url = Config.FRONTEND_URL
email_sent = send_verification_email(
user.email,
user.username,
user.verification_token,
frontend_url
)
return jsonify({
'message': 'Falls die Email registriert ist, wurde eine neue Verifizierungs-Email gesendet.',
'email_sent': email_sent
}), 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.username}"
# 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,
'username': user.username,
'email': user.email,
'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.username)
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