Sicherheitsstatus
Dieser Commit ist enthalten in:
@@ -40,7 +40,8 @@
|
|||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(docker exec:*)",
|
"Bash(docker exec:*)",
|
||||||
"Bash(rm:*)",
|
"Bash(rm:*)",
|
||||||
"Bash(mv:*)"
|
"Bash(mv:*)",
|
||||||
|
"Bash(docker-compose restart:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
122
JOURNAL.md
122
JOURNAL.md
@@ -567,4 +567,124 @@ Die Session-Daten werden erst gefüllt, wenn der License Server API implementier
|
|||||||
- ✅ "Nicht sicher" Warnung in Chrome behoben
|
- ✅ "Nicht sicher" Warnung in Chrome behoben
|
||||||
- ✅ Saubere SSL-Konfiguration ohne Mixed Content
|
- ✅ Saubere SSL-Konfiguration ohne Mixed Content
|
||||||
- ✅ Verbesserte Sicherheits-Header implementiert
|
- ✅ Verbesserte Sicherheits-Header implementiert
|
||||||
- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol
|
- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol
|
||||||
|
|
||||||
|
### 2025-06-07 - Sicherheitslücke geschlossen: License Server Port
|
||||||
|
- Direkter Zugriff auf License Server Port 8443 entfernt
|
||||||
|
- Sicherheitsanalyse der exponierten Ports durchgeführt
|
||||||
|
|
||||||
|
**Identifiziertes Problem:**
|
||||||
|
- License Server war direkt auf Port 8443 von außen erreichbar
|
||||||
|
- Umging damit die Nginx-Sicherheitsschicht und Security Headers
|
||||||
|
- Besonders kritisch, da nur Platzhalter ohne echte Sicherheit
|
||||||
|
|
||||||
|
**Durchgeführte Änderung:**
|
||||||
|
- Port-Mapping für License Server in docker-compose.yaml entfernt
|
||||||
|
- Service ist jetzt nur noch über Nginx Reverse Proxy erreichbar
|
||||||
|
- Gleiche Sicherheitskonfiguration wie Admin Panel
|
||||||
|
|
||||||
|
**Aktuelle Port-Exposition:**
|
||||||
|
- ✅ Nginx: Port 80/443 (benötigt für externen Zugriff)
|
||||||
|
- ✅ PostgreSQL: Keine Ports exponiert (gut)
|
||||||
|
- ✅ Admin Panel: Nur über Nginx erreichbar
|
||||||
|
- ✅ License Server: Nur über Nginx erreichbar (vorher direkt auf 8443)
|
||||||
|
|
||||||
|
**Weitere identifizierte Sicherheitsthemen:**
|
||||||
|
1. Credentials im Klartext in .env Datei
|
||||||
|
2. SSL-Zertifikate im Repository gespeichert
|
||||||
|
3. License Server noch nicht implementiert
|
||||||
|
|
||||||
|
**Empfehlung:** Docker-Container neu starten für Änderungsübernahme
|
||||||
|
|
||||||
|
### 2025-06-07 - License Server Port 8443 wieder aktiviert
|
||||||
|
- Port 8443 für direkten Zugriff auf License Server wieder geöffnet
|
||||||
|
- Notwendig für Client-Software Lizenzprüfung
|
||||||
|
|
||||||
|
**Begründung:**
|
||||||
|
- Client-Software benötigt direkten Zugriff für Lizenzprüfung
|
||||||
|
- Umgehung von möglichen Firewall-Blockaden auf Port 443
|
||||||
|
- Weniger Latenz ohne Nginx-Proxy
|
||||||
|
- Flexibilität für verschiedene Client-Implementierungen
|
||||||
|
|
||||||
|
**Konfiguration:**
|
||||||
|
- License Server erreichbar über:
|
||||||
|
- Direkt: Port 8443 (für Client-Software)
|
||||||
|
- Via Nginx: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com (für Browser/Tests)
|
||||||
|
|
||||||
|
**Sicherheitshinweis:**
|
||||||
|
- Port 8443 ist wieder direkt exponiert
|
||||||
|
- License Server muss vor Produktivbetrieb implementiert werden mit:
|
||||||
|
- Eigener SSL-Konfiguration
|
||||||
|
- API-Key Authentifizierung
|
||||||
|
- Rate Limiting
|
||||||
|
- Input-Validierung
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
- Port-Mapping in docker-compose.yaml wiederhergestellt
|
||||||
|
- Änderung erfordert Docker-Neustart
|
||||||
|
|
||||||
|
### 2025-06-07 - Rate-Limiting und Brute-Force-Schutz implementiert
|
||||||
|
- Umfassender Schutz vor Login-Angriffen mit IP-Sperre
|
||||||
|
- Dashboard-Integration für Sicherheitsüberwachung
|
||||||
|
|
||||||
|
**Implementierte Features:**
|
||||||
|
1. **Rate-Limiting System:**
|
||||||
|
- 5 Login-Versuche erlaubt, danach 24h IP-Sperre
|
||||||
|
- Progressive Fehlermeldungen (zufällig aus 5 lustigen Varianten)
|
||||||
|
- CAPTCHA nach 2 Fehlversuchen (Google reCAPTCHA v2 vorbereitet)
|
||||||
|
- E-Mail-Benachrichtigung bei Sperrung (vorbereitet, deaktiviert für PoC)
|
||||||
|
|
||||||
|
2. **Timing-Attack Schutz:**
|
||||||
|
- Mindestens 1 Sekunde Antwortzeit bei allen Login-Versuchen
|
||||||
|
- Gleiche Antwortzeit bei richtigem/falschem Username
|
||||||
|
- Verhindert Username-Enumeration
|
||||||
|
|
||||||
|
3. **Lustige Fehlermeldungen (zufällig):**
|
||||||
|
- "NOPE!"
|
||||||
|
- "ACCESS DENIED, TRY HARDER"
|
||||||
|
- "WRONG! 🚫"
|
||||||
|
- "COMPUTER SAYS NO"
|
||||||
|
- "YOU FAILED"
|
||||||
|
|
||||||
|
4. **Dashboard-Sicherheitswidget:**
|
||||||
|
- Sicherheitslevel-Anzeige (NORMAL/ERHÖHT/KRITISCH)
|
||||||
|
- Anzahl gesperrter IPs
|
||||||
|
- Fehlversuche heute
|
||||||
|
- Letzte 5 Sicherheitsereignisse mit Details
|
||||||
|
|
||||||
|
5. **IP-Verwaltung:**
|
||||||
|
- Übersicht aller gesperrten IPs
|
||||||
|
- Manuelles Entsperren möglich
|
||||||
|
- Login-Versuche zurücksetzen
|
||||||
|
- Detaillierte Informationen pro IP
|
||||||
|
|
||||||
|
6. **Audit-Log Erweiterungen:**
|
||||||
|
- LOGIN_SUCCESS - Erfolgreiche Anmeldung
|
||||||
|
- LOGIN_FAILED - Fehlgeschlagener Versuch
|
||||||
|
- LOGIN_BLOCKED - IP wurde gesperrt
|
||||||
|
- UNBLOCK_IP - IP manuell entsperrt
|
||||||
|
- CLEAR_ATTEMPTS - Versuche zurückgesetzt
|
||||||
|
|
||||||
|
**Neue/Geänderte Dateien:**
|
||||||
|
- v2_adminpanel/init.sql (login_attempts Tabelle)
|
||||||
|
- v2_adminpanel/app.py (Rate-Limiting Logik, neue Routen)
|
||||||
|
- v2_adminpanel/templates/login.html (Fehlermeldungs-Styling, CAPTCHA)
|
||||||
|
- v2_adminpanel/templates/dashboard.html (Sicherheitswidget)
|
||||||
|
- v2_adminpanel/templates/blocked_ips.html (neu - IP-Verwaltung)
|
||||||
|
|
||||||
|
**Technische Details:**
|
||||||
|
- IP-Ermittlung berücksichtigt Proxy-Header (X-Forwarded-For)
|
||||||
|
- Fehlermeldungen mit Animation (shake-effect)
|
||||||
|
- Farbcodierung: Rot für Fehler, Lila für Sperre, Orange für CAPTCHA
|
||||||
|
- Automatische Bereinigung alter Einträge möglich
|
||||||
|
|
||||||
|
**Sicherheitsverbesserungen:**
|
||||||
|
- Schutz vor Brute-Force-Angriffen
|
||||||
|
- Timing-Attack-Schutz implementiert
|
||||||
|
- IP-basierte Sperrung für 24 Stunden
|
||||||
|
- Audit-Trail für alle Sicherheitsereignisse
|
||||||
|
|
||||||
|
**Hinweis für Produktion:**
|
||||||
|
- CAPTCHA-Keys müssen in .env konfiguriert werden
|
||||||
|
- E-Mail-Server für Benachrichtigungen einrichten
|
||||||
|
- Rate-Limits können über Konstanten angepasst werden
|
||||||
@@ -6,7 +6,7 @@ from flask_session import Session
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import io
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
import gzip
|
import gzip
|
||||||
@@ -15,6 +15,8 @@ from pathlib import Path
|
|||||||
import time
|
import time
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
|
import hashlib
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -29,6 +31,19 @@ Session(app)
|
|||||||
BACKUP_DIR = Path("/app/backups")
|
BACKUP_DIR = Path("/app/backups")
|
||||||
BACKUP_DIR.mkdir(exist_ok=True)
|
BACKUP_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Rate-Limiting Konfiguration
|
||||||
|
FAIL_MESSAGES = [
|
||||||
|
"NOPE!",
|
||||||
|
"ACCESS DENIED, TRY HARDER",
|
||||||
|
"WRONG! 🚫",
|
||||||
|
"COMPUTER SAYS NO",
|
||||||
|
"YOU FAILED"
|
||||||
|
]
|
||||||
|
|
||||||
|
MAX_LOGIN_ATTEMPTS = 5
|
||||||
|
BLOCK_DURATION_HOURS = 24
|
||||||
|
CAPTCHA_AFTER_ATTEMPTS = 2
|
||||||
|
|
||||||
# Scheduler für automatische Backups
|
# Scheduler für automatische Backups
|
||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
@@ -321,11 +336,183 @@ scheduler.add_job(
|
|||||||
replace_existing=True
|
replace_existing=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Rate-Limiting Funktionen
|
||||||
|
def get_client_ip():
|
||||||
|
"""Ermittelt die echte IP-Adresse des Clients"""
|
||||||
|
if request.environ.get('HTTP_X_FORWARDED_FOR'):
|
||||||
|
return request.environ['HTTP_X_FORWARDED_FOR'].split(',')[0]
|
||||||
|
elif request.environ.get('HTTP_X_REAL_IP'):
|
||||||
|
return request.environ.get('HTTP_X_REAL_IP')
|
||||||
|
else:
|
||||||
|
return request.environ.get('REMOTE_ADDR')
|
||||||
|
|
||||||
|
def check_ip_blocked(ip_address):
|
||||||
|
"""Prüft ob eine IP-Adresse gesperrt ist"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT blocked_until FROM login_attempts
|
||||||
|
WHERE ip_address = %s AND blocked_until IS NOT NULL
|
||||||
|
""", (ip_address,))
|
||||||
|
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if result and result[0]:
|
||||||
|
if result[0] > datetime.now():
|
||||||
|
return True, result[0]
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def record_failed_attempt(ip_address, username):
|
||||||
|
"""Zeichnet einen fehlgeschlagenen Login-Versuch auf"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Random Fehlermeldung
|
||||||
|
error_message = random.choice(FAIL_MESSAGES)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prüfen ob IP bereits existiert
|
||||||
|
cur.execute("""
|
||||||
|
SELECT attempt_count FROM login_attempts
|
||||||
|
WHERE ip_address = %s
|
||||||
|
""", (ip_address,))
|
||||||
|
|
||||||
|
result = cur.fetchone()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
# Update bestehenden Eintrag
|
||||||
|
new_count = result[0] + 1
|
||||||
|
blocked_until = None
|
||||||
|
|
||||||
|
if new_count >= MAX_LOGIN_ATTEMPTS:
|
||||||
|
blocked_until = datetime.now() + timedelta(hours=BLOCK_DURATION_HOURS)
|
||||||
|
# E-Mail-Benachrichtigung (wenn aktiviert)
|
||||||
|
if os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||||||
|
send_security_alert_email(ip_address, username, new_count)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE login_attempts
|
||||||
|
SET attempt_count = %s,
|
||||||
|
last_attempt = CURRENT_TIMESTAMP,
|
||||||
|
blocked_until = %s,
|
||||||
|
last_username_tried = %s,
|
||||||
|
last_error_message = %s
|
||||||
|
WHERE ip_address = %s
|
||||||
|
""", (new_count, blocked_until, username, error_message, ip_address))
|
||||||
|
else:
|
||||||
|
# Neuen Eintrag erstellen
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO login_attempts
|
||||||
|
(ip_address, attempt_count, last_username_tried, last_error_message)
|
||||||
|
VALUES (%s, 1, %s, %s)
|
||||||
|
""", (ip_address, username, error_message))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Audit-Log
|
||||||
|
log_audit('LOGIN_FAILED', 'user',
|
||||||
|
additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Rate limiting error: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return error_message
|
||||||
|
|
||||||
|
def reset_login_attempts(ip_address):
|
||||||
|
"""Setzt die Login-Versuche für eine IP zurück"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
DELETE FROM login_attempts
|
||||||
|
WHERE ip_address = %s
|
||||||
|
""", (ip_address,))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Reset attempts error: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_login_attempts(ip_address):
|
||||||
|
"""Gibt die Anzahl der Login-Versuche für eine IP zurück"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT attempt_count FROM login_attempts
|
||||||
|
WHERE ip_address = %s
|
||||||
|
""", (ip_address,))
|
||||||
|
|
||||||
|
result = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return result[0] if result else 0
|
||||||
|
|
||||||
|
def send_security_alert_email(ip_address, username, attempt_count):
|
||||||
|
"""Sendet eine Sicherheitswarnung per E-Mail"""
|
||||||
|
subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
|
||||||
|
body = f"""
|
||||||
|
WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
|
||||||
|
|
||||||
|
IP-Adresse: {ip_address}
|
||||||
|
Versuchter Benutzername: {username}
|
||||||
|
Anzahl Versuche: {attempt_count}
|
||||||
|
Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||||
|
|
||||||
|
Die IP-Adresse wurde für 24 Stunden gesperrt.
|
||||||
|
|
||||||
|
Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert
|
||||||
|
logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
|
||||||
|
print(f"E-Mail würde gesendet: {subject}")
|
||||||
|
|
||||||
@app.route("/login", methods=["GET", "POST"])
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
|
# Timing-Attack Schutz - Start Zeit merken
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# IP-Adresse ermitteln
|
||||||
|
ip_address = get_client_ip()
|
||||||
|
|
||||||
|
# Prüfen ob IP gesperrt ist
|
||||||
|
is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||||||
|
if is_blocked:
|
||||||
|
time_remaining = (blocked_until - datetime.now()).total_seconds() / 3600
|
||||||
|
error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||||||
|
return render_template("login.html", error=error_msg, error_type="blocked")
|
||||||
|
|
||||||
|
# Anzahl bisheriger Versuche
|
||||||
|
attempt_count = get_login_attempts(ip_address)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username")
|
username = request.form.get("username")
|
||||||
password = request.form.get("password")
|
password = request.form.get("password")
|
||||||
|
captcha_response = request.form.get("g-recaptcha-response")
|
||||||
|
|
||||||
|
# CAPTCHA-Prüfung wenn nötig
|
||||||
|
if attempt_count >= CAPTCHA_AFTER_ATTEMPTS:
|
||||||
|
if not captcha_response:
|
||||||
|
# Timing-Attack Schutz
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if elapsed < 1.0:
|
||||||
|
time.sleep(1.0 - elapsed)
|
||||||
|
return render_template("login.html",
|
||||||
|
error="CAPTCHA ERFORDERLICH!",
|
||||||
|
show_captcha=True,
|
||||||
|
error_type="captcha")
|
||||||
|
|
||||||
# Check gegen beide Admin-Accounts aus .env
|
# Check gegen beide Admin-Accounts aus .env
|
||||||
admin1_user = os.getenv("ADMIN1_USERNAME")
|
admin1_user = os.getenv("ADMIN1_USERNAME")
|
||||||
@@ -333,16 +520,46 @@ def login():
|
|||||||
admin2_user = os.getenv("ADMIN2_USERNAME")
|
admin2_user = os.getenv("ADMIN2_USERNAME")
|
||||||
admin2_pass = os.getenv("ADMIN2_PASSWORD")
|
admin2_pass = os.getenv("ADMIN2_PASSWORD")
|
||||||
|
|
||||||
|
# Login-Prüfung
|
||||||
|
login_success = False
|
||||||
if ((username == admin1_user and password == admin1_pass) or
|
if ((username == admin1_user and password == admin1_pass) or
|
||||||
(username == admin2_user and password == admin2_pass)):
|
(username == admin2_user and password == admin2_pass)):
|
||||||
|
login_success = True
|
||||||
|
|
||||||
|
# Timing-Attack Schutz - Mindestens 1 Sekunde warten
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if elapsed < 1.0:
|
||||||
|
time.sleep(1.0 - elapsed)
|
||||||
|
|
||||||
|
if login_success:
|
||||||
|
# Erfolgreicher Login
|
||||||
session['logged_in'] = True
|
session['logged_in'] = True
|
||||||
session['username'] = username
|
session['username'] = username
|
||||||
log_audit('LOGIN', 'user', additional_info=f"Erfolgreiche Anmeldung")
|
reset_login_attempts(ip_address)
|
||||||
|
log_audit('LOGIN_SUCCESS', 'user',
|
||||||
|
additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
else:
|
else:
|
||||||
return render_template("login.html", error="Ungültige Anmeldedaten")
|
# Fehlgeschlagener Login
|
||||||
|
error_message = record_failed_attempt(ip_address, username)
|
||||||
|
new_attempt_count = get_login_attempts(ip_address)
|
||||||
|
|
||||||
|
# Prüfen ob jetzt gesperrt
|
||||||
|
is_now_blocked, _ = check_ip_blocked(ip_address)
|
||||||
|
if is_now_blocked:
|
||||||
|
log_audit('LOGIN_BLOCKED', 'security',
|
||||||
|
additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||||||
|
|
||||||
|
return render_template("login.html",
|
||||||
|
error=error_message,
|
||||||
|
show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS),
|
||||||
|
error_type="failed",
|
||||||
|
attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count))
|
||||||
|
|
||||||
return render_template("login.html")
|
# GET Request
|
||||||
|
return render_template("login.html",
|
||||||
|
show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS),
|
||||||
|
attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count))
|
||||||
|
|
||||||
@app.route("/logout")
|
@app.route("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
@@ -440,6 +657,56 @@ def dashboard():
|
|||||||
""")
|
""")
|
||||||
last_backup_info = cur.fetchone()
|
last_backup_info = cur.fetchone()
|
||||||
|
|
||||||
|
# Sicherheitsstatistiken
|
||||||
|
# Gesperrte IPs
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM login_attempts
|
||||||
|
WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP
|
||||||
|
""")
|
||||||
|
blocked_ips_count = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Fehlversuche heute
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts
|
||||||
|
WHERE last_attempt::date = CURRENT_DATE
|
||||||
|
""")
|
||||||
|
failed_attempts_today = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# Letzte 5 Sicherheitsereignisse
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
la.ip_address,
|
||||||
|
la.attempt_count,
|
||||||
|
la.last_attempt,
|
||||||
|
la.blocked_until,
|
||||||
|
la.last_username_tried,
|
||||||
|
la.last_error_message
|
||||||
|
FROM login_attempts la
|
||||||
|
ORDER BY la.last_attempt DESC
|
||||||
|
LIMIT 5
|
||||||
|
""")
|
||||||
|
recent_security_events = []
|
||||||
|
for event in cur.fetchall():
|
||||||
|
recent_security_events.append({
|
||||||
|
'ip_address': event[0],
|
||||||
|
'attempt_count': event[1],
|
||||||
|
'last_attempt': event[2].strftime('%d.%m %H:%M'),
|
||||||
|
'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now() else None,
|
||||||
|
'username_tried': event[4],
|
||||||
|
'error_message': event[5]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sicherheitslevel berechnen
|
||||||
|
if blocked_ips_count > 5 or failed_attempts_today > 50:
|
||||||
|
security_level = 'danger'
|
||||||
|
security_level_text = 'KRITISCH'
|
||||||
|
elif blocked_ips_count > 2 or failed_attempts_today > 20:
|
||||||
|
security_level = 'warning'
|
||||||
|
security_level_text = 'ERHÖHT'
|
||||||
|
else:
|
||||||
|
security_level = 'success'
|
||||||
|
security_level_text = 'NORMAL'
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -454,7 +721,13 @@ def dashboard():
|
|||||||
'recent_licenses': recent_licenses,
|
'recent_licenses': recent_licenses,
|
||||||
'expiring_licenses': expiring_licenses,
|
'expiring_licenses': expiring_licenses,
|
||||||
'active_sessions': active_sessions_count,
|
'active_sessions': active_sessions_count,
|
||||||
'last_backup': last_backup_info
|
'last_backup': last_backup_info,
|
||||||
|
# Sicherheitsstatistiken
|
||||||
|
'blocked_ips_count': blocked_ips_count,
|
||||||
|
'failed_attempts_today': failed_attempts_today,
|
||||||
|
'recent_security_events': recent_security_events,
|
||||||
|
'security_level': security_level,
|
||||||
|
'security_level_text': security_level_text
|
||||||
}
|
}
|
||||||
|
|
||||||
return render_template("dashboard.html", stats=stats, username=session.get('username'))
|
return render_template("dashboard.html", stats=stats, username=session.get('username'))
|
||||||
@@ -1274,5 +1547,87 @@ def download_backup(backup_id):
|
|||||||
|
|
||||||
return send_file(filepath, as_attachment=True, download_name=filename)
|
return send_file(filepath, as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
|
@app.route("/security/blocked-ips")
|
||||||
|
@login_required
|
||||||
|
def blocked_ips():
|
||||||
|
"""Zeigt alle gesperrten IPs an"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
ip_address,
|
||||||
|
attempt_count,
|
||||||
|
first_attempt,
|
||||||
|
last_attempt,
|
||||||
|
blocked_until,
|
||||||
|
last_username_tried,
|
||||||
|
last_error_message
|
||||||
|
FROM login_attempts
|
||||||
|
WHERE blocked_until IS NOT NULL
|
||||||
|
ORDER BY blocked_until DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
blocked_ips_list = []
|
||||||
|
for ip in cur.fetchall():
|
||||||
|
blocked_ips_list.append({
|
||||||
|
'ip_address': ip[0],
|
||||||
|
'attempt_count': ip[1],
|
||||||
|
'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'),
|
||||||
|
'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'),
|
||||||
|
'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'),
|
||||||
|
'is_active': ip[4] > datetime.now(),
|
||||||
|
'last_username': ip[5],
|
||||||
|
'last_error': ip[6]
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return render_template("blocked_ips.html",
|
||||||
|
blocked_ips=blocked_ips_list,
|
||||||
|
username=session.get('username'))
|
||||||
|
|
||||||
|
@app.route("/security/unblock-ip", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def unblock_ip():
|
||||||
|
"""Entsperrt eine IP-Adresse"""
|
||||||
|
ip_address = request.form.get('ip_address')
|
||||||
|
|
||||||
|
if ip_address:
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE login_attempts
|
||||||
|
SET blocked_until = NULL
|
||||||
|
WHERE ip_address = %s
|
||||||
|
""", (ip_address,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Audit-Log
|
||||||
|
log_audit('UNBLOCK_IP', 'security',
|
||||||
|
additional_info=f"IP {ip_address} manuell entsperrt")
|
||||||
|
|
||||||
|
return redirect(url_for('blocked_ips'))
|
||||||
|
|
||||||
|
@app.route("/security/clear-attempts", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def clear_attempts():
|
||||||
|
"""Löscht alle Login-Versuche für eine IP"""
|
||||||
|
ip_address = request.form.get('ip_address')
|
||||||
|
|
||||||
|
if ip_address:
|
||||||
|
reset_login_attempts(ip_address)
|
||||||
|
|
||||||
|
# Audit-Log
|
||||||
|
log_audit('CLEAR_ATTEMPTS', 'security',
|
||||||
|
additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt")
|
||||||
|
|
||||||
|
return redirect(url_for('blocked_ips'))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5000)
|
app.run(host="0.0.0.0", port=5000)
|
||||||
|
|||||||
@@ -70,3 +70,18 @@ CREATE TABLE IF NOT EXISTS backup_history (
|
|||||||
-- Index für bessere Performance
|
-- Index für bessere Performance
|
||||||
CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
|
CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
|
||||||
CREATE INDEX idx_backup_history_status ON backup_history(status);
|
CREATE INDEX idx_backup_history_status ON backup_history(status);
|
||||||
|
|
||||||
|
-- Login-Attempts-Tabelle für Rate-Limiting
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
ip_address VARCHAR(45) PRIMARY KEY,
|
||||||
|
attempt_count INTEGER DEFAULT 0,
|
||||||
|
first_attempt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_attempt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
blocked_until TIMESTAMP NULL,
|
||||||
|
last_username_tried TEXT,
|
||||||
|
last_error_message TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index für schnelle Abfragen
|
||||||
|
CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until);
|
||||||
|
CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC);
|
||||||
|
|||||||
115
v2_adminpanel/templates/blocked_ips.html
Normale Datei
115
v2_adminpanel/templates/blocked_ips.html
Normale Datei
@@ -0,0 +1,115 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Gesperrte IPs - Admin Panel</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<span class="navbar-brand">🎛️ Lizenzverwaltung</span>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="text-white me-3">Angemeldet als: {{ username }}</span>
|
||||||
|
<a href="/logout" class="btn btn-outline-light btn-sm">Abmelden</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>🔒 Gesperrte IPs</h1>
|
||||||
|
<div>
|
||||||
|
<a href="/" class="btn btn-secondary">← Dashboard</a>
|
||||||
|
<a href="/audit" class="btn btn-secondary">📋 Audit-Log</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">IP-Sperrverwaltung</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if blocked_ips %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP-Adresse</th>
|
||||||
|
<th>Versuche</th>
|
||||||
|
<th>Erster Versuch</th>
|
||||||
|
<th>Letzter Versuch</th>
|
||||||
|
<th>Gesperrt bis</th>
|
||||||
|
<th>Letzter User</th>
|
||||||
|
<th>Letzte Meldung</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ip in blocked_ips %}
|
||||||
|
<tr class="{% if ip.is_active %}table-danger{% else %}table-secondary{% endif %}">
|
||||||
|
<td><code>{{ ip.ip_address }}</code></td>
|
||||||
|
<td><span class="badge bg-danger">{{ ip.attempt_count }}</span></td>
|
||||||
|
<td>{{ ip.first_attempt }}</td>
|
||||||
|
<td>{{ ip.last_attempt }}</td>
|
||||||
|
<td>{{ ip.blocked_until }}</td>
|
||||||
|
<td>{{ ip.last_username or '-' }}</td>
|
||||||
|
<td><strong>{{ ip.last_error or '-' }}</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if ip.is_active %}
|
||||||
|
<span class="badge bg-danger">GESPERRT</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">ABGELAUFEN</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
{% if ip.is_active %}
|
||||||
|
<form method="post" action="/security/unblock-ip" class="d-inline">
|
||||||
|
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||||
|
<button type="submit" class="btn btn-success"
|
||||||
|
onclick="return confirm('IP {{ ip.ip_address }} wirklich entsperren?')">
|
||||||
|
🔓 Entsperren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/security/clear-attempts" class="d-inline ms-1">
|
||||||
|
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||||
|
<button type="submit" class="btn btn-warning"
|
||||||
|
onclick="return confirm('Alle Versuche für IP {{ ip.ip_address }} zurücksetzen?')">
|
||||||
|
🗑️ Reset
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Keine gesperrten IPs vorhanden.</strong>
|
||||||
|
Das System läuft ohne Sicherheitsvorfälle.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">ℹ️ Informationen</h5>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>IPs werden nach <strong>{{ 5 }} fehlgeschlagenen Login-Versuchen</strong> für <strong>24 Stunden</strong> gesperrt.</li>
|
||||||
|
<li>Nach <strong>2 Versuchen</strong> wird ein CAPTCHA angezeigt.</li>
|
||||||
|
<li>Bei <strong>5 Versuchen</strong> wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).</li>
|
||||||
|
<li>Gesperrte IPs können manuell entsperrt werden.</li>
|
||||||
|
<li>Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -118,10 +118,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backup-Status -->
|
<!-- Backup-Status und Sicherheit nebeneinander -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-md-6">
|
||||||
<div class="card">
|
<div class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">💾 Backup-Status</h5>
|
<h5 class="card-title">💾 Backup-Status</h5>
|
||||||
{% if stats.last_backup %}
|
{% if stats.last_backup %}
|
||||||
@@ -152,7 +152,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sicherheitsstatus -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">🔒 Sicherheitsstatus</h5>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span>Sicherheitslevel:</span>
|
||||||
|
<span class="badge bg-{{ stats.security_level }} fs-6">{{ stats.security_level_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-6">
|
||||||
|
<h4 class="text-danger mb-0">{{ stats.blocked_ips_count }}</h4>
|
||||||
|
<small class="text-muted">Gesperrte IPs</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<h4 class="text-warning mb-0">{{ stats.failed_attempts_today }}</h4>
|
||||||
|
<small class="text-muted">Fehlversuche heute</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/security/blocked-ips" class="btn btn-sm btn-outline-danger mt-3">IP-Verwaltung →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sicherheitsereignisse -->
|
||||||
|
{% if stats.recent_security_events %}
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-dark text-white">
|
||||||
|
<h6 class="mb-0">🚨 Letzte Sicherheitsereignisse</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>IP-Adresse</th>
|
||||||
|
<th>Versuche</th>
|
||||||
|
<th>Fehlermeldung</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in stats.recent_security_events %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ event.last_attempt }}</td>
|
||||||
|
<td><code>{{ event.ip_address }}</code></td>
|
||||||
|
<td><span class="badge bg-secondary">{{ event.attempt_count }}</span></td>
|
||||||
|
<td><strong class="text-danger">{{ event.error_message }}</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if event.blocked_until %}
|
||||||
|
<span class="badge bg-danger">Gesperrt bis {{ event.blocked_until }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning">Aktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- Bald ablaufende Lizenzen -->
|
<!-- Bald ablaufende Lizenzen -->
|
||||||
|
|||||||
@@ -5,6 +5,70 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Admin Login - Lizenzverwaltung</title>
|
<title>Admin Login - Lizenzverwaltung</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.error-failed {
|
||||||
|
background-color: #dc3545 !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
animation: shake 0.5s;
|
||||||
|
box-shadow: 0 0 20px rgba(220, 53, 69, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-blocked {
|
||||||
|
background-color: #6f42c1 !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
font-size: 1.2rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-captcha {
|
||||||
|
background-color: #fd7e14 !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
font-size: 1.2rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-warning {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -15,11 +79,17 @@
|
|||||||
<h2 class="text-center mb-4">🔐 Admin Login</h2>
|
<h2 class="text-center mb-4">🔐 Admin Login</h2>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="error-{{ error_type|default('failed') }}">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %}
|
||||||
|
<div class="attempts-warning">
|
||||||
|
⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre!
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">Benutzername</label>
|
<label for="username" class="form-label">Benutzername</label>
|
||||||
@@ -29,12 +99,27 @@
|
|||||||
<label for="password" class="form-label">Passwort</label>
|
<label for="password" class="form-label">Passwort</label>
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if show_captcha %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key|default('6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI') }}"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
|
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="security-info">
|
||||||
|
🛡️ Geschützt durch Rate-Limiting und IP-Sperre
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if show_captcha %}
|
||||||
|
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren