Backend: - First registered user becomes admin automatically - Email verification required before login - Admin API with user management endpoints: - Block/unblock users - Reset passwords (sends email) - Delete user containers - Delete users - Resend verification emails - Takeover sessions (Phase 2 dummy) - New decorators: @admin_required, @verified_required - SMTP configuration for email sending - UserState enum (registered/verified/active) - Activity tracking (last_used field) Frontend: - Admin dashboard with color-coded user list - Green: active, recently used - Yellow: warning (unverified/inactive) - Red: critical (long unverified/very long inactive) - Email verification flow (verify-success/verify-error pages) - Signup shows verification instructions - Login handles unverified accounts with resend option - Admin link in dashboard header for admins Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
354 lines
11 KiB
Python
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")
|
|
|
|
|
|
@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
|