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>
165 lines
5.1 KiB
Python
165 lines
5.1 KiB
Python
"""
|
|
Email-Service fuer Verifizierungs-Emails und Magic Links
|
|
"""
|
|
import smtplib
|
|
import secrets
|
|
import hashlib
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from config import Config
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
def generate_verification_token():
|
|
"""Generiert einen sicheren Verifizierungs-Token"""
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
def generate_slug_from_email(email: str) -> str:
|
|
"""
|
|
Generiert eindeutigen Slug aus Email
|
|
Format: Erste 12 Zeichen von SHA256(email)
|
|
"""
|
|
email_lower = email.lower().strip()
|
|
hash_obj = hashlib.sha256(email_lower.encode())
|
|
slug = hash_obj.hexdigest()[:12]
|
|
return slug
|
|
|
|
|
|
def generate_magic_link_token() -> str:
|
|
"""
|
|
Generiert sicheren Token für Magic Links
|
|
32 Byte = ~43 Zeichen URL-safe Base64
|
|
"""
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
def check_rate_limit(email: str) -> bool:
|
|
"""
|
|
Prüft ob User zu viele Magic Links angefordert hat
|
|
Max. 3 Tokens pro Email in den letzten 60 Minuten
|
|
|
|
Returns:
|
|
True wenn OK, False wenn Rate Limit erreicht
|
|
"""
|
|
from models import User, MagicLinkToken
|
|
|
|
user = User.query.filter_by(email=email).first()
|
|
if not user:
|
|
return True # Neue Email, kein Limit
|
|
|
|
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
|
recent_tokens = MagicLinkToken.query.filter(
|
|
MagicLinkToken.user_id == user.id,
|
|
MagicLinkToken.created_at >= one_hour_ago
|
|
).count()
|
|
|
|
return recent_tokens < 3
|
|
|
|
|
|
def send_magic_link_email(email: str, token: str, token_type: str) -> bool:
|
|
"""
|
|
Sendet Magic Link Email
|
|
|
|
Args:
|
|
email: Empfänger-Email
|
|
token: Magic Link Token
|
|
token_type: 'signup' oder 'login'
|
|
|
|
Returns:
|
|
True bei Erfolg, False bei Fehler
|
|
"""
|
|
# URL basierend auf Type
|
|
if token_type == 'signup':
|
|
verify_url = f"{Config.FRONTEND_URL}/verify-signup?token={token}"
|
|
subject = "Registrierung abschließen - Container Spawner"
|
|
action_text = "Registrierung abschließen"
|
|
greeting = "Vielen Dank für deine Registrierung!"
|
|
else: # login
|
|
verify_url = f"{Config.FRONTEND_URL}/verify-login?token={token}"
|
|
subject = "Login-Link - Container Spawner"
|
|
action_text = "Jetzt einloggen"
|
|
greeting = "Hier ist dein Login-Link:"
|
|
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: #1a1a2e; color: white; padding: 20px; text-align: center; }}
|
|
.content {{ padding: 30px; background: #f9f9f9; }}
|
|
.button {{ display: inline-block; padding: 12px 30px; background: #4f46e5; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }}
|
|
.footer {{ padding: 20px; text-align: center; color: #666; font-size: 12px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Container Spawner</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>{greeting}</p>
|
|
<p>Klicke auf den Button, um fortzufahren:</p>
|
|
<p style="text-align: center;">
|
|
<a href="{verify_url}" class="button">{action_text}</a>
|
|
</p>
|
|
<p>Oder kopiere diesen Link in deinen Browser:</p>
|
|
<p style="word-break: break-all; background: #eee; padding: 10px; border-radius: 3px;">
|
|
{verify_url}
|
|
</p>
|
|
<p><small>Dieser Link ist 15 Minuten gültig und kann nur einmal verwendet werden.</small></p>
|
|
</div>
|
|
<div class="footer">
|
|
<p>Diese Email wurde automatisch generiert. Bitte antworte nicht darauf.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
text_content = f"""
|
|
{greeting}
|
|
|
|
Bitte öffne folgenden Link oder kopiere ihn in deinen Browser:
|
|
|
|
{verify_url}
|
|
|
|
Hinweis: Dieser Link ist 15 Minuten gültig und kann nur einmal verwendet werden.
|
|
|
|
---
|
|
Diese Email wurde automatisch generiert.
|
|
"""
|
|
|
|
msg = MIMEMultipart('alternative')
|
|
msg['Subject'] = subject
|
|
msg['From'] = Config.SMTP_FROM
|
|
msg['To'] = email
|
|
|
|
part1 = MIMEText(text_content, 'plain', 'utf-8')
|
|
part2 = MIMEText(html_content, 'html', 'utf-8')
|
|
msg.attach(part1)
|
|
msg.attach(part2)
|
|
|
|
try:
|
|
if Config.SMTP_USE_TLS:
|
|
server = smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT)
|
|
server.starttls()
|
|
else:
|
|
server = smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT)
|
|
|
|
if Config.SMTP_USER and Config.SMTP_PASSWORD:
|
|
server.login(Config.SMTP_USER, Config.SMTP_PASSWORD)
|
|
|
|
server.sendmail(Config.SMTP_FROM, email, msg.as_string())
|
|
server.quit()
|
|
|
|
print(f"[EMAIL] Magic Link ({token_type}) gesendet an {email}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"[EMAIL] Fehler beim Senden der Email an {email}: {str(e)}")
|
|
return False
|