2FA
Dieser Commit ist enthalten in:
128
JOURNAL.md
128
JOURNAL.md
@@ -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
|
||||||
43
v2_adminpanel/MIGRATION_2FA.md
Normale Datei
43
v2_adminpanel/MIGRATION_2FA.md
Normale Datei
@@ -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
|
||||||
@@ -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():
|
||||||
|
|||||||
20
v2_adminpanel/create_users_table.sql
Normale Datei
20
v2_adminpanel/create_users_table.sql
Normale Datei
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
78
v2_adminpanel/migrate_users.py
Normale Datei
78
v2_adminpanel/migrate_users.py
Normale Datei
@@ -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()
|
||||||
@@ -9,3 +9,6 @@ cryptography
|
|||||||
apscheduler
|
apscheduler
|
||||||
requests
|
requests
|
||||||
python-dateutil
|
python-dateutil
|
||||||
|
bcrypt
|
||||||
|
pyotp
|
||||||
|
qrcode[pil]
|
||||||
|
|||||||
228
v2_adminpanel/templates/backup_codes.html
Normale Datei
228
v2_adminpanel/templates/backup_codes.html
Normale Datei
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
217
v2_adminpanel/templates/profile.html
Normale Datei
217
v2_adminpanel/templates/profile.html
Normale Datei
@@ -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 %}
|
||||||
210
v2_adminpanel/templates/setup_2fa.html
Normale Datei
210
v2_adminpanel/templates/setup_2fa.html
Normale Datei
@@ -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 %}
|
||||||
131
v2_adminpanel/templates/verify_2fa.html
Normale Datei
131
v2_adminpanel/templates/verify_2fa.html
Normale Datei
@@ -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>
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren