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/validate` - Lizenzvalidierung
|
||||
- `/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 string
|
||||
import re
|
||||
import bcrypt
|
||||
import pyotp
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import json
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -111,6 +117,87 @@ def get_connection():
|
||||
conn.set_client_encoding('UTF8')
|
||||
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
|
||||
def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None):
|
||||
"""Protokolliert Änderungen im Audit-Log"""
|
||||
@@ -647,17 +734,26 @@ def login():
|
||||
attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||||
recaptcha_site_key=recaptcha_site_key)
|
||||
|
||||
# Check gegen beide Admin-Accounts aus .env
|
||||
admin1_user = os.getenv("ADMIN1_USERNAME")
|
||||
admin1_pass = os.getenv("ADMIN1_PASSWORD")
|
||||
admin2_user = os.getenv("ADMIN2_USERNAME")
|
||||
admin2_pass = os.getenv("ADMIN2_PASSWORD")
|
||||
|
||||
# Login-Prüfung
|
||||
# Check user in database first, fallback to env vars
|
||||
user = get_user_by_username(username)
|
||||
login_success = False
|
||||
if ((username == admin1_user and password == admin1_pass) or
|
||||
(username == admin2_user and password == admin2_pass)):
|
||||
login_success = True
|
||||
needs_2fa = False
|
||||
|
||||
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
|
||||
elapsed = time.time() - start_time
|
||||
@@ -666,14 +762,23 @@ def login():
|
||||
|
||||
if login_success:
|
||||
# Erfolgreicher Login
|
||||
session.permanent = True # Aktiviert das Timeout
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
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'))
|
||||
if needs_2fa:
|
||||
# Store temporary session for 2FA verification
|
||||
session['temp_username'] = username
|
||||
session['temp_user_id'] = user['id']
|
||||
session['awaiting_2fa'] = True
|
||||
return redirect(url_for('verify_2fa'))
|
||||
else:
|
||||
# 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:
|
||||
# Fehlgeschlagener Login
|
||||
error_message = record_failed_attempt(ip_address, username)
|
||||
@@ -704,8 +809,224 @@ def logout():
|
||||
log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||||
session.pop('logged_in', 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'))
|
||||
|
||||
@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'])
|
||||
@login_required
|
||||
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_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;
|
||||
|
||||
-- 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
|
||||
requests
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
</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