spawner/admin_api.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

325 lines
9.5 KiB
Python

"""
Admin-API Blueprint
Alle Endpoints erfordern Admin-Rechte.
"""
from flask import Blueprint, jsonify, request, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, timedelta
from models import db, User, UserState, AdminTakeoverSession
from decorators import admin_required
from container_manager import ContainerManager
from config import Config
admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin')
@admin_bp.route('/users', methods=['GET'])
@jwt_required()
@admin_required()
def get_users():
"""Listet alle Benutzer auf"""
users = User.query.all()
users_list = []
for user in users:
users_list.append(user.to_dict())
return jsonify({
'users': users_list,
'total': len(users_list)
}), 200
@admin_bp.route('/users/<int:user_id>', methods=['GET'])
@jwt_required()
@admin_required()
def get_user(user_id):
"""Gibt Details eines einzelnen Users zurueck"""
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
# Container-Status abrufen
container_status = 'no_container'
if user.container_id:
try:
container_mgr = ContainerManager()
container_status = container_mgr.get_container_status(user.container_id)
except Exception:
container_status = 'error'
user_data = user.to_dict()
user_data['container_status'] = container_status
return jsonify({'user': user_data}), 200
@admin_bp.route('/users/<int:user_id>/block', methods=['POST'])
@jwt_required()
@admin_required()
def block_user(user_id):
"""Sperrt einen Benutzer"""
admin_id = get_jwt_identity()
if int(admin_id) == user_id:
return jsonify({'error': 'Du kannst dich nicht selbst sperren'}), 400
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
if user.is_admin:
return jsonify({'error': 'Admins koennen nicht gesperrt werden'}), 400
if user.is_blocked:
return jsonify({'error': 'User ist bereits gesperrt'}), 400
user.is_blocked = True
user.blocked_at = datetime.utcnow()
user.blocked_by = int(admin_id)
db.session.commit()
current_app.logger.info(f"User {user.email} wurde von Admin {admin_id} gesperrt")
return jsonify({
'message': f'User {user.email} wurde gesperrt',
'user': user.to_dict()
}), 200
@admin_bp.route('/users/<int:user_id>/unblock', methods=['POST'])
@jwt_required()
@admin_required()
def unblock_user(user_id):
"""Entsperrt einen Benutzer"""
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
if not user.is_blocked:
return jsonify({'error': 'User ist nicht gesperrt'}), 400
user.is_blocked = False
user.blocked_at = None
user.blocked_by = None
db.session.commit()
admin_id = get_jwt_identity()
current_app.logger.info(f"User {user.email} wurde von Admin {admin_id} entsperrt")
return jsonify({
'message': f'User {user.email} wurde entsperrt',
'user': user.to_dict()
}), 200
@admin_bp.route('/users/<int:user_id>/resend-verification', methods=['POST'])
@jwt_required()
@admin_required()
def resend_user_verification(user_id):
"""Sendet Magic Link erneut an einen Benutzer (für Admin-Funktion)"""
from email_service import generate_magic_link_token, send_magic_link_email
from models import MagicLinkToken
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
# Generiere neuen 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()
# Email senden
email_sent = send_magic_link_email(user.email, token, 'login')
admin_id = get_jwt_identity()
current_app.logger.info(f"Magic Link für User {user.email} wurde von Admin {admin_id} erneut gesendet")
return jsonify({
'message': f'Login-Link an {user.email} gesendet',
'email_sent': email_sent
}), 200
@admin_bp.route('/users/<int:user_id>/container', methods=['DELETE'])
@jwt_required()
@admin_required()
def delete_user_container(user_id):
"""Loescht den Container eines Benutzers"""
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
if not user.container_id:
return jsonify({'error': 'User hat keinen Container'}), 400
container_mgr = ContainerManager()
try:
container_mgr.stop_container(user.container_id)
container_mgr.remove_container(user.container_id)
except Exception as e:
current_app.logger.warning(f"Fehler beim Loeschen des Containers: {str(e)}")
old_container_id = user.container_id
user.container_id = None
user.container_port = None
db.session.commit()
admin_id = get_jwt_identity()
current_app.logger.info(f"Container {old_container_id[:12]} von User {user.email} wurde von Admin {admin_id} geloescht")
return jsonify({
'message': f'Container von {user.email} wurde geloescht',
'user': user.to_dict()
}), 200
@admin_bp.route('/users/<int:user_id>', methods=['DELETE'])
@jwt_required()
@admin_required()
def delete_user(user_id):
"""Loescht einen Benutzer komplett"""
admin_id = get_jwt_identity()
if int(admin_id) == user_id:
return jsonify({'error': 'Du kannst dich nicht selbst loeschen'}), 400
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
if user.is_admin:
return jsonify({'error': 'Admins koennen nicht geloescht werden'}), 400
# Container loeschen falls vorhanden
if user.container_id:
container_mgr = ContainerManager()
try:
container_mgr.stop_container(user.container_id)
container_mgr.remove_container(user.container_id)
except Exception as e:
current_app.logger.warning(f"Fehler beim Loeschen des Containers: {str(e)}")
email = user.email
db.session.delete(user)
db.session.commit()
current_app.logger.info(f"User {email} wurde von Admin {admin_id} geloescht")
return jsonify({
'message': f'User {email} wurde geloescht'
}), 200
# ============================================================
# Takeover-Endpoints (Phase 2 - Dummy-Implementierung)
# ============================================================
@admin_bp.route('/users/<int:user_id>/takeover', methods=['POST'])
@jwt_required()
@admin_required()
def start_takeover(user_id):
"""
Startet eine Takeover-Session für einen User-Container.
DUMMY-IMPLEMENTIERUNG - wird in Phase 2 vollstaendig implementiert.
"""
admin_id = get_jwt_identity()
data = request.get_json() or {}
reason = data.get('reason', '')
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
if not user.container_id:
return jsonify({'error': 'User hat keinen Container'}), 400
# Takeover-Session erstellen (nur Protokollierung)
session = AdminTakeoverSession(
admin_id=int(admin_id),
target_user_id=user_id,
reason=reason
)
db.session.add(session)
db.session.commit()
current_app.logger.info(f"Admin {admin_id} hat Takeover für User {user.email} gestartet (Session {session.id})")
return jsonify({
'message': 'Takeover-Funktion ist noch nicht vollstaendig implementiert (Phase 2)',
'session_id': session.id,
'status': 'dummy',
'note': 'Diese Funktion wird in einer späteren Version verfügbar sein'
}), 200
@admin_bp.route('/takeover/<int:session_id>/end', methods=['POST'])
@jwt_required()
@admin_required()
def end_takeover(session_id):
"""
Beendet eine Takeover-Session.
DUMMY-IMPLEMENTIERUNG - wird in Phase 2 vollstaendig implementiert.
"""
session = AdminTakeoverSession.query.get(session_id)
if not session:
return jsonify({'error': 'Takeover-Session nicht gefunden'}), 404
if session.ended_at:
return jsonify({'error': 'Takeover-Session ist bereits beendet'}), 400
session.ended_at = datetime.utcnow()
db.session.commit()
admin_id = get_jwt_identity()
current_app.logger.info(f"Admin {admin_id} hat Takeover-Session {session_id} beendet")
return jsonify({
'message': 'Takeover-Session beendet',
'session_id': session_id
}), 200
@admin_bp.route('/takeover/active', methods=['GET'])
@jwt_required()
@admin_required()
def get_active_takeovers():
"""Listet alle aktiven Takeover-Sessions auf"""
sessions = AdminTakeoverSession.query.filter_by(ended_at=None).all()
sessions_list = []
for session in sessions:
sessions_list.append({
'id': session.id,
'admin_id': session.admin_id,
'admin_email': session.admin.email if session.admin else None,
'target_user_id': session.target_user_id,
'target_email': session.target_user.email if session.target_user else None,
'started_at': session.started_at.isoformat() if session.started_at else None,
'reason': session.reason
})
return jsonify({
'sessions': sessions_list,
'total': len(sessions_list)
}), 200