Dieser Commit ist enthalten in:
2025-06-09 04:38:35 +02:00
Ursprung 888d27442c
Commit fa9d79089a
12 geänderte Dateien mit 1420 neuen und 19 gelöschten Zeilen

Datei anzeigen

@@ -2031,3 +2031,131 @@ Ein Pool-System für Domains, IPv4-Adressen und Telefonnummern, wobei bei jeder
- `/api/version` - Versionscheck - `/api/version` - Versionscheck
- `/api/validate` - Lizenzvalidierung - `/api/validate` - Lizenzvalidierung
- `/api/heartbeat` - Session-Management - `/api/heartbeat` - Session-Management
### 2025-06-09: Resource Pool Internal Error behoben
**Problem:**
- Internal Server Error beim Zugriff auf `/resources`
- NameError: name 'datetime' is not defined in Template
**Ursache:**
- Fehlende `datetime` und `timedelta` Objekte im Template-Kontext
- Falsche Array-Indizes in resources.html für activity-Daten
**Lösung:**
1. **app.py (Zeile 2797-2798):**
- `datetime=datetime` und `timedelta=timedelta` zu render_template hinzugefügt
2. **resources.html (Zeile 484-490):**
- Array-Indizes korrigiert:
- activity[0] = action
- activity[1] = action_by
- activity[2] = action_at
- activity[3] = resource_type
- activity[4] = resource_value
- activity[5] = details
**Geänderte Dateien:**
- `v2_adminpanel/app.py`
- `v2_adminpanel/templates/resources.html`
**Status:** ✅ Behoben - Resource Pool funktioniert wieder einwandfrei
### 2025-06-09: Passwort-Änderung und 2FA implementiert
**Ziel:**
- Benutzer können ihr Passwort ändern
- Zwei-Faktor-Authentifizierung (2FA) mit TOTP
- Komplett kostenlose Lösung ohne externe Services
**Implementierte Features:**
1. **Datenbank-Erweiterung:**
- Neue `users` Tabelle mit Passwort-Hash und 2FA-Feldern
- Unterstützung für TOTP-Secrets und Backup-Codes
- Migration von Environment-Variablen zu Datenbank
2. **Passwort-Management:**
- Sichere Passwort-Hashes mit bcrypt
- Passwort-Änderung mit Verifikation des alten Passworts
- Passwort-Stärke-Indikator im Frontend
3. **2FA-Implementation:**
- TOTP-basierte 2FA (Google Authenticator, Authy kompatibel)
- QR-Code-Generierung für einfaches Setup
- 8 Backup-Codes für Notfallzugriff
- Backup-Codes als Textdatei downloadbar
4. **Neue Routen:**
- `/profile` - Benutzerprofil mit Passwort und 2FA-Verwaltung
- `/verify-2fa` - 2FA-Verifizierung beim Login
- `/profile/setup-2fa` - 2FA-Einrichtung mit QR-Code
- `/profile/enable-2fa` - 2FA-Aktivierung
- `/profile/disable-2fa` - 2FA-Deaktivierung
- `/profile/change-password` - Passwort ändern
5. **Sicherheits-Features:**
- Fallback zu Environment-Variablen für Rückwärtskompatibilität
- Session-Management für 2FA-Verifizierung
- Fehlgeschlagene 2FA-Versuche werden protokolliert
- Verwendete Backup-Codes werden entfernt
**Verwendete Libraries (alle kostenlos):**
- `bcrypt` - Passwort-Hashing
- `pyotp` - TOTP-Generierung und Verifizierung
- `qrcode[pil]` - QR-Code-Generierung
**Migration:**
- Script `migrate_users.py` erstellt für Migration existierender Benutzer
- Erhält bestehende Credentials aus Environment-Variablen
- Erstellt Datenbank-Einträge mit gehashten Passwörtern
**Geänderte Dateien:**
- `v2_adminpanel/init.sql` - Users-Tabelle hinzugefügt
- `v2_adminpanel/requirements.txt` - Neue Dependencies
- `v2_adminpanel/app.py` - Auth-Funktionen und neue Routen
- `v2_adminpanel/migrate_users.py` - Migrations-Script (neu)
- `v2_adminpanel/templates/base.html` - Profil-Link hinzugefügt
- `v2_adminpanel/templates/profile.html` - Profil-Seite (neu)
- `v2_adminpanel/templates/verify_2fa.html` - 2FA-Verifizierung (neu)
- `v2_adminpanel/templates/setup_2fa.html` - 2FA-Setup (neu)
- `v2_adminpanel/templates/backup_codes.html` - Backup-Codes Anzeige (neu)
**Status:** ✅ Vollständig implementiert
### 2025-06-09: Internal Server Error behoben und UI-Design angepasst
**Problem:**
- Internal Server Error nach Login wegen fehlender `users` Tabelle
- UI-Design der neuen 2FA-Seiten passte nicht zum Rest der Anwendung
**Lösung:**
1. **Datenbank-Fix:**
- Users-Tabelle wurde nicht automatisch erstellt
- Manuell mit SQL-Script nachgeholt
- Migration erfolgreich durchgeführt
- Beide Admin-User (rac00n, w@rh@mm3r) migriert
2. **UI-Design Überarbeitung:**
- Profile-Seite im Dashboard-Stil mit Cards und Hover-Effekten
- 2FA-Setup mit nummerierten Schritten und modernem Card-Design
- Backup-Codes Seite mit Animation und verbessertem Layout
- Konsistente Farbgebung und Icons
- Verbesserte Benutzerführung mit visuellen Hinweisen
**Design-Features:**
- Card-basiertes Layout mit Schatten-Effekten
- Hover-Animationen für bessere Interaktivität
- Farbcodierte Sicherheitsstatus-Anzeigen
- Passwort-Stärke-Indikator mit visueller Rückmeldung
- Responsive Design für alle Bildschirmgrößen
- Print-optimiertes Layout für Backup-Codes
**Geänderte Dateien:**
- `v2_adminpanel/create_users_table.sql` - SQL für Users-Tabelle (temporär)
- `v2_adminpanel/templates/profile.html` - Komplett überarbeitet
- `v2_adminpanel/templates/setup_2fa.html` - Neues Step-by-Step Design
- `v2_adminpanel/templates/backup_codes.html` - Modernisiertes Layout
**Status:** ✅ Abgeschlossen - Login funktioniert, UI im konsistenten Design

Datei anzeigen

@@ -0,0 +1,43 @@
# Migration zu Passwort-Änderung und 2FA
## Übersicht
Das Admin Panel unterstützt jetzt Passwort-Änderungen und Zwei-Faktor-Authentifizierung (2FA). Um diese Features zu nutzen, müssen bestehende Benutzer migriert werden.
## Migration durchführen
1. **Container neu bauen** (für neue Dependencies):
```bash
docker-compose down
docker-compose build adminpanel
docker-compose up -d
```
2. **Migration ausführen**:
```bash
docker exec -it v2_adminpanel python migrate_users.py
```
Dies erstellt Datenbankeinträge für die in der .env konfigurierten Admin-Benutzer.
## Nach der Migration
### Passwort ändern
1. Einloggen mit bisherigem Passwort
2. Klick auf "👤 Profil" in der Navigation
3. Neues Passwort eingeben (min. 8 Zeichen)
### 2FA aktivieren
1. Im Profil auf "2FA einrichten" klicken
2. QR-Code mit Google Authenticator oder Authy scannen
3. 6-stelligen Code eingeben
4. Backup-Codes sicher aufbewahren!
## Wichtige Hinweise
- Backup-Codes unbedingt speichern (Drucker, USB-Stick, etc.)
- Jeder Backup-Code kann nur einmal verwendet werden
- Bei Verlust des 2FA-Geräts können nur Backup-Codes helfen
## Rückwärtskompatibilität
- Benutzer aus .env funktionieren weiterhin
- Diese haben aber keinen Zugriff auf Profil-Features
- Migration ist erforderlich für neue Features

Datei anzeigen

@@ -22,6 +22,12 @@ import requests
import secrets import secrets
import string import string
import re import re
import bcrypt
import pyotp
import qrcode
from io import BytesIO
import base64
import json
load_dotenv() load_dotenv()
@@ -111,6 +117,87 @@ def get_connection():
conn.set_client_encoding('UTF8') conn.set_client_encoding('UTF8')
return conn return conn
# User Authentication Helper Functions
def hash_password(password):
"""Hash a password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify_password(password, hashed):
"""Verify a password against its hash"""
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
def get_user_by_username(username):
"""Get user from database by username"""
conn = get_connection()
cur = conn.cursor()
try:
cur.execute("""
SELECT id, username, password_hash, email, totp_secret, totp_enabled,
backup_codes, last_password_change, failed_2fa_attempts
FROM users WHERE username = %s
""", (username,))
user = cur.fetchone()
if user:
return {
'id': user[0],
'username': user[1],
'password_hash': user[2],
'email': user[3],
'totp_secret': user[4],
'totp_enabled': user[5],
'backup_codes': user[6],
'last_password_change': user[7],
'failed_2fa_attempts': user[8]
}
return None
finally:
cur.close()
conn.close()
def generate_totp_secret():
"""Generate a new TOTP secret"""
return pyotp.random_base32()
def generate_qr_code(username, totp_secret):
"""Generate QR code for TOTP setup"""
totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
name=username,
issuer_name='V2 Admin Panel'
)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(totp_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return base64.b64encode(buf.getvalue()).decode()
def verify_totp(totp_secret, token):
"""Verify a TOTP token"""
totp = pyotp.TOTP(totp_secret)
return totp.verify(token, valid_window=1)
def generate_backup_codes(count=8):
"""Generate backup codes for 2FA recovery"""
codes = []
for _ in range(count):
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
codes.append(code)
return codes
def hash_backup_code(code):
"""Hash a backup code for storage"""
return hashlib.sha256(code.encode()).hexdigest()
def verify_backup_code(code, hashed_codes):
"""Verify a backup code against stored hashes"""
code_hash = hashlib.sha256(code.encode()).hexdigest()
return code_hash in hashed_codes
# Audit-Log-Funktion # Audit-Log-Funktion
def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None):
"""Protokolliert Änderungen im Audit-Log""" """Protokolliert Änderungen im Audit-Log"""
@@ -647,17 +734,26 @@ def login():
attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
recaptcha_site_key=recaptcha_site_key) recaptcha_site_key=recaptcha_site_key)
# Check gegen beide Admin-Accounts aus .env # Check user in database first, fallback to env vars
admin1_user = os.getenv("ADMIN1_USERNAME") user = get_user_by_username(username)
admin1_pass = os.getenv("ADMIN1_PASSWORD")
admin2_user = os.getenv("ADMIN2_USERNAME")
admin2_pass = os.getenv("ADMIN2_PASSWORD")
# Login-Prüfung
login_success = False login_success = False
if ((username == admin1_user and password == admin1_pass) or needs_2fa = False
(username == admin2_user and password == admin2_pass)):
login_success = True if user:
# Database user authentication
if verify_password(password, user['password_hash']):
login_success = True
needs_2fa = user['totp_enabled']
else:
# Fallback to environment variables for backward compatibility
admin1_user = os.getenv("ADMIN1_USERNAME")
admin1_pass = os.getenv("ADMIN1_PASSWORD")
admin2_user = os.getenv("ADMIN2_USERNAME")
admin2_pass = os.getenv("ADMIN2_PASSWORD")
if ((username == admin1_user and password == admin1_pass) or
(username == admin2_user and password == admin2_pass)):
login_success = True
# Timing-Attack Schutz - Mindestens 1 Sekunde warten # Timing-Attack Schutz - Mindestens 1 Sekunde warten
elapsed = time.time() - start_time elapsed = time.time() - start_time
@@ -666,14 +762,23 @@ def login():
if login_success: if login_success:
# Erfolgreicher Login # Erfolgreicher Login
session.permanent = True # Aktiviert das Timeout if needs_2fa:
session['logged_in'] = True # Store temporary session for 2FA verification
session['username'] = username session['temp_username'] = username
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() session['temp_user_id'] = user['id']
reset_login_attempts(ip_address) session['awaiting_2fa'] = True
log_audit('LOGIN_SUCCESS', 'user', return redirect(url_for('verify_2fa'))
additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") else:
return redirect(url_for('dashboard')) # Complete login without 2FA
session.permanent = True # Aktiviert das Timeout
session['logged_in'] = True
session['username'] = username
session['user_id'] = user['id'] if user else None
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
reset_login_attempts(ip_address)
log_audit('LOGIN_SUCCESS', 'user',
additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
return redirect(url_for('dashboard'))
else: else:
# Fehlgeschlagener Login # Fehlgeschlagener Login
error_message = record_failed_attempt(ip_address, username) error_message = record_failed_attempt(ip_address, username)
@@ -704,8 +809,224 @@ def logout():
log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
session.pop('logged_in', None) session.pop('logged_in', None)
session.pop('username', None) session.pop('username', None)
session.pop('user_id', None)
session.pop('temp_username', None)
session.pop('temp_user_id', None)
session.pop('awaiting_2fa', None)
return redirect(url_for('login')) return redirect(url_for('login'))
@app.route("/verify-2fa", methods=["GET", "POST"])
def verify_2fa():
if not session.get('awaiting_2fa'):
return redirect(url_for('login'))
if request.method == "POST":
token = request.form.get('token', '').replace(' ', '')
username = session.get('temp_username')
user_id = session.get('temp_user_id')
if not username or not user_id:
flash('Session expired. Please login again.', 'error')
return redirect(url_for('login'))
user = get_user_by_username(username)
if not user:
flash('User not found.', 'error')
return redirect(url_for('login'))
# Check if it's a backup code
if len(token) == 8 and token.isupper():
# Try backup code
backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
if verify_backup_code(token, backup_codes):
# Remove used backup code
code_hash = hash_backup_code(token)
backup_codes.remove(code_hash)
conn = get_connection()
cur = conn.cursor()
cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
(json.dumps(backup_codes), user_id))
conn.commit()
cur.close()
conn.close()
# Complete login
session.permanent = True
session['logged_in'] = True
session['username'] = username
session['user_id'] = user_id
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
session.pop('temp_username', None)
session.pop('temp_user_id', None)
session.pop('awaiting_2fa', None)
flash('Login successful using backup code. Please generate new backup codes.', 'warning')
log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
return redirect(url_for('dashboard'))
else:
# Try TOTP token
if verify_totp(user['totp_secret'], token):
# Complete login
session.permanent = True
session['logged_in'] = True
session['username'] = username
session['user_id'] = user_id
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
session.pop('temp_username', None)
session.pop('temp_user_id', None)
session.pop('awaiting_2fa', None)
log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
return redirect(url_for('dashboard'))
# Failed verification
conn = get_connection()
cur = conn.cursor()
cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
(datetime.now(), user_id))
conn.commit()
cur.close()
conn.close()
flash('Invalid authentication code. Please try again.', 'error')
log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
return render_template('verify_2fa.html')
@app.route("/profile")
@login_required
def profile():
user = get_user_by_username(session['username'])
if not user:
# For environment-based users, redirect with message
flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info')
return redirect(url_for('dashboard'))
return render_template('profile.html', user=user)
@app.route("/profile/change-password", methods=["POST"])
@login_required
def change_password():
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
user = get_user_by_username(session['username'])
# Verify current password
if not verify_password(current_password, user['password_hash']):
flash('Current password is incorrect.', 'error')
return redirect(url_for('profile'))
# Check new password
if new_password != confirm_password:
flash('New passwords do not match.', 'error')
return redirect(url_for('profile'))
if len(new_password) < 8:
flash('Password must be at least 8 characters long.', 'error')
return redirect(url_for('profile'))
# Update password
new_hash = hash_password(new_password)
conn = get_connection()
cur = conn.cursor()
cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s",
(new_hash, datetime.now(), user['id']))
conn.commit()
cur.close()
conn.close()
log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'],
additional_info="Password changed successfully")
flash('Password changed successfully.', 'success')
return redirect(url_for('profile'))
@app.route("/profile/setup-2fa")
@login_required
def setup_2fa():
user = get_user_by_username(session['username'])
if user['totp_enabled']:
flash('2FA is already enabled for your account.', 'info')
return redirect(url_for('profile'))
# Generate new TOTP secret
totp_secret = generate_totp_secret()
session['temp_totp_secret'] = totp_secret
# Generate QR code
qr_code = generate_qr_code(user['username'], totp_secret)
return render_template('setup_2fa.html',
totp_secret=totp_secret,
qr_code=qr_code)
@app.route("/profile/enable-2fa", methods=["POST"])
@login_required
def enable_2fa():
token = request.form.get('token', '').replace(' ', '')
totp_secret = session.get('temp_totp_secret')
if not totp_secret:
flash('2FA setup session expired. Please try again.', 'error')
return redirect(url_for('setup_2fa'))
# Verify the token
if not verify_totp(totp_secret, token):
flash('Invalid authentication code. Please try again.', 'error')
return redirect(url_for('setup_2fa'))
# Generate backup codes
backup_codes = generate_backup_codes()
hashed_codes = [hash_backup_code(code) for code in backup_codes]
# Enable 2FA
conn = get_connection()
cur = conn.cursor()
cur.execute("""
UPDATE users
SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s
WHERE username = %s
""", (totp_secret, json.dumps(hashed_codes), session['username']))
conn.commit()
cur.close()
conn.close()
session.pop('temp_totp_secret', None)
log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully")
# Show backup codes
return render_template('backup_codes.html', backup_codes=backup_codes)
@app.route("/profile/disable-2fa", methods=["POST"])
@login_required
def disable_2fa():
password = request.form.get('password')
user = get_user_by_username(session['username'])
# Verify password
if not verify_password(password, user['password_hash']):
flash('Incorrect password.', 'error')
return redirect(url_for('profile'))
# Disable 2FA
conn = get_connection()
cur = conn.cursor()
cur.execute("""
UPDATE users
SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL
WHERE username = %s
""", (session['username'],))
conn.commit()
cur.close()
conn.close()
log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled")
flash('2FA has been disabled for your account.', 'success')
return redirect(url_for('profile'))
@app.route("/heartbeat", methods=['POST']) @app.route("/heartbeat", methods=['POST'])
@login_required @login_required
def heartbeat(): def heartbeat():

Datei anzeigen

@@ -0,0 +1,20 @@
-- Create users table if it doesn't exist
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100),
totp_secret VARCHAR(32),
totp_enabled BOOLEAN DEFAULT FALSE,
backup_codes TEXT, -- JSON array of hashed backup codes
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
password_reset_token VARCHAR(64),
password_reset_expires TIMESTAMP WITH TIME ZONE,
failed_2fa_attempts INTEGER DEFAULT 0,
last_failed_2fa TIMESTAMP WITH TIME ZONE
);
-- Index for faster login lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;

Datei anzeigen

@@ -179,3 +179,24 @@ CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_
CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id); CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC); CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC);
CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE; CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE;
-- Users table for authentication with password and 2FA support
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100),
totp_secret VARCHAR(32),
totp_enabled BOOLEAN DEFAULT FALSE,
backup_codes TEXT, -- JSON array of hashed backup codes
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
password_reset_token VARCHAR(64),
password_reset_expires TIMESTAMP WITH TIME ZONE,
failed_2fa_attempts INTEGER DEFAULT 0,
last_failed_2fa TIMESTAMP WITH TIME ZONE
);
-- Index for faster login lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;

Datei anzeigen

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
Migration script to create initial users in the database from environment variables
Run this once after creating the users table
"""
import os
import psycopg2
import bcrypt
from dotenv import load_dotenv
from datetime import datetime
load_dotenv()
def get_connection():
return psycopg2.connect(
host=os.getenv("POSTGRES_HOST", "postgres"),
port=os.getenv("POSTGRES_PORT", "5432"),
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD"),
options='-c client_encoding=UTF8'
)
def hash_password(password):
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def migrate_users():
conn = get_connection()
cur = conn.cursor()
try:
# Check if users already exist
cur.execute("SELECT COUNT(*) FROM users")
user_count = cur.fetchone()[0]
if user_count > 0:
print(f"Users table already contains {user_count} users. Skipping migration.")
return
# Get admin users from environment
admin1_user = os.getenv("ADMIN1_USERNAME")
admin1_pass = os.getenv("ADMIN1_PASSWORD")
admin2_user = os.getenv("ADMIN2_USERNAME")
admin2_pass = os.getenv("ADMIN2_PASSWORD")
if not all([admin1_user, admin1_pass, admin2_user, admin2_pass]):
print("ERROR: Admin credentials not found in environment variables!")
return
# Insert admin users
users = [
(admin1_user, hash_password(admin1_pass), f"{admin1_user}@v2-admin.local"),
(admin2_user, hash_password(admin2_pass), f"{admin2_user}@v2-admin.local")
]
for username, password_hash, email in users:
cur.execute("""
INSERT INTO users (username, password_hash, email, totp_enabled, created_at)
VALUES (%s, %s, %s, %s, %s)
""", (username, password_hash, email, False, datetime.now()))
print(f"Created user: {username}")
conn.commit()
print("\nMigration completed successfully!")
print("Users can now log in with their existing credentials.")
print("They can enable 2FA from their profile page.")
except Exception as e:
conn.rollback()
print(f"ERROR during migration: {e}")
finally:
cur.close()
conn.close()
if __name__ == "__main__":
print("Starting user migration...")
migrate_users()

Datei anzeigen

@@ -9,3 +9,6 @@ cryptography
apscheduler apscheduler
requests requests
python-dateutil python-dateutil
bcrypt
pyotp
qrcode[pil]

Datei anzeigen

@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block title %}Backup-Codes{% endblock %}
{% block extra_css %}
<style>
.success-icon {
font-size: 5rem;
animation: bounce 0.5s ease-in-out;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px); }
}
.backup-code {
font-family: 'Courier New', monospace;
font-size: 1.3rem;
font-weight: bold;
letter-spacing: 2px;
padding: 8px 15px;
background-color: #f8f9fa;
border: 2px dashed #dee2e6;
border-radius: 5px;
display: inline-block;
margin: 5px;
}
.backup-codes-container {
background-color: #fff;
border: 2px solid #dee2e6;
border-radius: 10px;
padding: 30px;
margin: 20px 0;
}
.action-buttons .btn {
margin: 5px;
}
@media print {
.no-print {
display: none !important;
}
.backup-codes-container {
border: 1px solid #000;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<!-- Success Message -->
<div class="text-center mb-4">
<div class="success-icon text-success"></div>
<h1 class="mt-3">2FA erfolgreich aktiviert!</h1>
<p class="lead text-muted">Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.</p>
</div>
<!-- Backup Codes Card -->
<div class="card shadow">
<div class="card-header bg-warning text-dark">
<h4 class="mb-0">
<span style="font-size: 1.5rem; vertical-align: middle;">⚠️</span>
Wichtig: Ihre Backup-Codes
</h4>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<strong>Was sind Backup-Codes?</strong><br>
Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben.
<strong>Jeder Code kann nur einmal verwendet werden.</strong>
</div>
<!-- Backup Codes Display -->
<div class="backup-codes-container text-center">
<h5 class="mb-4">Ihre 8 Backup-Codes:</h5>
<div class="row justify-content-center">
{% for code in backup_codes %}
<div class="col-md-6 col-lg-4 mb-3">
<div class="backup-code">{{ code }}</div>
</div>
{% endfor %}
</div>
</div>
<!-- Action Buttons -->
<div class="text-center action-buttons no-print">
<button type="button" class="btn btn-primary btn-lg" onclick="downloadCodes()">
💾 Als Datei speichern
</button>
<button type="button" class="btn btn-secondary btn-lg" onclick="printCodes()">
🖨️ Drucken
</button>
<button type="button" class="btn btn-info btn-lg" onclick="copyCodes()">
📋 Alle kopieren
</button>
</div>
<hr class="my-4">
<!-- Security Tips -->
<div class="row">
<div class="col-md-6">
<div class="alert alert-danger">
<h6>❌ Nicht empfohlen:</h6>
<ul class="mb-0 small">
<li>Im selben Passwort-Manager wie Ihr Passwort</li>
<li>Als Foto auf Ihrem Handy</li>
<li>In einer unverschlüsselten Datei</li>
<li>Per E-Mail an sich selbst</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-success">
<h6>✅ Empfohlen:</h6>
<ul class="mb-0 small">
<li>Ausgedruckt in einem Safe</li>
<li>In einem separaten Passwort-Manager</li>
<li>Verschlüsselt auf einem USB-Stick</li>
<li>An einem sicheren Ort zu Hause</li>
</ul>
</div>
</div>
</div>
<!-- Confirmation -->
<div class="text-center mt-4 no-print">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="confirmSaved" onchange="checkConfirmation()">
<label class="form-check-label" for="confirmSaved">
Ich habe die Backup-Codes sicher gespeichert
</label>
</div>
<a href="{{ url_for('profile') }}" class="btn btn-lg btn-success" id="continueBtn" style="display: none;">
✅ Weiter zum Profil
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const backupCodes = {{ backup_codes | tojson }};
function checkConfirmation() {
const checkbox = document.getElementById('confirmSaved');
const continueBtn = document.getElementById('continueBtn');
continueBtn.style.display = checkbox.checked ? 'inline-block' : 'none';
}
function downloadCodes() {
const content = `V2 Admin Panel - Backup Codes
=====================================
Generiert am: ${new Date().toLocaleString('de-DE')}
WICHTIG: Bewahren Sie diese Codes sicher auf!
Jeder Code kann nur einmal verwendet werden.
Ihre 8 Backup-Codes:
--------------------
${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
Sicherheitshinweise:
-------------------
✓ Bewahren Sie diese Codes getrennt von Ihrem Passwort auf
✓ Speichern Sie sie an einem sicheren physischen Ort
✓ Teilen Sie diese Codes niemals mit anderen
✓ Jeder Code funktioniert nur einmal
Bei Verlust Ihrer Authenticator-App:
------------------------------------
1. Gehen Sie zur Login-Seite
2. Geben Sie Benutzername und Passwort ein
3. Klicken Sie auf "Backup-Code verwenden"
4. Geben Sie einen dieser Codes ein
Nach Verwendung eines Codes sollten Sie neue Backup-Codes generieren.`;
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `v2-backup-codes-${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Visual feedback
const btn = event.target;
const originalText = btn.innerHTML;
btn.innerHTML = '✅ Heruntergeladen!';
btn.classList.remove('btn-primary');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-primary');
}, 2000);
}
function printCodes() {
window.print();
}
function copyCodes() {
const codesText = backupCodes.join('\n');
navigator.clipboard.writeText(codesText).then(function() {
// Visual feedback
const btn = event.target;
const originalText = btn.innerHTML;
btn.innerHTML = '✅ Kopiert!';
btn.classList.remove('btn-info');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-info');
}, 2000);
}).catch(function(err) {
alert('Fehler beim Kopieren. Bitte manuell kopieren.');
});
}
</script>
{% endblock %}

Datei anzeigen

@@ -235,6 +235,7 @@
⏱️ <span id="timer-display">5:00</span> ⏱️ <span id="timer-display">5:00</span>
</div> </div>
<span class="text-white me-3">Angemeldet als: {{ username }}</span> <span class="text-white me-3">Angemeldet als: {{ username }}</span>
<a href="/profile" class="btn btn-outline-light btn-sm me-2">👤 Profil</a>
<a href="/logout" class="btn btn-outline-light btn-sm">Abmelden</a> <a href="/logout" class="btn btn-outline-light btn-sm">Abmelden</a>
</div> </div>
</div> </div>

Datei anzeigen

@@ -0,0 +1,217 @@
{% extends "base.html" %}
{% block title %}Benutzerprofil{% endblock %}
{% block extra_css %}
<style>
.profile-card {
transition: all 0.3s ease;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.profile-card:hover {
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.profile-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.8;
}
.security-badge {
font-size: 2rem;
margin-right: 1rem;
}
.form-control:focus {
border-color: #6c757d;
box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
}
.password-strength {
height: 4px;
margin-top: 5px;
border-radius: 2px;
transition: all 0.3s ease;
}
.strength-very-weak { background-color: #dc3545; width: 20%; }
.strength-weak { background-color: #fd7e14; width: 40%; }
.strength-medium { background-color: #ffc107; width: 60%; }
.strength-strong { background-color: #28a745; width: 80%; }
.strength-very-strong { background-color: #0d6efd; width: 100%; }
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>👤 Benutzerprofil</h1>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">← Zurück zum Dashboard</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- User Info Stats -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card profile-card h-100">
<div class="card-body text-center">
<div class="profile-icon text-primary">👤</div>
<h5 class="card-title">{{ user.username }}</h5>
<p class="text-muted mb-0">{{ user.email or 'Keine E-Mail angegeben' }}</p>
<small class="text-muted">Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }}</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card profile-card h-100">
<div class="card-body text-center">
<div class="profile-icon text-info">🔐</div>
<h5 class="card-title">Sicherheitsstatus</h5>
{% if user.totp_enabled %}
<span class="badge bg-success">2FA Aktiv</span>
{% else %}
<span class="badge bg-warning text-dark">2FA Inaktiv</span>
{% endif %}
<p class="text-muted mb-0 mt-2">
<small>Letztes Passwort-Update:<br>{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}</small>
</p>
</div>
</div>
</div>
</div>
<!-- Password Change Card -->
<div class="card profile-card mb-4">
<div class="card-body">
<h5 class="card-title d-flex align-items-center">
<span class="security-badge">🔑</span>
Passwort ändern
</h5>
<hr>
<form method="POST" action="{{ url_for('change_password') }}">
<div class="mb-3">
<label for="current_password" class="form-label">Aktuelles Passwort</label>
<input type="password" class="form-control" id="current_password" name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">Neues Passwort</label>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
<div class="password-strength" id="password-strength"></div>
<div class="form-text" id="password-help">Mindestens 8 Zeichen</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Neues Passwort bestätigen</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
<div class="invalid-feedback">Passwörter stimmen nicht überein</div>
</div>
<button type="submit" class="btn btn-primary">🔄 Passwort ändern</button>
</form>
</div>
</div>
<!-- 2FA Card -->
<div class="card profile-card mb-4">
<div class="card-body">
<h5 class="card-title d-flex align-items-center">
<span class="security-badge">🔐</span>
Zwei-Faktor-Authentifizierung (2FA)
</h5>
<hr>
{% if user.totp_enabled %}
<div class="d-flex align-items-center mb-3">
<div class="flex-grow-1">
<h6 class="mb-1">Status: <span class="badge bg-success">Aktiv</span></h6>
<p class="text-muted mb-0">Ihr Account ist durch 2FA geschützt</p>
</div>
<div class="text-success" style="font-size: 3rem;"></div>
</div>
<form method="POST" action="{{ url_for('disable_2fa') }}" onsubmit="return confirm('Sind Sie sicher, dass Sie 2FA deaktivieren möchten? Dies verringert die Sicherheit Ihres Accounts.');">
<div class="mb-3">
<label for="password" class="form-label">Passwort zur Bestätigung</label>
<input type="password" class="form-control" id="password" name="password" required placeholder="Ihr aktuelles Passwort">
</div>
<button type="submit" class="btn btn-danger">🚫 2FA deaktivieren</button>
</form>
{% else %}
<div class="d-flex align-items-center mb-3">
<div class="flex-grow-1">
<h6 class="mb-1">Status: <span class="badge bg-warning text-dark">Inaktiv</span></h6>
<p class="text-muted mb-0">Aktivieren Sie 2FA für zusätzliche Sicherheit</p>
</div>
<div class="text-warning" style="font-size: 3rem;">⚠️</div>
</div>
<p class="text-muted">
Mit 2FA wird bei jeder Anmeldung zusätzlich ein Code aus Ihrer Authenticator-App benötigt.
Dies schützt Ihren Account auch bei kompromittiertem Passwort.
</p>
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">✨ 2FA einrichten</a>
{% endif %}
</div>
</div>
</div>
<script>
// Password strength indicator
document.getElementById('new_password').addEventListener('input', function(e) {
const password = e.target.value;
let strength = 0;
if (password.length >= 8) strength++;
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++;
if (password.match(/[0-9]/)) strength++;
if (password.match(/[^a-zA-Z0-9]/)) strength++;
const strengthText = ['Sehr schwach', 'Schwach', 'Mittel', 'Stark', 'Sehr stark'];
const strengthClass = ['strength-very-weak', 'strength-weak', 'strength-medium', 'strength-strong', 'strength-very-strong'];
const textClass = ['text-danger', 'text-warning', 'text-warning', 'text-success', 'text-primary'];
const strengthBar = document.getElementById('password-strength');
const helpText = document.getElementById('password-help');
if (password.length > 0) {
strengthBar.className = `password-strength ${strengthClass[strength]}`;
strengthBar.style.display = 'block';
helpText.textContent = `Stärke: ${strengthText[strength]}`;
helpText.className = `form-text ${textClass[strength]}`;
} else {
strengthBar.style.display = 'none';
helpText.textContent = 'Mindestens 8 Zeichen';
helpText.className = 'form-text';
}
});
// Confirm password validation
document.getElementById('confirm_password').addEventListener('input', function(e) {
const password = document.getElementById('new_password').value;
const confirm = e.target.value;
if (confirm.length > 0) {
if (password !== confirm) {
e.target.classList.add('is-invalid');
e.target.setCustomValidity('Passwörter stimmen nicht überein');
} else {
e.target.classList.remove('is-invalid');
e.target.classList.add('is-valid');
e.target.setCustomValidity('');
}
} else {
e.target.classList.remove('is-invalid', 'is-valid');
}
});
// Also check when password field changes
document.getElementById('new_password').addEventListener('input', function(e) {
const confirm = document.getElementById('confirm_password');
if (confirm.value.length > 0) {
confirm.dispatchEvent(new Event('input'));
}
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,210 @@
{% extends "base.html" %}
{% block title %}2FA Einrichten{% endblock %}
{% block extra_css %}
<style>
.setup-card {
transition: all 0.3s ease;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.setup-card:hover {
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 35px;
height: 35px;
background-color: #0d6efd;
color: white;
border-radius: 50%;
font-weight: bold;
margin-right: 10px;
}
.app-icon {
width: 40px;
height: 40px;
object-fit: contain;
margin-right: 10px;
}
.qr-container {
background: white;
padding: 20px;
border-radius: 10px;
display: inline-block;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.secret-code {
font-family: monospace;
font-size: 1.2rem;
letter-spacing: 2px;
background-color: #f8f9fa;
padding: 10px 15px;
border-radius: 5px;
word-break: break-all;
}
.code-input {
font-size: 2rem;
letter-spacing: 0.5rem;
text-align: center;
font-family: monospace;
font-weight: bold;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>🔐 2FA einrichten</h1>
<a href="{{ url_for('profile') }}" class="btn btn-secondary">← Zurück zum Profil</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Step 1: Install App -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">1</span>
Authenticator-App installieren
</h5>
<p class="ms-5">Wählen Sie eine der folgenden Apps für Ihr Smartphone:</p>
<div class="row ms-4">
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">📱</span>
<div>
<strong>Google Authenticator</strong><br>
<small class="text-muted">Android / iOS</small>
</div>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">🔷</span>
<div>
<strong>Microsoft Authenticator</strong><br>
<small class="text-muted">Android / iOS</small>
</div>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">🔴</span>
<div>
<strong>Authy</strong><br>
<small class="text-muted">Android / iOS / Desktop</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Step 2: Scan QR Code -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">2</span>
QR-Code scannen oder Code eingeben
</h5>
<div class="row mt-4">
<div class="col-md-6 text-center mb-4">
<p class="fw-bold">Option A: QR-Code scannen</p>
<div class="qr-container">
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" style="max-width: 250px;">
</div>
<p class="text-muted mt-2">
<small>Öffnen Sie Ihre Authenticator-App und scannen Sie diesen Code</small>
</p>
</div>
<div class="col-md-6 mb-4">
<p class="fw-bold">Option B: Code manuell eingeben</p>
<div class="mb-3">
<label class="text-muted small">Account-Name:</label>
<div class="alert alert-light py-2">
<strong>V2 Admin Panel</strong>
</div>
</div>
<div class="mb-3">
<label class="text-muted small">Geheimer Schlüssel:</label>
<div class="secret-code">{{ totp_secret }}</div>
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="copySecret()">
📋 Schlüssel kopieren
</button>
</div>
<div class="alert alert-warning">
<small>
<strong>⚠️ Wichtiger Hinweis:</strong><br>
Speichern Sie diesen Code sicher. Er ist Ihre einzige Möglichkeit,
2FA auf einem neuen Gerät einzurichten.
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Step 3: Verify -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">3</span>
Code verifizieren
</h5>
<p class="ms-5">Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:</p>
<form method="POST" action="{{ url_for('enable_2fa') }}" class="ms-5 me-5">
<div class="row align-items-center">
<div class="col-md-6 mb-3">
<input type="text"
class="form-control code-input"
id="token"
name="token"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
autocomplete="off"
autofocus
required>
<div class="form-text text-center">Der Code ändert sich alle 30 Sekunden</div>
</div>
<div class="col-md-6 mb-3">
<button type="submit" class="btn btn-success btn-lg w-100">
✅ 2FA aktivieren
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
function copySecret() {
const secret = '{{ totp_secret }}';
navigator.clipboard.writeText(secret).then(function() {
alert('Code wurde in die Zwischenablage kopiert!');
});
}
// Auto-format the code input
document.getElementById('token').addEventListener('input', function(e) {
// Remove non-digits
e.target.value = e.target.value.replace(/\D/g, '');
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2FA Verifizierung - Admin Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
font-size: 3rem;
margin-bottom: 1rem;
}
.code-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.5rem;
font-family: monospace;
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">🔐</div>
<h2 class="text-center mb-4">Zwei-Faktor-Authentifizierung</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<div class="mb-3">
<label for="token" class="form-label">Authentifizierungscode eingeben</label>
<input type="text"
class="form-control code-input"
id="token"
name="token"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
autocomplete="off"
autofocus
required>
<div class="form-text text-center mt-2">
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">Verifizieren</button>
</div>
<div class="text-center mt-3">
<details>
<summary class="text-muted" style="cursor: pointer;">Backup-Code verwenden</summary>
<div class="mt-2">
<p class="small text-muted">
Falls Sie keinen Zugriff auf Ihre Authenticator-App haben,
können Sie einen 8-stelligen Backup-Code eingeben.
</p>
<input type="text"
class="form-control code-input mt-2"
id="backup-token"
placeholder="ABCD1234"
maxlength="8"
pattern="[A-Z0-9]{8}"
style="letter-spacing: 0.25rem;">
</div>
</details>
</div>
<hr class="my-4">
<div class="text-center">
<a href="/logout" class="text-muted">Abbrechen und zur Anmeldung zurück</a>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Auto-format the code input
document.getElementById('token').addEventListener('input', function(e) {
// Remove non-digits
e.target.value = e.target.value.replace(/\D/g, '');
});
// Handle backup code input
document.getElementById('backup-token').addEventListener('input', function(e) {
// Convert to uppercase and remove non-alphanumeric
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
// If backup code is being used, copy to main token field
if (e.target.value.length > 0) {
document.getElementById('token').value = e.target.value;
document.getElementById('token').removeAttribute('pattern');
document.getElementById('token').setAttribute('maxlength', '8');
}
});
// Reset main field when typing in it
document.getElementById('token').addEventListener('focus', function(e) {
document.getElementById('backup-token').value = '';
e.target.setAttribute('pattern', '[0-9]{6}');
e.target.setAttribute('maxlength', '6');
});
</script>
</body>
</html>