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>
This commit is contained in:
XPS\Micro 2026-01-31 16:19:22 +01:00
parent 67149e1544
commit 20a0f3d6af
16 changed files with 1197 additions and 783 deletions

View File

@ -88,7 +88,17 @@ LOG_LEVEL=INFO
CONTAINER_IDLE_TIMEOUT=3600 CONTAINER_IDLE_TIMEOUT=3600
# ============================================================ # ============================================================
# EMAIL - Verifizierung und Benachrichtigungen # PASSWORDLESS AUTH - Magic Links
# ============================================================
# Gueltigkeitsdauer von Magic Link Tokens in Sekunden (Standard: 15 Minuten)
MAGIC_LINK_TOKEN_EXPIRY=900
# Max. Anzahl Magic Links pro Email-Adresse pro Stunde (Rate Limiting)
MAGIC_LINK_RATE_LIMIT=3
# ============================================================
# EMAIL - Magic Links und Benachrichtigungen
# ============================================================ # ============================================================
# SMTP-Server Konfiguration # SMTP-Server Konfiguration
@ -99,7 +109,7 @@ SMTP_PASSWORD=your-smtp-password
SMTP_FROM=noreply@example.com SMTP_FROM=noreply@example.com
SMTP_USE_TLS=true SMTP_USE_TLS=true
# Frontend-URL fuer Email-Links (Verifizierung etc.) # Frontend-URL fuer Email-Links (Magic Links, etc.)
# WICHTIG: Muss die URL sein, unter der das Frontend erreichbar ist # WICHTIG: Muss die URL sein, unter der das Frontend erreichbar ist
FRONTEND_URL=https://coder.example.com FRONTEND_URL=https://coder.example.com

213
IMPLEMENTATION-GUIDE.md Normal file
View File

@ -0,0 +1,213 @@
# Passwordless Authentication - Implementierungsanleitung
## ✅ Was wurde implementiert
Das Container Spawner System wurde komplett auf **Passwordless Authentication mit Magic Links** umgestellt.
### Backend-Änderungen (Python/Flask)
#### 1. **Datenbank-Schema** (`models.py`)
- ❌ Entfernt: `username` Spalte
- ❌ Entfernt: `password_hash` Spalte
- ✅ Hinzugefügt: `slug` Spalte (unique, 12 Zeichen, basierend auf Email-Hash)
- ✅ Neue Tabelle: `MagicLinkToken` für Magic Link Tokens
- `token` - der Magic Link Token (unique)
- `token_type` - 'signup' oder 'login'
- `expires_at` - Ablaufzeit (15 Minuten)
- `used_at` - Zeitstempel wenn Token verwendet wurde
- `is_valid()` - Methode zur Validierung
- `mark_as_used()` - Methode zum Markieren als verwendet
#### 2. **Email-Service** (`email_service.py`)
- ✅ `generate_slug_from_email(email)` - Generiert eindeutigen Slug aus Email
- ✅ `generate_magic_link_token()` - Generiert sicheren Token
- ✅ `send_magic_link_email(email, token, token_type)` - Sendet Magic Link per Email
- ✅ `check_rate_limit(email)` - Rate-Limiting (max 3 Tokens/Stunde)
#### 3. **API Routes** (`api.py`)
- ✅ `POST /api/auth/login` - Sendet Magic Link statt Passwort zu prüfen
- ✅ `POST /api/auth/signup` - Sendet Magic Link für Registrierung
- ✅ `GET /api/auth/verify-signup` - Verifiziert Signup Token & erstellt JWT
- ✅ `GET /api/auth/verify-login` - Verifiziert Login Token & erstellt JWT
- ❌ Gelöscht: `/api/auth/verify` (alte Email-Verifizierung)
- ❌ Gelöscht: `/api/auth/resend-verification`
- ✅ Angepasst: `/api/user/me` - Nutzt `slug` statt `username`
- ✅ Angepasst: `/api/container/restart` - Nutzt `slug`
#### 4. **Admin API** (`admin_api.py`)
- ❌ Gelöscht: `/api/admin/users/{id}/reset-password`
- ✅ Angepasst: `resend_verification()` - Sendet Magic Link statt Password-Reset
- ✅ Alle `user.username` Referenzen → `user.email`
#### 5. **Container Manager** (`container_manager.py`)
- ✅ `spawn_container(user_id, slug)` - Nutzt `slug` statt `username`
- ✅ Traefik-Labels aktualisiert: `/username``/slug`
- ✅ Environment: `USERNAME``USER_SLUG`
- ✅ `start_container()` - Neue Methode zum Starten gestoppter Container
- ✅ `_get_user_container()` - Nutzt `slug` statt `username`
#### 6. **Konfiguration** (`config.py`)
- ✅ `MAGIC_LINK_TOKEN_EXPIRY = 900` (15 Minuten)
- ✅ `MAGIC_LINK_RATE_LIMIT = 3` (3 Tokens pro Stunde)
### Frontend-Änderungen (TypeScript/React)
#### 1. **API Client** (`src/lib/api.ts`)
- ✅ Neue `User` Interface: `email`, `slug`, `state`, keine `username`
- ✅ `api.auth.login(email)` - nur Email statt username+password
- ✅ `api.auth.signup(email)` - nur Email
- ✅ `api.auth.verifySignup(token)` - Verifiziert Signup Token
- ✅ `api.auth.verifyLogin(token)` - Verifiziert Login Token
- ✅ QueryParams Support in `fetchApi()`
#### 2. **Auth Hook** (`src/hooks/use-auth.tsx`)
- ✅ `login(email)` - Magic Link Request
- ✅ `signup(email)` - Magic Link Request
- ✅ `verifySignup(token)` - Token-Verifizierung
- ✅ `verifyLogin(token)` - Token-Verifizierung
- ✅ State Management: Error Tracking, isAuthenticated
#### 3. **Login Page** (`src/app/login/page.tsx`)
- ✅ Email-Input statt Username+Password
- ✅ "Email gesendet" Nachricht nach Submit
- ✅ Option, neue Email anzufordern
#### 4. **Signup Page** (`src/app/signup/page.tsx`)
- ✅ Email-Input nur (kein Username/Password mehr)
- ✅ "Email gesendet" Nachricht nach Submit
- ✅ Link zu Login
#### 5. **Neue Pages**
- ✅ `src/app/verify-signup/page.tsx` - Signup-Token Verifizierung
- Token aus URL auslesen
- API aufrufen
- JWT speichern
- Zu Dashboard umleiten
- ✅ `src/app/verify-login/page.tsx` - Login-Token Verifizierung
- Token aus URL auslesen
- API aufrufen
- JWT speichern
- Zu Dashboard umleiten
#### 6. **Dashboard** (`src/app/dashboard/page.tsx`)
- ✅ Container Slug anzeigen
- ✅ Email statt Username in Header
- ✅ Service-URL nutzt Slug
## 🚀 Erste Schritte nach Deployment
### 1. SMTP konfigurieren
Stelle sicher, dass deine `.env` folgendes enthält:
```
SMTP_HOST=dein-smtp-server.com
SMTP_PORT=587
SMTP_USER=deine-email@domain.com
SMTP_PASSWORD=dein-app-passwort
SMTP_FROM=noreply@domain.com
FRONTEND_URL=https://coder.deine-domain.com
```
**Datenbank:** Wird automatisch beim Start erstellt (alle Tabellen inkl. MagicLinkToken)
### 2. Magic Link Einstellungen anpassen (optional)
```env
# Token Gültigkeitsdauer in Sekunden (Standard: 900 = 15 Minuten)
MAGIC_LINK_TOKEN_EXPIRY=900
# Rate-Limiting: Max Tokens pro Stunde (Standard: 3)
MAGIC_LINK_RATE_LIMIT=3
```
## 📧 User Journey
### Registrierung
1. User klickt auf "Registrierung"
2. Gibt Email ein
3. Backend sendet Magic Link per Email (Gültig 15 Minuten)
4. User klickt Link → Token wird verifiziert
5. Account wird erstellt & Container spawnt
6. JWT wird gespeichert
7. Auto-Redirect zu Dashboard
### Login
1. User klickt auf "Login"
2. Gibt Email ein
3. Backend sendet Magic Link per Email
4. User klickt Link → Token wird verifiziert
5. JWT wird gespeichert
6. Auto-Redirect zu Dashboard
## 🔗 Container URLs
**Alt (deprecated):**
```
https://coder.domain.com/username
```
**Neu (mit Slug):**
```
https://coder.domain.com/u-a3f9c2d1 # Beispiel: erste 12 Zeichen von SHA256(email)
```
Der Slug ist eindeutig und kann im Dashboard angesehen werden.
## 🔒 Security Features
- **One-Time Use Tokens** - Magic Link kann nur einmal verwendet werden
- **Token Expiration** - Tokens verfallen nach 15 Minuten
- **Rate Limiting** - Max 3 Token-Anfragen pro Email pro Stunde
- **User Enumeration Protection** - Gleiche Meldung ob Email registriert oder nicht
- **Container Isolation** - User-Container haben keinen Zugriff auf Docker Socket
- **No Passwords** - Keine Passwort-Speicherung, kein Passwort-Reset möglich
## 📝 Wichtige Änderungen im Überblick
| Bereich | Alt | Neu |
|---------|-----|-----|
| **Login Feld** | Username + Password | Email nur |
| **Signup Felder** | Username + Email + Password | Email nur |
| **Container ID** | user-{username}-{id} | user-{slug}-{id} |
| **Container URL** | /username | /{slug} |
| **User Identifier** | Username | Email |
| **Authentifizierung** | Username/Password | Magic Link (Email) |
| **Auth-Endpunkte** | /verify | /verify-signup, /verify-login |
## 🐛 Troubleshooting
### "Email konnte nicht gesendet werden"
- Überprüfe SMTP-Konfiguration in `.env`
- Teste: `docker logs spawner` → SMTP Fehler?
- Sind alle SMTP-Credentials korrekt?
### "Token ist abgelaufen"
- Standard-Expiration: 15 Minuten
- Kann in `.env` angepasst werden: `MAGIC_LINK_TOKEN_EXPIRY=900`
### Rate-Limiting blockiert
- Max 3 Requests pro Email pro Stunde
- Warte 1 Stunde oder ändere in `.env`: `MAGIC_LINK_RATE_LIMIT=5`
### Container spawnt nicht
- `docker logs spawner` überprüfen
- Container Template Image existiert? `docker images | grep user-template`
- Traefik Network konfiguriert?
## 📚 Weitere Ressourcen
- **CLAUDE.md** - Projekt-Übersicht und Architektur
- **Backend Logs** - `docker logs spawner`
- **Frontend Logs** - Browser Console (F12)
- **Container Logs** - `docker logs user-{slug}-{id}`
## ✨ Nächste Phase (optional)
1. **Admin Magic Link** - Admins können Benutzern Magic Links senden
2. **Two-Factor Auth** - Optional 2FA mit TOTP
3. **WebAuthn** - Biometric/FIDO2 support
4. **Session Management** - Token-Refresh, Logout überall
---
**Implementiert von:** Claude Code
**Datum:** 2026-01-31
**Version:** 1.0.0 (Passwordless Auth)

View File

@ -2,18 +2,12 @@
Admin-API Blueprint Admin-API Blueprint
Alle Endpoints erfordern Admin-Rechte. Alle Endpoints erfordern Admin-Rechte.
""" """
import secrets
from flask import Blueprint, jsonify, request, current_app from flask import Blueprint, jsonify, request, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime from datetime import datetime, timedelta
from models import db, User, UserState, AdminTakeoverSession from models import db, User, UserState, AdminTakeoverSession
from decorators import admin_required from decorators import admin_required
from container_manager import ContainerManager from container_manager import ContainerManager
from email_service import (
generate_verification_token,
send_verification_email,
send_password_reset_email
)
from config import Config from config import Config
admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin')
@ -87,10 +81,10 @@ def block_user(user_id):
user.blocked_by = int(admin_id) user.blocked_by = int(admin_id)
db.session.commit() db.session.commit()
current_app.logger.info(f"User {user.username} wurde von Admin {admin_id} gesperrt") current_app.logger.info(f"User {user.email} wurde von Admin {admin_id} gesperrt")
return jsonify({ return jsonify({
'message': f'User {user.username} wurde gesperrt', 'message': f'User {user.email} wurde gesperrt',
'user': user.to_dict() 'user': user.to_dict()
}), 200 }), 200
@ -114,82 +108,49 @@ def unblock_user(user_id):
db.session.commit() db.session.commit()
admin_id = get_jwt_identity() admin_id = get_jwt_identity()
current_app.logger.info(f"User {user.username} wurde von Admin {admin_id} entsperrt") current_app.logger.info(f"User {user.email} wurde von Admin {admin_id} entsperrt")
return jsonify({ return jsonify({
'message': f'User {user.username} wurde entsperrt', 'message': f'User {user.email} wurde entsperrt',
'user': user.to_dict() 'user': user.to_dict()
}), 200 }), 200
@admin_bp.route('/users/<int:user_id>/reset-password', methods=['POST'])
@jwt_required()
@admin_required()
def reset_user_password(user_id):
"""Setzt das Passwort eines Benutzers zurueck"""
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User nicht gefunden'}), 404
data = request.get_json() or {}
# Neues Passwort: entweder angegeben oder zufaellig generiert
new_password = data.get('password')
if not new_password:
new_password = secrets.token_urlsafe(12)
if len(new_password) < 6:
return jsonify({'error': 'Passwort muss mindestens 6 Zeichen lang sein'}), 400
user.set_password(new_password)
db.session.commit()
# Email mit neuem Passwort senden
email_sent = send_password_reset_email(user.email, user.username, new_password)
admin_id = get_jwt_identity()
current_app.logger.info(f"Passwort von User {user.username} wurde von Admin {admin_id} zurueckgesetzt")
return jsonify({
'message': f'Passwort von {user.username} wurde zurueckgesetzt',
'email_sent': email_sent,
'password_generated': 'password' not in (data or {})
}), 200
@admin_bp.route('/users/<int:user_id>/resend-verification', methods=['POST']) @admin_bp.route('/users/<int:user_id>/resend-verification', methods=['POST'])
@jwt_required() @jwt_required()
@admin_required() @admin_required()
def resend_user_verification(user_id): def resend_user_verification(user_id):
"""Sendet Verifizierungs-Email erneut an einen Benutzer""" """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) user = User.query.get(user_id)
if not user: if not user:
return jsonify({'error': 'User nicht gefunden'}), 404 return jsonify({'error': 'User nicht gefunden'}), 404
if user.state != UserState.REGISTERED.value: # Generiere neuen Magic Link Token
return jsonify({'error': 'User ist bereits verifiziert'}), 400 token = generate_magic_link_token()
expires_at = datetime.utcnow() + timedelta(seconds=Config.MAGIC_LINK_TOKEN_EXPIRY)
# Neuen Token generieren magic_token = MagicLinkToken(
user.verification_token = generate_verification_token() user_id=user.id,
user.verification_sent_at = datetime.utcnow() token=token,
token_type='login',
expires_at=expires_at,
ip_address=request.remote_addr
)
db.session.add(magic_token)
db.session.commit() db.session.commit()
# Email senden # Email senden
frontend_url = Config.FRONTEND_URL email_sent = send_magic_link_email(user.email, token, 'login')
email_sent = send_verification_email(
user.email,
user.username,
user.verification_token,
frontend_url
)
admin_id = get_jwt_identity() admin_id = get_jwt_identity()
current_app.logger.info(f"Verifizierungs-Email fuer User {user.username} wurde von Admin {admin_id} erneut gesendet") current_app.logger.info(f"Magic Link für User {user.email} wurde von Admin {admin_id} erneut gesendet")
return jsonify({ return jsonify({
'message': f'Verifizierungs-Email an {user.email} gesendet', 'message': f'Login-Link an {user.email} gesendet',
'email_sent': email_sent 'email_sent': email_sent
}), 200 }), 200
@ -221,10 +182,10 @@ def delete_user_container(user_id):
db.session.commit() db.session.commit()
admin_id = get_jwt_identity() admin_id = get_jwt_identity()
current_app.logger.info(f"Container {old_container_id[:12]} von User {user.username} wurde von Admin {admin_id} geloescht") current_app.logger.info(f"Container {old_container_id[:12]} von User {user.email} wurde von Admin {admin_id} geloescht")
return jsonify({ return jsonify({
'message': f'Container von {user.username} wurde geloescht', 'message': f'Container von {user.email} wurde geloescht',
'user': user.to_dict() 'user': user.to_dict()
}), 200 }), 200
@ -256,14 +217,14 @@ def delete_user(user_id):
except Exception as e: except Exception as e:
current_app.logger.warning(f"Fehler beim Loeschen des Containers: {str(e)}") current_app.logger.warning(f"Fehler beim Loeschen des Containers: {str(e)}")
username = user.username email = user.email
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
current_app.logger.info(f"User {username} wurde von Admin {admin_id} geloescht") current_app.logger.info(f"User {email} wurde von Admin {admin_id} geloescht")
return jsonify({ return jsonify({
'message': f'User {username} wurde geloescht' 'message': f'User {email} wurde geloescht'
}), 200 }), 200
@ -276,7 +237,7 @@ def delete_user(user_id):
@admin_required() @admin_required()
def start_takeover(user_id): def start_takeover(user_id):
""" """
Startet eine Takeover-Session fuer einen User-Container. Startet eine Takeover-Session für einen User-Container.
DUMMY-IMPLEMENTIERUNG - wird in Phase 2 vollstaendig implementiert. DUMMY-IMPLEMENTIERUNG - wird in Phase 2 vollstaendig implementiert.
""" """
admin_id = get_jwt_identity() admin_id = get_jwt_identity()
@ -300,13 +261,13 @@ def start_takeover(user_id):
db.session.add(session) db.session.add(session)
db.session.commit() db.session.commit()
current_app.logger.info(f"Admin {admin_id} hat Takeover fuer User {user.username} gestartet (Session {session.id})") current_app.logger.info(f"Admin {admin_id} hat Takeover für User {user.email} gestartet (Session {session.id})")
return jsonify({ return jsonify({
'message': 'Takeover-Funktion ist noch nicht vollstaendig implementiert (Phase 2)', 'message': 'Takeover-Funktion ist noch nicht vollstaendig implementiert (Phase 2)',
'session_id': session.id, 'session_id': session.id,
'status': 'dummy', 'status': 'dummy',
'note': 'Diese Funktion wird in einer spaeteren Version verfuegbar sein' 'note': 'Diese Funktion wird in einer späteren Version verfügbar sein'
}), 200 }), 200
@ -350,9 +311,9 @@ def get_active_takeovers():
sessions_list.append({ sessions_list.append({
'id': session.id, 'id': session.id,
'admin_id': session.admin_id, 'admin_id': session.admin_id,
'admin_username': session.admin.username if session.admin else None, 'admin_email': session.admin.email if session.admin else None,
'target_user_id': session.target_user_id, 'target_user_id': session.target_user_id,
'target_username': session.target_user.username if session.target_user else None, 'target_email': session.target_user.email if session.target_user else None,
'started_at': session.started_at.isoformat() if session.started_at else None, 'started_at': session.started_at.isoformat() if session.started_at else None,
'reason': session.reason 'reason': session.reason
}) })

384
api.py
View File

@ -6,10 +6,16 @@ from flask_jwt_extended import (
get_jwt get_jwt
) )
from datetime import timedelta, datetime from datetime import timedelta, datetime
from models import db, User, UserState from models import db, User, UserState, MagicLinkToken
from container_manager import ContainerManager from container_manager import ContainerManager
from email_service import generate_verification_token, send_verification_email from email_service import (
generate_slug_from_email,
generate_magic_link_token,
send_magic_link_email,
check_rate_limit
)
from config import Config from config import Config
import re
api_bp = Blueprint('api', __name__, url_prefix='/api') api_bp = Blueprint('api', __name__, url_prefix='/api')
@ -19,146 +25,277 @@ token_blacklist = set()
@api_bp.route('/auth/login', methods=['POST']) @api_bp.route('/auth/login', methods=['POST'])
def api_login(): def api_login():
"""API-Login - gibt JWT-Token zurueck""" """API-Login mit Magic Link (Passwordless)"""
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'error': 'Keine Daten uebermittelt'}), 400 return jsonify({'error': 'Keine Daten uebermittelt'}), 400
username = data.get('username') email = data.get('email', '').strip().lower()
password = data.get('password')
if not username or not password: if not email:
return jsonify({'error': 'Username und Passwort erforderlich'}), 400 return jsonify({'error': 'Email ist erforderlich'}), 400
user = User.query.filter_by(username=username).first() # Prüfe ob User existiert
user = User.query.filter_by(email=email).first()
if not user or not user.check_password(password): if not user:
return jsonify({'error': 'Ungueltige Anmeldedaten'}), 401 # Security: Gleiche Nachricht wie bei Erfolg (verhindert User-Enumeration)
# 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({ return jsonify({
'error': 'Email nicht verifiziert. Bitte pruefe dein Postfach.', 'message': 'Falls diese Email registriert ist, wurde ein Login-Link gesendet.'
'needs_verification': True }), 200
}), 403
# Container spawnen wenn noch nicht vorhanden # 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: if not user.container_id:
try: try:
container_mgr = ContainerManager() container_mgr = ContainerManager()
container_id, port = container_mgr.spawn_container(user.id, user.username) container_id, port = container_mgr.spawn_container(user.id, user.slug)
user.container_id = container_id user.container_id = container_id
user.container_port = port 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() db.session.commit()
current_app.logger.info(f"[SPAWNER] Container erstellt für User {user.id} (slug: {user.slug})")
except Exception as e: except Exception as e:
current_app.logger.error(f"Container-Start fehlgeschlagen: {str(e)}") current_app.logger.error(f"Container-Spawn fehlgeschlagen: {str(e)}")
return jsonify({'error': f'Container-Start fehlgeschlagen: {str(e)}'}), 500 # User ist trotzdem erstellt, Container kann später manuell erstellt werden
else:
# last_used aktualisieren
user.last_used = datetime.utcnow()
db.session.commit()
# JWT-Token erstellen # JWT erstellen
expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600)) expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
access_token = create_access_token( access_token = create_access_token(
identity=str(user.id), identity=str(user.id),
expires_delta=expires, expires_delta=expires,
additional_claims={'username': user.username, 'is_admin': user.is_admin} additional_claims={'is_admin': user.is_admin}
) )
current_app.logger.info(f"[SIGNUP] User {user.email} erfolgreich registriert")
return jsonify({ return jsonify({
'access_token': access_token, 'access_token': access_token,
'token_type': 'Bearer', 'token_type': 'Bearer',
'expires_in': int(expires.total_seconds()), 'expires_in': int(expires.total_seconds()),
'user': { 'user': {
'id': user.id, 'id': user.id,
'username': user.username,
'email': user.email, 'email': user.email,
'slug': user.slug,
'is_admin': user.is_admin, 'is_admin': user.is_admin,
'state': user.state 'state': user.state,
'container_id': user.container_id
} }
}), 200 }), 200
@api_bp.route('/auth/signup', methods=['POST']) @api_bp.route('/auth/verify-login', methods=['GET'])
def api_signup(): def api_verify_login():
"""API-Registrierung - erstellt User und sendet Verifizierungs-Email""" """Verifiziert Login Magic Link und erstellt JWT"""
data = request.get_json() token = request.args.get('token')
if not data: if not token:
return jsonify({'error': 'Keine Daten uebermittelt'}), 400 return jsonify({'error': 'Token fehlt'}), 400
username = data.get('username') # Suche Token
email = data.get('email') magic_token = MagicLinkToken.query.filter_by(
password = data.get('password') token=token,
token_type='login'
).first()
if not username or not email or not password: if not magic_token:
return jsonify({'error': 'Username, Email und Passwort erforderlich'}), 400 return jsonify({'error': 'Ungültiger oder abgelaufener Link'}), 400
# Validierung # Prüfe Gültigkeit
if len(username) < 3: if not magic_token.is_valid():
return jsonify({'error': 'Username muss mindestens 3 Zeichen lang sein'}), 400 return jsonify({'error': 'Dieser Link ist abgelaufen oder wurde bereits verwendet'}), 400
if len(password) < 6: # Hole User
return jsonify({'error': 'Passwort muss mindestens 6 Zeichen lang sein'}), 400 user = magic_token.user
# Username-Validierung (nur alphanumerisch und Bindestrich) # Prüfe ob User blockiert
import re if user.is_blocked:
if not re.match(r'^[a-zA-Z0-9-]+$', username): return jsonify({'error': 'Dein Account wurde gesperrt'}), 403
return jsonify({'error': 'Username darf nur Buchstaben, Zahlen und Bindestriche enthalten'}), 400
# Pruefe ob User existiert # Prüfe ob Email verifiziert
if User.query.filter_by(username=username).first(): if user.state == UserState.REGISTERED.value:
return jsonify({'error': 'Username bereits vergeben'}), 409 return jsonify({'error': 'Bitte verifiziere zuerst deine Email-Adresse'}), 403
if User.query.filter_by(email=email).first(): # Markiere Token als verwendet
return jsonify({'error': 'Email bereits registriert'}), 409 magic_token.mark_as_used()
# Pruefe ob dies der erste User ist -> wird Admin # Container starten falls gestoppt
is_first_user = User.query.count() == 0 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)}")
# Neuen User anlegen user.last_used = datetime.utcnow()
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() db.session.commit()
# Verifizierungs-Email senden # JWT erstellen
frontend_url = Config.FRONTEND_URL expires = timedelta(seconds=current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
email_sent = send_verification_email( access_token = create_access_token(
user.email, identity=str(user.id),
user.username, expires_delta=expires,
user.verification_token, additional_claims={'is_admin': user.is_admin}
frontend_url
) )
if not email_sent: current_app.logger.info(f"[LOGIN] User {user.email} erfolgreich eingeloggt")
current_app.logger.warning(f"Verifizierungs-Email konnte nicht gesendet werden an {user.email}")
return jsonify({ return jsonify({
'message': 'Registrierung erfolgreich. Bitte pruefe dein Postfach und bestatige deine Email-Adresse.', 'access_token': access_token,
'token_type': 'Bearer',
'expires_in': int(expires.total_seconds()),
'user': { 'user': {
'id': user.id, 'id': user.id,
'username': user.username,
'email': user.email, 'email': user.email,
'is_admin': user.is_admin 'slug': user.slug,
}, 'is_admin': user.is_admin,
'email_sent': email_sent 'state': user.state,
}), 201 'container_id': user.container_id
}
}), 200
@api_bp.route('/auth/logout', methods=['POST']) @api_bp.route('/auth/logout', methods=['POST'])
@ -170,71 +307,6 @@ def api_logout():
return jsonify({'message': 'Erfolgreich abgemeldet'}), 200 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']) @api_bp.route('/user/me', methods=['GET'])
@jwt_required() @jwt_required()
def api_user_me(): def api_user_me():
@ -248,7 +320,7 @@ def api_user_me():
# Service-URL berechnen # Service-URL berechnen
scheme = current_app.config['PREFERRED_URL_SCHEME'] scheme = current_app.config['PREFERRED_URL_SCHEME']
spawner_domain = f"{current_app.config['SPAWNER_SUBDOMAIN']}.{current_app.config['BASE_DOMAIN']}" spawner_domain = f"{current_app.config['SPAWNER_SUBDOMAIN']}.{current_app.config['BASE_DOMAIN']}"
service_url = f"{scheme}://{spawner_domain}/{user.username}" service_url = f"{scheme}://{spawner_domain}/{user.slug}"
# Container-Status abrufen # Container-Status abrufen
container_status = 'unknown' container_status = 'unknown'
@ -262,8 +334,8 @@ def api_user_me():
return jsonify({ return jsonify({
'user': { 'user': {
'id': user.id, 'id': user.id,
'username': user.username,
'email': user.email, 'email': user.email,
'slug': user.slug,
'is_admin': user.is_admin, 'is_admin': user.is_admin,
'state': user.state, 'state': user.state,
'last_used': user.last_used.isoformat() if user.last_used else None, 'last_used': user.last_used.isoformat() if user.last_used else None,
@ -324,7 +396,7 @@ def api_container_restart():
# Neuen Container starten # Neuen Container starten
try: try:
container_id, port = container_mgr.spawn_container(user.id, user.username) container_id, port = container_mgr.spawn_container(user.id, user.slug)
user.container_id = container_id user.container_id = container_id
user.container_port = port user.container_port = port

View File

@ -91,6 +91,12 @@ class Config:
f"{PREFERRED_URL_SCHEME}://{SPAWNER_SUBDOMAIN}.{BASE_DOMAIN}" f"{PREFERRED_URL_SCHEME}://{SPAWNER_SUBDOMAIN}.{BASE_DOMAIN}"
) )
# ========================================
# Magic Link Passwordless Auth
# ========================================
MAGIC_LINK_TOKEN_EXPIRY = int(os.getenv('MAGIC_LINK_TOKEN_EXPIRY', 900)) # 15 Minuten
MAGIC_LINK_RATE_LIMIT = int(os.getenv('MAGIC_LINK_RATE_LIMIT', 3)) # Max 3 pro Stunde
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
"""Konfiguration für Entwicklung""" """Konfiguration für Entwicklung"""

View File

@ -17,14 +17,14 @@ class ContainerManager:
raise Exception(f"Docker connection failed: {str(e)}") raise Exception(f"Docker connection failed: {str(e)}")
return self.client return self.client
def spawn_container(self, user_id, username): def spawn_container(self, user_id, slug):
"""Spawnt einen neuen Container für den User""" """Spawnt einen neuen Container für den User"""
try: try:
existing = self._get_user_container(username) existing = self._get_user_container(slug)
if existing and existing.status == 'running': if existing and existing.status == 'running':
return existing.id, self._get_container_port(existing) return existing.id, self._get_container_port(existing)
# Pfad-basiertes Routing: User unter coder.wieland.org/username # Pfad-basiertes Routing: User unter coder.domain.org/<slug>
base_host = f"{Config.SPAWNER_SUBDOMAIN}.{Config.BASE_DOMAIN}" base_host = f"{Config.SPAWNER_SUBDOMAIN}.{Config.BASE_DOMAIN}"
# Labels vorbereiten # Labels vorbereiten
@ -34,12 +34,12 @@ class ContainerManager:
# HTTPS Router mit PathPrefix # HTTPS Router mit PathPrefix
f'traefik.http.routers.user{user_id}.rule': f'traefik.http.routers.user{user_id}.rule':
f'Host(`{base_host}`) && PathPrefix(`/{username}`)', f'Host(`{base_host}`) && PathPrefix(`/{slug}`)',
f'traefik.http.routers.user{user_id}.entrypoints': Config.TRAEFIK_ENTRYPOINT, f'traefik.http.routers.user{user_id}.entrypoints': Config.TRAEFIK_ENTRYPOINT,
f'traefik.http.routers.user{user_id}.priority': '100', f'traefik.http.routers.user{user_id}.priority': '100',
# StripPrefix Middleware - entfernt /{username} bevor Container Request erhält # StripPrefix Middleware - entfernt /{slug} bevor Container Request erhält
f'traefik.http.routers.user{user_id}.middlewares': f'user{user_id}-strip', f'traefik.http.routers.user{user_id}.middlewares': f'user{user_id}-strip',
f'traefik.http.middlewares.user{user_id}-strip.stripprefix.prefixes': f'/{username}', f'traefik.http.middlewares.user{user_id}-strip.stripprefix.prefixes': f'/{slug}',
# TLS für HTTPS # TLS für HTTPS
f'traefik.http.routers.user{user_id}.tls': 'true', f'traefik.http.routers.user{user_id}.tls': 'true',
f'traefik.http.routers.user{user_id}.tls.certresolver': Config.TRAEFIK_CERTRESOLVER, f'traefik.http.routers.user{user_id}.tls.certresolver': Config.TRAEFIK_CERTRESOLVER,
@ -49,12 +49,12 @@ class ContainerManager:
# Metadata # Metadata
'spawner.user_id': str(user_id), 'spawner.user_id': str(user_id),
'spawner.username': username, 'spawner.slug': slug,
'spawner.managed': 'true' 'spawner.managed': 'true'
} }
# Logging: Traefik-Labels ausgeben # Logging: Traefik-Labels ausgeben
print(f"[SPAWNER] Creating container user-{username}-{user_id}") print(f"[SPAWNER] Creating container user-{slug}-{user_id}")
print(f"[SPAWNER] Traefik Labels:") print(f"[SPAWNER] Traefik Labels:")
for key, value in labels.items(): for key, value in labels.items():
if 'traefik' in key: if 'traefik' in key:
@ -62,13 +62,13 @@ class ContainerManager:
container = self._get_client().containers.run( container = self._get_client().containers.run(
Config.USER_TEMPLATE_IMAGE, Config.USER_TEMPLATE_IMAGE,
name=f"user-{username}-{user_id}", name=f"user-{slug}-{user_id}",
detach=True, detach=True,
network=Config.TRAEFIK_NETWORK, network=Config.TRAEFIK_NETWORK,
labels=labels, labels=labels,
environment={ environment={
'USER_ID': str(user_id), 'USER_ID': str(user_id),
'USERNAME': username 'USER_SLUG': slug
}, },
restart_policy={'Name': 'unless-stopped'}, restart_policy={'Name': 'unless-stopped'},
mem_limit=Config.DEFAULT_MEMORY_LIMIT, mem_limit=Config.DEFAULT_MEMORY_LIMIT,
@ -76,7 +76,7 @@ class ContainerManager:
) )
print(f"[SPAWNER] Container created: {container.id[:12]}") print(f"[SPAWNER] Container created: {container.id[:12]}")
print(f"[SPAWNER] URL: https://{base_host}/{username}") print(f"[SPAWNER] URL: https://{base_host}/{slug}")
return container.id, 8080 return container.id, 8080
except docker.errors.ImageNotFound as e: except docker.errors.ImageNotFound as e:
@ -91,6 +91,17 @@ class ContainerManager:
print(f"[SPAWNER] ERROR: {str(e)}") print(f"[SPAWNER] ERROR: {str(e)}")
raise raise
def start_container(self, container_id):
"""Startet einen gestoppten User-Container"""
try:
container = self._get_client().containers.get(container_id)
if container.status != 'running':
container.start()
print(f"[SPAWNER] Container {container_id[:12]} gestartet")
return True
except docker.errors.NotFound:
return False
def stop_container(self, container_id): def stop_container(self, container_id):
"""Stoppt einen User-Container""" """Stoppt einen User-Container"""
try: try:
@ -117,9 +128,9 @@ class ContainerManager:
except docker.errors.NotFound: except docker.errors.NotFound:
return 'not_found' return 'not_found'
def _get_user_container(self, username): def _get_user_container(self, slug):
"""Findet existierenden Container für User""" """Findet existierenden Container für User"""
filters = {'label': f'spawner.username={username}'} filters = {'label': f'spawner.slug={slug}'}
containers = self._get_client().containers.list(all=True, filters=filters) containers = self._get_client().containers.list(all=True, filters=filters)
return containers[0] if containers else None return containers[0] if containers else None

View File

@ -1,11 +1,13 @@
""" """
Email-Service fuer Verifizierungs-Emails Email-Service fuer Verifizierungs-Emails und Magic Links
""" """
import smtplib import smtplib
import secrets import secrets
import hashlib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from config import Config from config import Config
from datetime import datetime, timedelta
def generate_verification_token(): def generate_verification_token():
@ -13,26 +15,71 @@ def generate_verification_token():
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
def send_verification_email(user_email, username, token, base_url=None): def generate_slug_from_email(email: str) -> str:
""" """
Sendet eine Verifizierungs-Email an den Benutzer. 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: Args:
user_email: Email-Adresse des Benutzers email: Empfänger-Email
username: Benutzername token: Magic Link Token
token: Verifizierungs-Token token_type: 'signup' oder 'login'
base_url: Basis-URL fuer den Verifizierungs-Link (optional)
Returns: Returns:
True bei Erfolg, False bei Fehler True bei Erfolg, False bei Fehler
""" """
if base_url is None: # URL basierend auf Type
base_url = Config.FRONTEND_URL if token_type == 'signup':
verify_url = f"{Config.FRONTEND_URL}/verify-signup?token={token}"
verify_url = f"{base_url}/verify-success?token={token}" subject = "Registrierung abschließen - Container Spawner"
action_text = "Registrierung abschließen"
# Email-Inhalt greeting = "Vielen Dank für deine Registrierung!"
subject = "Bestatige deine Email-Adresse - Container Spawner" 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""" html_content = f"""
<!DOCTYPE html> <!DOCTYPE html>
@ -54,17 +101,16 @@ def send_verification_email(user_email, username, token, base_url=None):
<h1>Container Spawner</h1> <h1>Container Spawner</h1>
</div> </div>
<div class="content"> <div class="content">
<h2>Hallo {username}!</h2> <p>{greeting}</p>
<p>Vielen Dank fuer deine Registrierung beim Container Spawner.</p> <p>Klicke auf den Button, um fortzufahren:</p>
<p>Bitte bestatige deine Email-Adresse, indem du auf den folgenden Button klickst:</p>
<p style="text-align: center;"> <p style="text-align: center;">
<a href="{verify_url}" class="button">Email bestätigen</a> <a href="{verify_url}" class="button">{action_text}</a>
</p> </p>
<p>Oder kopiere diesen Link in deinen Browser:</p> <p>Oder kopiere diesen Link in deinen Browser:</p>
<p style="word-break: break-all; background: #eee; padding: 10px; border-radius: 3px;"> <p style="word-break: break-all; background: #eee; padding: 10px; border-radius: 3px;">
{verify_url} {verify_url}
</p> </p>
<p><strong>Hinweis:</strong> Dieser Link ist nur einmal verwendbar.</p> <p><small>Dieser Link ist 15 Minuten gültig und kann nur einmal verwendet werden.</small></p>
</div> </div>
<div class="footer"> <div class="footer">
<p>Diese Email wurde automatisch generiert. Bitte antworte nicht darauf.</p> <p>Diese Email wurde automatisch generiert. Bitte antworte nicht darauf.</p>
@ -75,112 +121,13 @@ def send_verification_email(user_email, username, token, base_url=None):
""" """
text_content = f""" text_content = f"""
Hallo {username}! {greeting}
Vielen Dank fuer deine Registrierung beim Container Spawner. Bitte öffne folgenden Link oder kopiere ihn in deinen Browser:
Bitte bestatige deine Email-Adresse, indem du folgenden Link oeffnest:
{verify_url} {verify_url}
Hinweis: Dieser Link ist nur einmal verwendbar. Hinweis: Dieser Link ist 15 Minuten gültig und kann nur einmal verwendet werden.
---
Diese Email wurde automatisch generiert.
"""
# Email erstellen
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = Config.SMTP_FROM
msg['To'] = user_email
# Text- und HTML-Teil hinzufuegen
part1 = MIMEText(text_content, 'plain', 'utf-8')
part2 = MIMEText(html_content, 'html', 'utf-8')
msg.attach(part1)
msg.attach(part2)
try:
# SMTP-Verbindung
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)
# Authentifizierung wenn konfiguriert
if Config.SMTP_USER and Config.SMTP_PASSWORD:
server.login(Config.SMTP_USER, Config.SMTP_PASSWORD)
# Email senden
server.sendmail(Config.SMTP_FROM, user_email, msg.as_string())
server.quit()
print(f"[EMAIL] Verifizierungs-Email gesendet an {user_email}")
return True
except Exception as e:
print(f"[EMAIL] Fehler beim Senden der Email an {user_email}: {str(e)}")
return False
def send_password_reset_email(user_email, username, new_password):
"""
Sendet eine Email mit dem neuen Passwort an den Benutzer.
Args:
user_email: Email-Adresse des Benutzers
username: Benutzername
new_password: Das neue Passwort
Returns:
True bei Erfolg, False bei Fehler
"""
subject = "Dein Passwort wurde zurueckgesetzt - Container Spawner"
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; }}
.password {{ background: #eee; padding: 15px; font-family: monospace; font-size: 18px; text-align: center; border-radius: 5px; }}
.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">
<h2>Hallo {username}!</h2>
<p>Ein Administrator hat dein Passwort zurueckgesetzt.</p>
<p>Dein neues Passwort lautet:</p>
<p class="password">{new_password}</p>
<p><strong>Wichtig:</strong> Bitte aendere dieses Passwort nach dem ersten Login!</p>
</div>
<div class="footer">
<p>Diese Email wurde automatisch generiert. Bitte antworte nicht darauf.</p>
</div>
</div>
</body>
</html>
"""
text_content = f"""
Hallo {username}!
Ein Administrator hat dein Passwort zurueckgesetzt.
Dein neues Passwort lautet: {new_password}
Wichtig: Bitte aendere dieses Passwort nach dem ersten Login!
--- ---
Diese Email wurde automatisch generiert. Diese Email wurde automatisch generiert.
@ -189,7 +136,7 @@ def send_password_reset_email(user_email, username, new_password):
msg = MIMEMultipart('alternative') msg = MIMEMultipart('alternative')
msg['Subject'] = subject msg['Subject'] = subject
msg['From'] = Config.SMTP_FROM msg['From'] = Config.SMTP_FROM
msg['To'] = user_email msg['To'] = email
part1 = MIMEText(text_content, 'plain', 'utf-8') part1 = MIMEText(text_content, 'plain', 'utf-8')
part2 = MIMEText(html_content, 'html', 'utf-8') part2 = MIMEText(html_content, 'html', 'utf-8')
@ -206,12 +153,12 @@ def send_password_reset_email(user_email, username, new_password):
if Config.SMTP_USER and Config.SMTP_PASSWORD: if Config.SMTP_USER and Config.SMTP_PASSWORD:
server.login(Config.SMTP_USER, Config.SMTP_PASSWORD) server.login(Config.SMTP_USER, Config.SMTP_PASSWORD)
server.sendmail(Config.SMTP_FROM, user_email, msg.as_string()) server.sendmail(Config.SMTP_FROM, email, msg.as_string())
server.quit() server.quit()
print(f"[EMAIL] Passwort-Reset-Email gesendet an {user_email}") print(f"[EMAIL] Magic Link ({token_type}) gesendet an {email}")
return True return True
except Exception as e: except Exception as e:
print(f"[EMAIL] Fehler beim Senden der Email an {user_email}: {str(e)}") print(f"[EMAIL] Fehler beim Senden der Email an {email}: {str(e)}")
return False return False

View File

@ -144,10 +144,10 @@ export default function DashboardPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback className="text-xs"> <AvatarFallback className="text-xs">
{user.username.slice(0, 2).toUpperCase()} {user.email.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="text-sm font-medium">{user.username}</span> <span className="text-sm font-medium">{user.email}</span>
{user.is_admin && ( {user.is_admin && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Admin Admin
@ -302,14 +302,16 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div>
<p className="text-sm text-muted-foreground">Benutzername</p>
<p className="font-medium">{user.username}</p>
</div>
<div> <div>
<p className="text-sm text-muted-foreground">E-Mail</p> <p className="text-sm text-muted-foreground">E-Mail</p>
<p className="font-medium">{user.email}</p> <p className="font-medium">{user.email}</p>
</div> </div>
<div>
<p className="text-sm text-muted-foreground">Container Slug</p>
<code className="font-medium text-sm bg-muted px-2 py-1 rounded">
{user.slug}
</code>
</div>
<div> <div>
<p className="text-sm text-muted-foreground">Registriert</p> <p className="text-sm text-muted-foreground">Registriert</p>
<p className="font-medium"> <p className="font-medium">

View File

@ -4,7 +4,6 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -15,17 +14,13 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Container, Loader2, Mail, AlertCircle } from "lucide-react"; import { Loader2, Mail, AlertCircle, Container } from "lucide-react";
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [emailSent, setEmailSent] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [needsVerification, setNeedsVerification] = useState(false);
const [resendingEmail, setResendingEmail] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const { login, user, isLoading } = useAuth(); const { login, user, isLoading } = useAuth();
const router = useRouter(); const router = useRouter();
@ -39,41 +34,21 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
setNeedsVerification(false);
setEmailSent(false);
setIsSubmitting(true);
const result = await login(username, password);
if (result.success) {
router.push("/dashboard");
} else {
setError(result.error || "Login fehlgeschlagen");
if (result.needsVerification) {
setNeedsVerification(true);
}
setIsSubmitting(false);
}
};
const handleResendVerification = async () => {
if (!email) { if (!email) {
setError("Bitte gib deine Email-Adresse ein"); setError("Bitte gib deine Email-Adresse ein");
return; return;
} }
setResendingEmail(true); setIsSubmitting(true);
setError(""); const result = await login(email);
const { data, error } = await api.resendVerification(email); if (result.success) {
if (error) {
setError(error);
} else {
setEmailSent(true); setEmailSent(true);
} else {
setError(result.message || "Login fehlgeschlagen");
setIsSubmitting(false);
} }
setResendingEmail(false);
}; };
if (isLoading) { if (isLoading) {
@ -91,12 +66,43 @@ export default function LoginPage() {
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary">
<Container className="h-6 w-6 text-primary-foreground" /> <Container className="h-6 w-6 text-primary-foreground" />
</div> </div>
<CardTitle className="text-2xl font-bold">Willkommen</CardTitle> <CardTitle className="text-2xl font-bold">Login</CardTitle>
<CardDescription> <CardDescription>
Melde dich an, um auf deinen Container zuzugreifen Gib deine Email-Adresse ein, um dich anzumelden
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{emailSent ? (
<div className="space-y-4">
<div className="rounded-md border border-green-200 bg-green-50 p-4">
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-5 w-5 text-green-600" />
<div className="space-y-2">
<p className="text-sm font-medium text-green-800">
Email gesendet!
</p>
<p className="text-sm text-green-700">
Wir haben einen Login-Link an <strong>{email}</strong> gesendet.
Bitte überprüfe dein Postfach und klicke auf den Link.
</p>
<p className="text-xs text-green-600">
Der Link ist 15 Minuten gültig.
</p>
</div>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
setEmailSent(false);
setEmail("");
}}
>
Neue Email anfordern
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
@ -107,92 +113,37 @@ export default function LoginPage() {
</div> </div>
)} )}
{/* Verifizierungs-Hinweis */}
{needsVerification && (
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-4">
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-5 w-5 text-yellow-600" />
<div className="space-y-2">
<p className="text-sm font-medium text-yellow-800">
Email nicht verifiziert
</p>
<p className="text-sm text-yellow-700">
Bitte pruefe dein Postfach und klicke auf den
Verifizierungs-Link. Falls du keine Email erhalten hast,
kannst du eine neue anfordern.
</p>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email-Adresse</Label>
<Input <Input
id="email"
type="email" type="email"
placeholder="Deine Email-Adresse" placeholder="deine@email.de"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="bg-white" required
autoComplete="email"
disabled={isSubmitting}
/> />
<Button
type="button"
variant="outline"
size="sm"
onClick={handleResendVerification}
disabled={resendingEmail || emailSent}
className="w-full"
>
{resendingEmail ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird gesendet...
</>
) : emailSent ? (
"Email gesendet!"
) : (
"Neue Verifizierungs-Email senden"
)}
</Button>
</div> </div>
</div>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">Benutzername</Label>
<Input
id="username"
type="text"
placeholder="dein-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
placeholder="Dein Passwort"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}> <Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Anmelden... Login-Link wird gesendet...
</> </>
) : ( ) : (
"Anmelden" "Login-Link anfordern"
)} )}
</Button> </Button>
</form> </form>
)}
<div className="mt-6 text-center text-sm"> <div className="mt-6 text-center text-sm">
Noch kein Konto?{" "} Noch kein Konto?{" "}
<Link href="/signup" className="text-primary hover:underline"> <Link href="/signup" className="text-primary hover:underline">
Registrieren Jetzt registrieren
</Link> </Link>
</div> </div>
</CardContent> </CardContent>

View File

@ -14,17 +14,13 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Container, Loader2, Mail, CheckCircle2 } from "lucide-react"; import { Container, Loader2, Mail, AlertCircle } from "lucide-react";
export default function SignupPage() { export default function SignupPage() {
const [username, setUsername] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [emailSent, setEmailSent] = useState(false);
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [signupSuccess, setSignupSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
const { signup, user, isLoading } = useAuth(); const { signup, user, isLoading } = useAuth();
const router = useRouter(); const router = useRouter();
@ -39,36 +35,18 @@ export default function SignupPage() {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
if (password !== confirmPassword) { if (!email) {
setError("Passwoerter stimmen nicht ueberein"); setError("Bitte gib deine Email-Adresse ein");
return;
}
if (password.length < 6) {
setError("Passwort muss mindestens 6 Zeichen lang sein");
return;
}
if (username.length < 3) {
setError("Benutzername muss mindestens 3 Zeichen lang sein");
return;
}
// Validiere Username-Format
if (!/^[a-zA-Z0-9-]+$/.test(username)) {
setError("Benutzername darf nur Buchstaben, Zahlen und Bindestriche enthalten");
return; return;
} }
setIsSubmitting(true); setIsSubmitting(true);
const result = await signup(email);
const result = await signup(username, email, password);
if (result.success) { if (result.success) {
setSignupSuccess(true); setEmailSent(true);
setSuccessMessage(result.message || "Registrierung erfolgreich!");
} else { } else {
setError(result.error || "Registrierung fehlgeschlagen"); setError(result.message || "Registrierung fehlgeschlagen");
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
@ -81,48 +59,6 @@ export default function SignupPage() {
); );
} }
// Erfolgsanzeige nach Registrierung
if (signupSuccess) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-2xl font-bold">
Registrierung erfolgreich!
</CardTitle>
<CardDescription>{successMessage}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-muted/50 p-4">
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-5 w-5 text-primary" />
<div>
<p className="font-medium">Pruefe dein Postfach</p>
<p className="text-sm text-muted-foreground">
Wir haben eine Verifizierungs-Email an{" "}
<strong>{email}</strong> gesendet. Klicke auf den Link in
der Email, um dein Konto zu aktivieren.
</p>
</div>
</div>
</div>
<div className="text-center text-sm text-muted-foreground">
<p>Keine Email erhalten?</p>
<p>Pruefe deinen Spam-Ordner oder versuche dich anzumelden,</p>
<p>um eine neue Verifizierungs-Email anzufordern.</p>
</div>
<Button asChild className="w-full">
<Link href="/login">Zum Login</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4"> <div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
@ -130,88 +66,84 @@ export default function SignupPage() {
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary">
<Container className="h-6 w-6 text-primary-foreground" /> <Container className="h-6 w-6 text-primary-foreground" />
</div> </div>
<CardTitle className="text-2xl font-bold">Konto erstellen</CardTitle> <CardTitle className="text-2xl font-bold">Registrierung</CardTitle>
<CardDescription> <CardDescription>
Registriere dich, um deinen eigenen Container zu erhalten Gib deine Email-Adresse ein, um einen Account zu erstellen
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{emailSent ? (
<div className="space-y-4">
<div className="rounded-md border border-green-200 bg-green-50 p-4">
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-5 w-5 text-green-600" />
<div className="space-y-2">
<p className="text-sm font-medium text-green-800">
Email gesendet!
</p>
<p className="text-sm text-green-700">
Wir haben dir einen Registrierungs-Link an <strong>{email}</strong> gesendet.
Bitte überprüfe dein Postfach und klicke auf den Link, um deine Registrierung abzuschließen.
</p>
<p className="text-xs text-green-600">
Der Link ist 15 Minuten gültig.
</p>
</div>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
setEmailSent(false);
setEmail("");
}}
>
Neue Email anfordern
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error} <div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Benutzername</Label> <Label htmlFor="email">Email-Adresse</Label>
<Input
id="username"
type="text"
placeholder="dein-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Nur Buchstaben, Zahlen und Bindestriche. Wird Teil deiner
Service-URL.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="name@beispiel.de" placeholder="deine@email.de"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
disabled={isSubmitting} autoComplete="email"
/>
<p className="text-xs text-muted-foreground">
Du erhaeltst eine Verifizierungs-Email an diese Adresse.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
placeholder="Mindestens 6 Zeichen"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Passwort bestaetigen</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Passwort wiederholen"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={isSubmitting}> <Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Registrierung laeuft... Registrierungs-Link wird gesendet...
</> </>
) : ( ) : (
"Registrieren" "Registrierungs-Link anfordern"
)} )}
</Button> </Button>
</form> </form>
)}
<div className="mt-6 text-center text-sm"> <div className="mt-6 text-center text-sm">
Bereits ein Konto?{" "} Bereits ein Konto?{" "}
<Link href="/login" className="text-primary hover:underline"> <Link href="/login" className="text-primary hover:underline">
Anmelden Zum Login
</Link> </Link>
</div> </div>
</CardContent> </CardContent>

View File

@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
export default function VerifyLoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { verifyLogin } = useAuth();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [error, setError] = useState("");
useEffect(() => {
const token = searchParams.get("token");
if (!token) {
setStatus("error");
setError("Kein Token gefunden");
return;
}
const verify = async () => {
const result = await verifyLogin(token);
if (result.success) {
setStatus("success");
// Redirect nach 1 Sekunde zum Dashboard
setTimeout(() => {
router.push("/dashboard");
}, 1000);
} else {
setStatus("error");
setError("Ungültiger oder abgelaufener Link");
}
};
verify();
}, [searchParams, verifyLogin, router]);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
{status === "loading" && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
<CardTitle className="text-2xl font-bold">
Login läuft...
</CardTitle>
</>
)}
{status === "success" && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-2xl font-bold">
Login erfolgreich!
</CardTitle>
<CardDescription>
Du wirst zum Dashboard weitergeleitet
</CardDescription>
</>
)}
{status === "error" && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-2xl font-bold">
Login fehlgeschlagen
</CardTitle>
</>
)}
</CardHeader>
<CardContent>
{status === "loading" && (
<p className="text-center text-muted-foreground">
Bitte warten, du wirst eingeloggt...
</p>
)}
{status === "success" && (
<div className="space-y-4">
<p className="text-center text-muted-foreground">
Du wirst automatisch zum Dashboard weitergeleitet.
</p>
<Button className="w-full" onClick={() => router.push("/dashboard")}>
Zum Dashboard
</Button>
</div>
)}
{status === "error" && (
<div className="space-y-4">
<p className="text-center text-destructive">{error}</p>
<Button variant="outline" className="w-full" asChild>
<Link href="/login">Neuen Login-Link anfordern</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
export default function VerifySignupPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { verifySignup } = useAuth();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [error, setError] = useState("");
useEffect(() => {
const token = searchParams.get("token");
if (!token) {
setStatus("error");
setError("Kein Token gefunden");
return;
}
const verify = async () => {
const result = await verifySignup(token);
if (result.success) {
setStatus("success");
// Redirect nach 2 Sekunden zum Dashboard
setTimeout(() => {
router.push("/dashboard");
}, 2000);
} else {
setStatus("error");
setError("Ungültiger oder abgelaufener Link");
}
};
verify();
}, [searchParams, verifySignup, router]);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
{status === "loading" && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
<CardTitle className="text-2xl font-bold">
Verifizierung läuft...
</CardTitle>
</>
)}
{status === "success" && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-2xl font-bold">
Registrierung erfolgreich!
</CardTitle>
<CardDescription>
Dein Account wurde erstellt und verifiziert
</CardDescription>
</>
)}
{status === "error" && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-2xl font-bold">
Verifizierung fehlgeschlagen
</CardTitle>
</>
)}
</CardHeader>
<CardContent>
{status === "loading" && (
<p className="text-center text-muted-foreground">
Bitte warten, deine Registrierung wird verifiziert...
</p>
)}
{status === "success" && (
<div className="space-y-4">
<p className="text-center text-muted-foreground">
Du wirst automatisch zum Dashboard weitergeleitet.
</p>
<Button className="w-full" onClick={() => router.push("/dashboard")}>
Zum Dashboard
</Button>
</div>
)}
{status === "error" && (
<div className="space-y-4">
<p className="text-center text-destructive">{error}</p>
<Button variant="outline" className="w-full" asChild>
<Link href="/signup">Zurück zur Registrierung</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -7,29 +7,20 @@ import {
useEffect, useEffect,
ReactNode, ReactNode,
} from "react"; } from "react";
import { api, LoginResponse, UserResponse } from "@/lib/api"; import { api, User as ApiUser } from "@/lib/api";
export interface User { export type User = ApiUser;
id: number;
username: string;
email: string;
is_admin: boolean;
state: "registered" | "verified" | "active";
}
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
login: ( isAuthenticated: boolean;
username: string, error: string | null;
password: string login: (email: string) => Promise<{ success: boolean; message?: string }>;
) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>; signup: (email: string) => Promise<{ success: boolean; message?: string }>;
signup: ( verifySignup: (token: string) => Promise<{ success: boolean }>;
username: string, verifyLogin: (token: string) => Promise<{ success: boolean }>;
email: string,
password: string
) => Promise<{ success: boolean; error?: string; message?: string }>;
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
} }
@ -40,6 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const storedToken = localStorage.getItem("token"); const storedToken = localStorage.getItem("token");
@ -58,75 +50,111 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return; return;
} }
const { data, error } = await api.getUser(); const { data, error: apiError } = await api.getUser();
if (data && !error) { if (data && !apiError) {
setUser({ setUser(data.user);
id: data.user.id, setError(null);
username: data.user.username,
email: data.user.email,
is_admin: data.user.is_admin,
state: data.user.state,
});
} else { } else {
localStorage.removeItem("token"); localStorage.removeItem("token");
setToken(null); setToken(null);
setUser(null); setUser(null);
setError(apiError || null);
} }
setIsLoading(false); setIsLoading(false);
}; };
const login = async ( const login = async (email: string): Promise<{ success: boolean; message?: string }> => {
username: string, try {
password: string setError(null);
): Promise<{ success: boolean; error?: string; needsVerification?: boolean }> => { const { data, error: apiError } = await api.auth.login(email);
const { data, error } = await api.login(username, password);
if (error || !data) { if (apiError) {
// Pruefe ob Verifizierung erforderlich setError(apiError);
const needsVerification = error?.includes("nicht verifiziert"); return { success: false };
return {
success: false,
error: error || "Login fehlgeschlagen",
needsVerification
};
} }
return { success: true, message: data?.message };
} catch (err: any) {
const errorMsg = err.message || "Login fehlgeschlagen";
setError(errorMsg);
return { success: false };
}
};
const signup = async (email: string): Promise<{ success: boolean; message?: string }> => {
try {
setError(null);
const { data, error: apiError } = await api.auth.signup(email);
if (apiError) {
setError(apiError);
return { success: false };
}
return { success: true, message: data?.message };
} catch (err: any) {
const errorMsg = err.message || "Registrierung fehlgeschlagen";
setError(errorMsg);
return { success: false };
}
};
const verifySignup = async (signupToken: string): Promise<{ success: boolean }> => {
try {
setError(null);
const { data, error: apiError } = await api.auth.verifySignup(signupToken);
if (apiError) {
setError(apiError);
return { success: false };
}
if (data) {
localStorage.setItem("token", data.access_token); localStorage.setItem("token", data.access_token);
setToken(data.access_token); setToken(data.access_token);
setUser({ setUser(data.user);
id: data.user.id,
username: data.user.username,
email: data.user.email,
is_admin: data.user.is_admin,
state: data.user.state,
});
return { success: true }; return { success: true };
};
const signup = async (
username: string,
email: string,
password: string
): Promise<{ success: boolean; error?: string; message?: string }> => {
const { data, error } = await api.signup(username, email, password);
if (error || !data) {
return { success: false, error: error || "Registrierung fehlgeschlagen" };
} }
// Nach Signup wird kein Token mehr zurueckgegeben return { success: false };
// User muss erst Email verifizieren } catch (err: any) {
return { const errorMsg = err.message || "Verifizierung fehlgeschlagen";
success: true, setError(errorMsg);
message: data.message return { success: false };
}
}; };
const verifyLogin = async (loginToken: string): Promise<{ success: boolean }> => {
try {
setError(null);
const { data, error: apiError } = await api.auth.verifyLogin(loginToken);
if (apiError) {
setError(apiError);
return { success: false };
}
if (data) {
localStorage.setItem("token", data.access_token);
setToken(data.access_token);
setUser(data.user);
return { success: true };
}
return { success: false };
} catch (err: any) {
const errorMsg = err.message || "Login fehlgeschlagen";
setError(errorMsg);
return { success: false };
}
}; };
const logout = async () => { const logout = async () => {
await api.logout(); await api.auth.logout();
localStorage.removeItem("token"); localStorage.removeItem("token");
setToken(null); setToken(null);
setUser(null); setUser(null);
setError(null);
}; };
const refreshUser = async () => { const refreshUser = async () => {
@ -135,7 +163,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ user, token, isLoading, login, signup, logout, refreshUser }} value={{
user,
token,
isLoading,
isAuthenticated: !!token && !!user,
error,
login,
signup,
verifySignup,
verifyLogin,
logout,
refreshUser,
}}
> >
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>

View File

@ -5,9 +5,13 @@ interface ApiResponse<T> {
error?: string; error?: string;
} }
interface FetchApiOptions extends RequestInit {
queryParams?: Record<string, string>;
}
async function fetchApi<T>( async function fetchApi<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: FetchApiOptions = {}
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
const token = const token =
typeof window !== "undefined" ? localStorage.getItem("token") : null; typeof window !== "undefined" ? localStorage.getItem("token") : null;
@ -21,8 +25,16 @@ async function fetchApi<T>(
(headers as Record<string, string>)["Authorization"] = `Bearer ${token}`; (headers as Record<string, string>)["Authorization"] = `Bearer ${token}`;
} }
let url = `${API_BASE}${endpoint}`;
// Query-Parameter anhängen
if (options.queryParams) {
const params = new URLSearchParams(options.queryParams);
url += `?${params.toString()}`;
}
try { try {
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(url, {
...options, ...options,
headers, headers,
}); });
@ -43,40 +55,34 @@ async function fetchApi<T>(
// Auth Interfaces // Auth Interfaces
// ============================================================ // ============================================================
export interface User {
id: number;
email: string;
slug: string;
is_admin: boolean;
state: "registered" | "verified" | "active";
last_used?: string | null;
created_at?: string | null;
container_id?: string | null;
}
export interface LoginResponse { export interface LoginResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
expires_in: number; expires_in: number;
user: { user: User;
id: number;
username: string;
email: string;
is_admin: boolean;
state: "registered" | "verified" | "active";
};
} }
export interface SignupResponse { export interface SignupResponse {
message: string; message: string;
user: { }
id: number;
username: string; export interface MagicLinkMessage {
email: string; message: string;
is_admin: boolean;
};
email_sent: boolean;
} }
export interface UserResponse { export interface UserResponse {
user: { user: User;
id: number;
username: string;
email: string;
is_admin: boolean;
state: "registered" | "verified" | "active";
last_used: string | null;
created_at: string | null;
};
container: { container: {
id: string | null; id: string | null;
port: number | null; port: number | null;
@ -100,17 +106,9 @@ export interface ContainerRestartResponse {
// Admin Interfaces // Admin Interfaces
// ============================================================ // ============================================================
export interface AdminUser { export interface AdminUser extends User {
id: number;
username: string;
email: string;
is_admin: boolean;
is_blocked: boolean; is_blocked: boolean;
blocked_at: string | null; blocked_at: string | null;
state: "registered" | "verified" | "active";
last_used: string | null;
created_at: string | null;
container_id: string | null;
} }
export interface AdminUsersResponse { export interface AdminUsersResponse {
@ -140,9 +138,9 @@ export interface TakeoverResponse {
export interface TakeoverSession { export interface TakeoverSession {
id: number; id: number;
admin_id: number; admin_id: number;
admin_username: string | null; admin_email: string | null;
target_user_id: number; target_user_id: number;
target_username: string | null; target_email: string | null;
started_at: string | null; started_at: string | null;
reason: string | null; reason: string | null;
} }
@ -157,32 +155,41 @@ export interface ActiveTakeoversResponse {
// ============================================================ // ============================================================
export const api = { export const api = {
// Auth auth: {
login: (username: string, password: string) => // Magic Link Login
fetchApi<LoginResponse>("/api/auth/login", { login: (email: string) =>
fetchApi<MagicLinkMessage>("/api/auth/login", {
method: "POST", method: "POST",
body: JSON.stringify({ username, password }), body: JSON.stringify({ email }),
}), }),
signup: (username: string, email: string, password: string) => // Magic Link Signup
fetchApi<SignupResponse>("/api/auth/signup", { signup: (email: string) =>
fetchApi<MagicLinkMessage>("/api/auth/signup", {
method: "POST", method: "POST",
body: JSON.stringify({ username, email, password }), body: JSON.stringify({ email }),
}), }),
// Verify Signup Token
verifySignup: (token: string) =>
fetchApi<LoginResponse>("/api/auth/verify-signup", {
method: "GET",
queryParams: { token },
}),
// Verify Login Token
verifyLogin: (token: string) =>
fetchApi<LoginResponse>("/api/auth/verify-login", {
method: "GET",
queryParams: { token },
}),
// Logout
logout: () => logout: () =>
fetchApi<{ message: string }>("/api/auth/logout", { fetchApi<{ message: string }>("/api/auth/logout", {
method: "POST", method: "POST",
}), }),
},
resendVerification: (email: string) =>
fetchApi<{ message: string; email_sent: boolean }>(
"/api/auth/resend-verification",
{
method: "POST",
body: JSON.stringify({ email }),
}
),
// User // User
getUser: () => fetchApi<UserResponse>("/api/user/me"), getUser: () => fetchApi<UserResponse>("/api/user/me"),
@ -219,14 +226,7 @@ export const adminApi = {
method: "POST", method: "POST",
}), }),
// Password Reset // Resend Magic Link (for admins to resend login links)
resetPassword: (id: number, password?: string) =>
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/reset-password`, {
method: "POST",
body: JSON.stringify(password ? { password } : {}),
}),
// Verification
resendVerification: (id: number) => resendVerification: (id: number) =>
fetchApi<AdminActionResponse>(`/api/admin/users/${id}/resend-verification`, { fetchApi<AdminActionResponse>(`/api/admin/users/${id}/resend-verification`, {
method: "POST", method: "POST",

View File

@ -539,10 +539,15 @@ echo "Lokaler Zugriff (ohne Traefik):"
echo " API: http://localhost:${SPAWNER_PORT:-5000}" echo " API: http://localhost:${SPAWNER_PORT:-5000}"
echo " Frontend: http://localhost:3000" echo " Frontend: http://localhost:3000"
echo "" echo ""
echo "Nuetzliche Befehle:" echo "Nützliche Befehle:"
echo " Status: ${COMPOSE_CMD} ps" echo " Status: ${COMPOSE_CMD} ps"
echo " Logs API: ${COMPOSE_CMD} logs -f spawner" echo " Logs API: ${COMPOSE_CMD} logs -f spawner"
echo " Logs FE: ${COMPOSE_CMD} logs -f frontend" echo " Logs FE: ${COMPOSE_CMD} logs -f frontend"
echo " Neustart: ${COMPOSE_CMD} restart" echo " Neustart: ${COMPOSE_CMD} restart"
echo " Stoppen: ${COMPOSE_CMD} down" echo " Stoppen: ${COMPOSE_CMD} down"
echo "" echo ""
echo "WICHTIG - Passwordless Auth:"
echo " Das System nutzt Magic Links (Email-basiert)!"
echo " - SMTP konfigurieren: .env Datei anpassen"
echo " - Datenbank wird automatisch mit allen Tabellen erstellt"
echo ""

View File

@ -1,6 +1,5 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@ -17,9 +16,8 @@ class UserState(Enum):
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(200), nullable=False) slug = db.Column(db.String(12), unique=True, nullable=False, index=True)
container_id = db.Column(db.String(100), nullable=True) container_id = db.Column(db.String(100), nullable=True)
container_port = db.Column(db.Integer, nullable=True) container_port = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
@ -34,8 +32,6 @@ class User(UserMixin, db.Model):
# Email-Verifizierung und Status # Email-Verifizierung und Status
state = db.Column(db.String(20), default=UserState.REGISTERED.value, nullable=False) state = db.Column(db.String(20), default=UserState.REGISTERED.value, nullable=False)
verification_token = db.Column(db.String(64), nullable=True)
verification_sent_at = db.Column(db.DateTime, nullable=True)
# Aktivitaetstracking # Aktivitaetstracking
last_used = db.Column(db.DateTime, nullable=True) last_used = db.Column(db.DateTime, nullable=True)
@ -43,18 +39,12 @@ class User(UserMixin, db.Model):
# Beziehung fuer blocked_by # Beziehung fuer blocked_by
blocker = db.relationship('User', remote_side=[id], foreign_keys=[blocked_by]) blocker = db.relationship('User', remote_side=[id], foreign_keys=[blocked_by])
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def to_dict(self): def to_dict(self):
"""Konvertiert User zu Dictionary fuer API-Responses""" """Konvertiert User zu Dictionary fuer API-Responses"""
return { return {
'id': self.id, 'id': self.id,
'username': self.username,
'email': self.email, 'email': self.email,
'slug': self.slug,
'is_admin': self.is_admin, 'is_admin': self.is_admin,
'is_blocked': self.is_blocked, 'is_blocked': self.is_blocked,
'blocked_at': self.blocked_at.isoformat() if self.blocked_at else None, 'blocked_at': self.blocked_at.isoformat() if self.blocked_at else None,
@ -65,6 +55,34 @@ class User(UserMixin, db.Model):
} }
class MagicLinkToken(db.Model):
"""Magic Link Tokens für Passwordless Authentication"""
__tablename__ = 'magic_link_token'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
token_type = db.Column(db.String(20), nullable=False) # 'signup' oder 'login'
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=False)
used_at = db.Column(db.DateTime, nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
user = db.relationship('User', backref=db.backref('magic_tokens', lazy=True))
def is_valid(self):
"""Prüft ob Token noch gültig ist"""
if self.used_at is not None:
return False # Token bereits verwendet
if datetime.utcnow() > self.expires_at:
return False # Token abgelaufen
return True
def mark_as_used(self):
"""Markiert Token als verwendet"""
self.used_at = datetime.utcnow()
class AdminTakeoverSession(db.Model): class AdminTakeoverSession(db.Model):
"""Protokolliert Admin-Zugriffe auf User-Container (Phase 2)""" """Protokolliert Admin-Zugriffe auf User-Container (Phase 2)"""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)