feat(security): implement robust maintenance mode, session login guard, and comprehensive security hardening

This commit is contained in:
Alex Mueller 2026-09-12 15:02:31 +02:00
parent e49c685f25
commit af4b1dc460
5 changed files with 456 additions and 133 deletions

View file

@ -460,8 +460,12 @@
document.getElementById('modal-create-user').style.display = 'flex';
}
function getAuthToken() {
return (window.AUTH && window.AUTH.token) || sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token') || '';
}
async function loadAdminData() {
const token = localStorage.getItem('auth_token');
const token = getAuthToken();
if (!token) {
showAuthGuard();
return;
@ -573,8 +577,8 @@
</td>
<td style="color: #64748b; font-size: 0.8rem;">${createdDate}</td>
<td style="text-align: right;">
<button type="button" class="admin-btn" onclick="openResetPasswordModal(${u.id}, '${escapeHtml(u.name)}')" title="Passwort zurücksetzen">🔑</button>
<button type="button" class="admin-btn admin-btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.name)}')" title="Benutzer löschen">🗑️</button>
<button type="button" class="admin-btn" onclick="openResetPasswordModal(${u.id})" title="Passwort zurücksetzen">🔑</button>
<button type="button" class="admin-btn admin-btn-danger" onclick="deleteUser(${u.id})" title="Benutzer löschen">🗑️</button>
</td>
</tr>
`;
@ -583,7 +587,12 @@
function escapeHtml(t) {
if (!t) return '';
return String(t).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return String(t)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Filter Users
@ -608,7 +617,7 @@
// Change Role
async function changeUserRole(userId, newRole) {
const token = localStorage.getItem('auth_token');
const token = getAuthToken();
try {
const res = await fetch('/api/admin/users/role', {
method: 'POST',
@ -628,10 +637,12 @@
}
}
// Delete User
async function deleteUser(userId, name) {
// Delete User (sicher ohne inline string parameters)
async function deleteUser(userId) {
const user = ALL_USERS.find(u => u.id === userId);
const name = user ? user.name : `#${userId}`;
if (!confirm(`Soll der Benutzer '${name}' (ID: ${userId}) wirklich unwiderruflich gelöscht werden?`)) return;
const token = localStorage.getItem('auth_token');
const token = getAuthToken();
try {
const res = await fetch('/api/admin/users/delete', {
method: 'POST',
@ -651,8 +662,10 @@
}
}
// Open Reset Password
function openResetPasswordModal(userId, name) {
// Open Reset Password (sicher ohne inline string parameters)
function openResetPasswordModal(userId) {
const user = ALL_USERS.find(u => u.id === userId);
const name = user ? user.name : `#${userId}`;
document.getElementById('reset-user-id').value = userId;
document.getElementById('reset-user-name').innerText = name;
document.getElementById('reset-new-password').value = '';
@ -664,7 +677,7 @@
e.preventDefault();
const userId = document.getElementById('reset-user-id').value;
const newPassword = document.getElementById('reset-new-password').value;
const token = localStorage.getItem('auth_token');
const token = getAuthToken();
try {
const res = await fetch('/api/admin/users/reset-password', {
@ -691,7 +704,7 @@
const email = document.getElementById('create-email').value.trim();
const password = document.getElementById('create-password').value;
const role = document.getElementById('create-role').value;
const token = localStorage.getItem('auth_token');
const token = getAuthToken();
try {
const res = await fetch('/api/admin/users/create', {
@ -724,7 +737,7 @@
const d = await res.json();
const s = d.settings || {};
document.getElementById('setting-announcement').value = s.announcement_banner || '';
document.getElementById('setting-allow-reg').checked = (s.allow_registrations !== '0');
document.getElementById('setting-allow-reg').checked = (s.allow_registration !== '0' && s.allow_registrations !== '0');
document.getElementById('setting-maintenance').checked = (s.maintenance_mode === '1');
}
} catch (e) { }
@ -732,11 +745,14 @@
document.getElementById('admin-settings-form').onsubmit = async (e) => {
e.preventDefault();
const token = localStorage.getItem('auth_token');
const token = getAuthToken();
const isMaint = document.getElementById('setting-maintenance').checked;
const isAllowReg = document.getElementById('setting-allow-reg').checked;
const settings = {
announcement_banner: document.getElementById('setting-announcement').value.trim(),
allow_registrations: document.getElementById('setting-allow-reg').checked ? '1' : '0',
maintenance_mode: document.getElementById('setting-maintenance').checked ? '1' : '0'
allow_registration: isAllowReg ? '1' : '0',
allow_registrations: isAllowReg ? '1' : '0',
maintenance_mode: isMaint ? '1' : '0'
};
try {
@ -747,7 +763,10 @@
});
const d = await res.json();
if (res.ok) {
alert("Einstellungen erfolgreich gespeichert!");
alert("Plattform-Einstellungen erfolgreich gespeichert! " + (isMaint ? "(⚠️ Wartungsmodus ist nun AKTIV)" : "(Wartungsmodus ist inaktiv)"));
if (window.AUTH && window.AUTH.checkStatus) {
window.AUTH.checkStatus();
}
} else {
alert("Fehler: " + d.error);
}

View file

@ -1,8 +1,11 @@
/**
* 🔐 AUTHENTIFIZIERUNG & ZUGANGSSCHUTZ (AUTH GUARD) 🔐
* =======================================================
* IT-Praxisportal – Benutzer-Authentifizierung und Schutz aller Kurse & Web-IDE.
* Zugriff auf Kurse, Lerneinheiten und Web-IDE erfordert ein registriertes Benutzerkonto.
* IT-Praxisportal – Benutzer-Authentifizierung, Wartungsmodus und Zugangsschutz.
* - Strikter Login-Zwang für den Alpha-Test (sessionStorage als Standard).
* - Vollständiger Wartungsmodus mit Fullscreen-Blockade für Nicht-Admins.
* - Live-Statusabfrage (/api/platform/status) & Ankündigungsbanner.
* - Vollständige Beseitigung unsicherer Offline-/Dummy-Fallbacks.
*/
(function () {
@ -18,85 +21,117 @@
.replace(/'/g, '&#039;');
}
// Initialisiere Auth-Objekt
const initialUser = JSON.parse(localStorage.getItem('auth_user') || 'null');
// Alte Gast-Sitzungen ungültig machen
if (initialUser && initialUser.isGuest) {
localStorage.removeItem('auth_user');
// Session-Speicher auslesen (sessionStorage bevorzugt für Sitzungsbindung im Test)
function getStoredToken() {
return sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token') || null;
}
function getStoredUser() {
try {
const raw = sessionStorage.getItem('auth_user') || localStorage.getItem('auth_user') || 'null';
return JSON.parse(raw);
} catch (e) {
return null;
}
}
// Bereinigung veralteter oder gefälschter Dummy-Tokens
const initialToken = getStoredToken();
const initialUser = getStoredUser();
if ((initialToken && initialToken.startsWith('local_token_')) || (initialUser && initialUser.isGuest)) {
sessionStorage.removeItem('auth_token');
sessionStorage.removeItem('auth_user');
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
window.AUTH = {
token: localStorage.getItem('auth_token') || null,
user: (initialUser && !initialUser.isGuest) ? initialUser : null,
token: getStoredToken(),
user: getStoredUser(),
platformStatus: { maintenance: false, announcement: '', allow_registration: true },
isLoggedIn() {
return !!(this.token && this.user && !this.user.isGuest && this.user.email);
},
setSession(token, user) {
this.token = token;
this.user = user;
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
this.updateUI();
isAdmin() {
return !!(this.isLoggedIn() && this.user && this.user.role === 'admin');
},
logout() {
setSession(token, user, rememberMe = false) {
this.token = token;
this.user = user;
if (rememberMe) {
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
sessionStorage.removeItem('auth_token');
sessionStorage.removeItem('auth_user');
} else {
sessionStorage.setItem('auth_token', token);
sessionStorage.setItem('auth_user', JSON.stringify(user));
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
this.updateUI();
this.handleMaintenanceDisplay();
},
logout(redirect = true) {
this.token = null;
this.user = null;
sessionStorage.removeItem('auth_token');
sessionStorage.removeItem('auth_user');
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
this.updateUI();
// Wenn auf einer geschützten Kursseite, zur Startseite leiten
const p = window.location.pathname;
if (p.includes('/courses/') || p.includes('/lehrpfad_') || p.endsWith('workspace.html')) {
window.location.href = window.location.origin + '/index.html?auth=login';
if (redirect) {
const p = window.location.pathname;
if (p.includes('/courses/') || p.includes('/lehrpfad_') || p.endsWith('workspace.html') || p.endsWith('teacher.html') || p.endsWith('admin.html')) {
window.location.href = window.location.origin + '/index.html?auth=login';
} else {
this.handleMaintenanceDisplay();
}
}
},
async register(name, email, password, role = 'solo') {
async register(name, email, password, role = 'solo', rememberMe = false) {
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password, role })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen');
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return { success: false, error: data.error || 'Registrierung fehlgeschlagen' };
}
this.setSession(data.token, data.user);
this.setSession(data.token, data.user, rememberMe);
return { success: true, user: data.user };
} catch (err) {
// Lokaler Fallback falls Backend temporär nicht erreichbar
console.warn('API-Registrierung fehlgeschlagen, nutze lokalen Speicher:', err.message);
const localUser = { id: Date.now(), name, email, role, xp: 0, level: 1 };
this.setSession('local_token_' + Date.now(), localUser);
return { success: true, user: localUser };
return { success: false, error: 'Netzwerkfehler: Backend-Server nicht erreichbar.' };
}
},
async login(email, password) {
async login(email, password, rememberMe = false) {
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Anmeldung fehlgeschlagen');
const data = await res.json().catch(() => ({}));
this.setSession(data.token, data.user);
if (!res.ok) {
return { success: false, error: data.error || 'Anmeldung fehlgeschlagen' };
}
this.setSession(data.token, data.user, rememberMe);
return { success: true, user: data.user };
} catch (err) {
console.warn('API-Login fehlgeschlagen, versuche lokalen Fallback:', err.message);
if (email && password && password.length >= 6) {
const localUser = { id: 1, name: email.split('@')[0], email, role: 'solo', xp: 100, level: 1 };
this.setSession('local_token_' + Date.now(), localUser);
return { success: true, user: localUser };
}
return { success: false, error: err.message || 'Ungültige Anmeldedaten' };
return { success: false, error: 'Netzwerkfehler: Backend-Server nicht erreichbar.' };
}
},
@ -106,17 +141,156 @@
const res = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
if (res.ok) {
const data = await res.json();
this.user = data.user;
localStorage.setItem('auth_user', JSON.stringify(data.user));
if (localStorage.getItem('auth_token')) {
localStorage.setItem('auth_user', JSON.stringify(data.user));
} else {
sessionStorage.setItem('auth_user', JSON.stringify(data.user));
}
this.updateUI();
this.handleMaintenanceDisplay();
} else {
const errData = await res.json().catch(() => ({}));
if (res.status === 401 || res.status === 404) {
this.logout(false);
} else if (res.status === 503 && errData.maintenance) {
this.handleMaintenanceDisplay();
}
}
} catch (e) {
// Offline / Netzwerkfehler ignorieren
// Offline
}
},
async checkStatus() {
try {
const res = await fetch('/api/platform/status');
if (res.ok) {
this.platformStatus = await res.json();
this.handleMaintenanceDisplay();
this.handleAnnouncementDisplay();
}
} catch (e) {
// Ignorieren
}
},
handleMaintenanceDisplay() {
const isMaintenance = this.platformStatus && this.platformStatus.maintenance;
const isAdmin = this.isAdmin();
let screen = document.getElementById('itp-maintenance-screen');
let adminBanner = document.getElementById('itp-maintenance-admin-banner');
if (isMaintenance) {
if (!isAdmin) {
if (!screen) {
screen = document.createElement('div');
screen.id = 'itp-maintenance-screen';
screen.style.cssText = `
position: fixed; inset: 0; z-index: 999999;
background: rgba(10, 15, 29, 0.96); backdrop-filter: blur(12px);
display: flex; align-items: center; justify-content: center;
padding: 24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #f8fafc; text-align: center;
`;
screen.innerHTML = `
<div style="max-width: 520px; background: #0f172a; border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 20px; padding: 40px 32px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.7);">
<div style="width: 72px; height: 72px; margin: 0 auto 20px; background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 20px; display: flex; align-items: center; justify-content: center; font-size: 2.2rem;">🛠️</div>
<h1 style="font-size: 1.75rem; font-weight: 800; color: #ffffff; margin-bottom: 12px; letter-spacing: -0.5px;">Wartungsmodus aktiv</h1>
<p style="font-size: 0.98rem; color: #94a3b8; line-height: 1.6; margin-bottom: 28px;">
Das IT-Praxisportal wird derzeit planmäßig gewartet, um Sicherheits-Updates und neue Lernmodule einzuspielen. Reguläre Kurszugänge sind vorübergehend pausiert.
</p>
<div style="display: flex; flex-direction: column; gap: 12px;">
<button type="button" id="maintenance-admin-login-btn" style="background: #0284c7; color: white; border: none; padding: 13px 24px; border-radius: 10px; font-weight: 700; font-size: 0.95rem; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px;">
🔐 Administrator-Anmeldung
</button>
<div style="font-size: 0.8rem; color: #64748b;">
IT-Praxisportal Alpha • Alle Daten sind sicher
</div>
</div>
</div>
`;
document.body.appendChild(screen);
const btn = document.getElementById('maintenance-admin-login-btn');
if (btn) {
btn.onclick = () => {
window.openAuthModal('login', {
allowClose: true,
title: '🔐 Administrator-Login',
message: 'Wartungsmodus aktiv: Der Zugang ist derzeit exklusiv für System-Administratoren freigeschaltet.',
onSuccess: () => {
window.location.reload();
}
});
};
}
}
} else {
if (screen) screen.remove();
if (!adminBanner) {
adminBanner = document.createElement('div');
adminBanner.id = 'itp-maintenance-admin-banner';
adminBanner.style.cssText = `
background: linear-gradient(90deg, #b91c1c, #ea580c);
color: white; padding: 8px 16px; font-size: 0.84rem; font-weight: 700;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 999998; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
`;
adminBanner.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span>⚠️ WARTUNGSMODUS AKTIV</span>
<span style="opacity: 0.85; font-weight: 400;">| Nur Administratoren haben derzeit Zugriff auf die Plattform.</span>
</div>
<a href="${window.location.origin}/admin.html" style="color: white; background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 6px; text-decoration: none; font-size: 0.78rem;">⚙️ Admin-Panel öffnen</a>
`;
document.body.insertBefore(adminBanner, document.body.firstChild);
}
}
} else {
if (screen) screen.remove();
if (adminBanner) adminBanner.remove();
}
},
handleAnnouncementDisplay() {
const bannerText = this.platformStatus && this.platformStatus.announcement;
let el = document.getElementById('itp-announcement-banner');
if (!bannerText) {
if (el) el.remove();
return;
}
if (sessionStorage.getItem('dismissed_announcement') === bannerText) {
return;
}
if (!el) {
el = document.createElement('div');
el.id = 'itp-announcement-banner';
el.style.cssText = `
background: #0284c7; color: white; padding: 8px 16px; font-size: 0.85rem; font-weight: 600;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 999990;
`;
document.body.insertBefore(el, document.body.firstChild);
}
el.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; margin: 0 auto;">
<span>📢 ${escapeHtml(bannerText)}</span>
</div>
<button type="button" style="background: transparent; border: none; color: white; font-size: 1.1rem; cursor: pointer; line-height: 1;" title="Schließen">✕</button>
`;
el.querySelector('button').onclick = () => {
sessionStorage.setItem('dismissed_announcement', bannerText);
el.remove();
};
},
requireAuth(options = {}) {
if (this.isLoggedIn()) {
return true;
@ -126,10 +300,10 @@
},
showAuthBarrier(options = {}) {
window.openAuthModal(options.mode || 'register', {
window.openAuthModal(options.mode || 'login', {
allowClose: options.allowClose !== undefined ? options.allowClose : false,
title: options.title || '🔒 Registrierung erforderlich',
message: options.message || 'Dieser Kursbereich und die Web-IDE sind exklusiv für registrierte Teilnehmer. Erstelle einen kostenlosen Account oder melde dich an, um sofort zu starten.',
title: options.title || '🔒 Anmeldung erforderlich',
message: options.message || 'Dieser Lernbereich erfordert ein Benutzerkonto. Bitte melde dich an oder erstelle einen kostenlosen Account.',
onSuccess: () => {
if (options.onSuccess) options.onSuccess();
else window.location.reload();
@ -239,18 +413,20 @@
};
// Auth Modal UI
window.openAuthModal = function (initialMode = 'register', options = {}) {
window.openAuthModal = function (initialMode = 'login', options = {}) {
const allowClose = options.allowClose !== false;
let modal = document.getElementById('auth-modal-overlay');
if (modal) modal.remove();
const allowReg = window.AUTH.platformStatus.allow_registration !== false && !window.AUTH.platformStatus.maintenance;
modal = document.createElement('div');
modal.id = 'auth-modal-overlay';
modal.className = 'certificate-overlay';
modal.style.zIndex = '99999';
modal.style.zIndex = '999999';
modal.innerHTML = `
<div class="certificate-container" style="max-width: 460px; padding: 28px 30px; text-align: left; position: relative; border: 1px solid rgba(255, 255, 255, 0.15); box-shadow: 0 20px 40px rgba(0,0,0,0.6); border-radius: 16px;">
<div class="certificate-container" style="max-width: 460px; padding: 28px 30px; text-align: left; position: relative; border: 1px solid rgba(255, 255, 255, 0.15); box-shadow: 0 20px 40px rgba(0,0,0,0.6); border-radius: 16px; background: #ffffff;">
${allowClose ? `
<button type="button" id="auth-modal-close-x" style="position: absolute; top: 16px; right: 18px; background: transparent; border: none; font-size: 1.3rem; color: #94a3b8; cursor: pointer; line-height: 1;" title="Schließen">✕</button>
` : ''}
@ -258,76 +434,86 @@
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 14px;">
<div style="width: 36px; height: 36px; border-radius: 8px; background: rgba(56, 189, 248, 0.15); border: 1px solid rgba(56, 189, 248, 0.4); display: flex; align-items: center; justify-content: center; font-size: 1.2rem;">🎓</div>
<div>
<div style="font-size: 1.15rem; font-weight: 800; color: var(--text-main);" id="auth-modal-title">
${options.title || (initialMode === 'register' ? '✨ Kostenlos registrieren' : '🔑 Anmelden')}
<div style="font-size: 1.15rem; font-weight: 800; color: #0f172a;" id="auth-modal-title">
${escapeHtml(options.title || (initialMode === 'register' && allowReg ? '✨ Kostenlos registrieren' : '🔑 Anmelden'))}
</div>
<div style="font-size: 0.72rem; color: #64748b; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;">IT-PRAXISPORTAL ACCREDITATION</div>
<div style="font-size: 0.72rem; color: #64748b; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;">IT-PRAXISPORTAL AUTHENTICATION</div>
</div>
</div>
<p id="auth-modal-desc" style="font-size: 0.88rem; color: var(--text-muted); margin-bottom: 18px; line-height: 1.5;">
${options.message || 'Erhalte sofortigen Zugriff auf 13 Praxis-Kurse, 216 interaktive Module, die Web-IDE und IHK AP1 & AP2 Prüfungsfragen.'}
<p id="auth-modal-desc" style="font-size: 0.88rem; color: #64748b; margin-bottom: 18px; line-height: 1.5;">
${escapeHtml(options.message || 'Erhalte sofortigen Zugriff auf 13 Praxis-Kurse, 216 interaktive Module und die Web-IDE.')}
</p>
<!-- Mode Tabs -->
<div style="display: flex; background: #f1f5f9; border-radius: 8px; padding: 4px; margin-bottom: 18px;">
<button type="button" id="auth-tab-register" style="flex: 1; padding: 8px; font-size: 0.84rem; font-weight: 700; border: none; border-radius: 6px; cursor: pointer; transition: all 0.15s ease;">Konto erstellen</button>
<button type="button" id="auth-tab-login" style="flex: 1; padding: 8px; font-size: 0.84rem; font-weight: 700; border: none; border-radius: 6px; cursor: pointer; transition: all 0.15s ease;">Anmelden</button>
${allowReg ? `
<button type="button" id="auth-tab-register" style="flex: 1; padding: 8px; font-size: 0.84rem; font-weight: 700; border: none; border-radius: 6px; cursor: pointer; transition: all 0.15s ease;">Konto erstellen</button>
` : `
<button type="button" disabled style="flex: 1; padding: 8px; font-size: 0.84rem; font-weight: 600; border: none; border-radius: 6px; color: #94a3b8; cursor: not-allowed; background: transparent;">Registrierung pausiert</button>
`}
</div>
<form id="auth-form" style="display: flex; flex-direction: column; gap: 13px;">
<div id="auth-field-name">
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--text-main); margin-bottom: 4px;">Dein vollständiger Name:</label>
<input type="text" id="auth-input-name" style="width: 100%; padding: 9px 12px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 0.92rem; outline: none;" placeholder="z.B. Alex Müller">
<div id="auth-field-name" style="display: none;">
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #0f172a; margin-bottom: 4px;">Dein vollständiger Name:</label>
<input type="text" id="auth-input-name" style="width: 100%; padding: 9px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 0.92rem; outline: none; box-sizing: border-box;" placeholder="z.B. Alex Müller">
</div>
<div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--text-main); margin-bottom: 4px;">E-Mail-Adresse:</label>
<input type="email" id="auth-input-email" required style="width: 100%; padding: 9px 12px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 0.92rem; outline: none;" placeholder="name@beispiel.de">
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #0f172a; margin-bottom: 4px;">E-Mail-Adresse:</label>
<input type="email" id="auth-input-email" required style="width: 100%; padding: 9px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 0.92rem; outline: none; box-sizing: border-box;" placeholder="name@beispiel.de">
</div>
<div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--text-main); margin-bottom: 4px;">Passwort:</label>
<input type="password" id="auth-input-password" required minlength="6" style="width: 100%; padding: 9px 12px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 0.92rem; outline: none;" placeholder="Mindestens 6 Zeichen">
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #0f172a; margin-bottom: 4px;">Passwort:</label>
<input type="password" id="auth-input-password" required minlength="6" style="width: 100%; padding: 9px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 0.92rem; outline: none; box-sizing: border-box;" placeholder="Mindestens 6 Zeichen">
</div>
<div id="auth-field-role">
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--text-main); margin-bottom: 4px;">Deine Rolle / Lernziel:</label>
<select id="auth-select-role" style="width: 100%; padding: 9px 12px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 0.88rem; outline: none; background: white;">
<div id="auth-field-role" style="display: none;">
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: #0f172a; margin-bottom: 4px;">Deine Rolle / Lernziel:</label>
<select id="auth-select-role" style="width: 100%; padding: 9px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 0.88rem; outline: none; background: white; box-sizing: border-box;">
<option value="solo">🎓 Solo-Selbstlerner & Quereinsteiger</option>
<option value="student">💼 Auszubildender Fachinformatiker (FIAE / FISI)</option>
<option value="teacher">👨‍🏫 Lehrkraft / Dozent / Ausbilder</option>
</select>
</div>
<!-- Session-Einstellung (Standard: nicht angehakt für Login-Zwang nach Schließen des Browsers) -->
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
<input type="checkbox" id="auth-remember-me" style="width: 16px; height: 16px; cursor: pointer;">
<label for="auth-remember-me" style="font-size: 0.82rem; color: #64748b; cursor: pointer;">Angemeldet bleiben (dauerhafte Sitzung)</label>
</div>
<div id="auth-error-box" style="display: none; background: #fef2f2; border: 1px solid #fecaca; color: #b91c1c; padding: 9px 12px; border-radius: 6px; font-size: 0.82rem;"></div>
<button type="submit" id="auth-submit-btn" class="btn" style="width: 100%; justify-content: center; padding: 11px; font-size: 0.92rem; font-weight: 800; background: #0284c7; color: white; border-radius: 8px; margin-top: 4px; border: none; cursor: pointer;">
Jetzt kostenlos registrieren &rarr;
<button type="submit" id="auth-submit-btn" style="width: 100%; justify-content: center; padding: 11px; font-size: 0.92rem; font-weight: 800; background: #0284c7; color: white; border-radius: 8px; margin-top: 4px; border: none; cursor: pointer;">
Anmelden &rarr;
</button>
${!allowClose ? `
<a href="${window.location.origin}/index.html" class="btn btn-secondary" style="width: 100%; justify-content: center; padding: 9px; font-size: 0.86rem; text-decoration: none; border-radius: 8px;">
<a href="${window.location.origin}/index.html" style="width: 100%; text-align: center; padding: 9px; font-size: 0.86rem; text-decoration: none; border-radius: 8px; color: #64748b; background: #f1f5f9; display: block; box-sizing: border-box;">
&larr; Zurück zur Startseite
</a>
` : `
<button type="button" class="btn btn-secondary" id="auth-cancel-btn" style="width: 100%; justify-content: center; padding: 9px; font-size: 0.86rem; border-radius: 8px;">
<button type="button" id="auth-cancel-btn" style="width: 100%; text-align: center; padding: 9px; font-size: 0.86rem; border-radius: 8px; color: #64748b; background: #f1f5f9; border: none; cursor: pointer;">
Abbrechen
</button>
`}
</form>
<div style="margin-top: 14px; text-align: center; font-size: 0.76rem; color: #94a3b8;">
🔒 100% DSGVO-konform • Deutsche Server • Keine Kreditkarte erforderlich
🔒 100% DSGVO-konform • Deutsche Server • Keine Tracking-Cookies
</div>
</div>
`;
document.body.appendChild(modal);
let isRegister = (initialMode === 'register');
const tabReg = document.getElementById('auth-tab-register');
let isRegister = (initialMode === 'register' && allowReg);
const tabLog = document.getElementById('auth-tab-login');
const tabReg = document.getElementById('auth-tab-register');
const nameField = document.getElementById('auth-field-name');
const roleField = document.getElementById('auth-field-role');
const submitBtn = document.getElementById('auth-submit-btn');
@ -335,11 +521,13 @@
const form = document.getElementById('auth-form');
function switchMode(reg) {
isRegister = reg;
isRegister = reg && allowReg;
if (isRegister) {
tabReg.style.background = '#ffffff';
tabReg.style.color = '#0284c7';
tabReg.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)';
if (tabReg) {
tabReg.style.background = '#ffffff';
tabReg.style.color = '#0284c7';
tabReg.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)';
}
tabLog.style.background = 'transparent';
tabLog.style.color = '#64748b';
tabLog.style.boxShadow = 'none';
@ -352,9 +540,11 @@
tabLog.style.background = '#ffffff';
tabLog.style.color = '#0284c7';
tabLog.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)';
tabReg.style.background = 'transparent';
tabReg.style.color = '#64748b';
tabReg.style.boxShadow = 'none';
if (tabReg) {
tabReg.style.background = 'transparent';
tabReg.style.color = '#64748b';
tabReg.style.boxShadow = 'none';
}
nameField.style.display = 'none';
roleField.style.display = 'none';
@ -364,8 +554,8 @@
errorBox.style.display = 'none';
}
tabReg.onclick = () => switchMode(true);
tabLog.onclick = () => switchMode(false);
if (tabReg) tabReg.onclick = () => switchMode(true);
switchMode(isRegister);
if (allowClose) {
@ -386,15 +576,16 @@
const password = document.getElementById('auth-input-password').value;
const name = document.getElementById('auth-input-name')?.value.trim() || email.split('@')[0];
const role = document.getElementById('auth-select-role')?.value || 'solo';
const rememberMe = document.getElementById('auth-remember-me')?.checked || false;
submitBtn.disabled = true;
submitBtn.innerText = 'Bitte warten...';
let result;
if (isRegister) {
result = await window.AUTH.register(name, email, password, role);
result = await window.AUTH.register(name, email, password, role, rememberMe);
} else {
result = await window.AUTH.login(email, password);
result = await window.AUTH.login(email, password, rememberMe);
}
submitBtn.disabled = false;
@ -405,8 +596,8 @@
if (options.onSuccess) {
options.onSuccess(result.user);
} else {
// Wenn auf der Startseite: UI aktualisieren
window.AUTH.updateUI();
window.location.reload();
}
} else {
errorBox.innerText = result.error || 'Fehler bei der Authentifizierung';
@ -415,9 +606,10 @@
};
};
// URL-Parameter prüfen (z.B. ?auth=login oder ?auth=register)
// Initialisierung beim Laden des DOMs
document.addEventListener('DOMContentLoaded', () => {
window.AUTH.updateUI();
window.AUTH.checkStatus();
window.AUTH.fetchMe();
const params = new URLSearchParams(window.location.search);

109
server.py
View file

@ -24,6 +24,7 @@ import os
import re
import uuid
import urllib.parse
import ipaddress
from pathlib import Path
from collections import defaultdict
@ -32,6 +33,14 @@ BASE_DIR = Path(__file__).parent.resolve()
DB_FILE = BASE_DIR / "platform_data.db"
KEY_FILE = BASE_DIR / ".secret_key"
def is_trusted_source(ip: str) -> bool:
"""Verhindert direkte Zugriffe am Reverse Proxy vorbei."""
try:
addr = ipaddress.ip_address(ip)
return addr.is_loopback or addr.is_private
except ValueError:
return False
# ------------------------------------------------------------------------------
# 1. PERSISTENTER SECRET KEY
# ------------------------------------------------------------------------------
@ -161,6 +170,20 @@ def init_db():
conn.commit()
conn.close()
def get_platform_setting(key: str, default: str = "") -> str:
"""Liest einen Einstellungswert sicher aus der Datenbank."""
try:
conn = get_db()
row = conn.execute("SELECT value FROM platform_settings WHERE key = ?", (key,)).fetchone()
conn.close()
return str(row["value"]) if row and row["value"] is not None else default
except Exception:
return default
def is_maintenance_active() -> bool:
"""Prüft, ob der globale Wartungsmodus aktiv ist."""
return get_platform_setting("maintenance_mode", "0") == "1"
# ------------------------------------------------------------------------------
# 4. AUTH & HASHING
# ------------------------------------------------------------------------------
@ -185,7 +208,7 @@ def generate_token(user_id: int, email: str, role: str) -> str:
"uid": user_id,
"email": email,
"role": role,
"exp": int(time.time()) + (86400 * 30)
"exp": int(time.time()) + 43200 # 12 Stunden Token-Gültigkeit (Sitzungsbegrenzung für Alpha-Test)
}
payload_json = json.dumps(payload, sort_keys=True)
sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_json.encode("utf-8"), hashlib.sha256).hexdigest()
@ -245,18 +268,26 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
auth_header = self.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
return verify_token(token)
verified = verify_token(token)
if not verified:
return None
try:
conn = get_db()
row = conn.execute("SELECT id, email, name, role, xp, level, streak_days FROM users WHERE id = ?", (verified["uid"],)).fetchone()
conn.close()
if not row:
return None
data = dict(row)
data["uid"] = row["id"]
return data
except Exception:
return None
return None
def get_admin_user(self) -> dict | None:
user = self.get_auth_user()
if not user:
return None
conn = get_db()
row = conn.execute("SELECT id, email, name, role FROM users WHERE id = ?", (user["uid"],)).fetchone()
conn.close()
if row and row["role"] == "admin":
return dict(row)
if user and user.get("role") == "admin":
return user
return None
def read_json_body(self) -> dict:
@ -267,24 +298,50 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
return json.loads(body.decode("utf-8"))
def is_path_blocked(self, path: str) -> bool:
clean_path = path.split("?")[0].lower()
blocked_extensions = (".db", ".sqlite", ".secret_key", ".py", ".php", ".jsonl")
clean_path = urllib.parse.unquote(path).split("?")[0].lower()
clean_path = os.path.normpath(clean_path).replace("\\", "/")
blocked_extensions = (
".db", ".sqlite", ".sqlite3", ".secret_key", ".py", ".sh", ".php",
".jsonl", ".env", ".bak", ".sql", ".log", ".conf", ".ini", ".yaml", ".yml"
)
if any(clean_path.endswith(ext) for ext in blocked_extensions):
return True
if "/." in clean_path or clean_path.startswith("/."):
if "/." in clean_path or clean_path.startswith("."):
return True
if "platform_data.db" in clean_path or ".gamification.json" in clean_path:
if clean_path.startswith("/exams/") or clean_path == "/exams":
return True
if any(seg in clean_path for seg in ["/.git", "/.system_generated", "/.gemini", "platform_data.db", ".secret_key"]):
return True
return False
def do_GET(self):
# 0. Proxy-Schutz gegen direktes Ansprechen von Port 8008 von außen
if not is_trusted_source(self.client_address[0]):
return self.send_json({"error": "Direkter Serverzugriff verweigert"}, 403)
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if self.is_path_blocked(path):
return self.send_json({"error": "Zugriff verweigert (Geschützte Datei)"}, 403)
# 1. AUTH ME
# 1. ÖFFENTLICHER STATUS-ENDPUNKT (Wartungsmodus, Ankündigung, Registrierung)
if path == "/api/platform/status":
return self.send_json({
"maintenance": is_maintenance_active(),
"announcement": get_platform_setting("announcement_banner", ""),
"allow_registration": get_platform_setting("allow_registration", "1") == "1"
})
# 2. WARTUNGSMODUS-FILTER FÜR GET-APIS
if path.startswith("/api/"):
if is_maintenance_active() and not self.get_admin_user():
return self.send_json({
"error": "Die Plattform befindet sich derzeit im Wartungsmodus. Nur Administratoren haben Zugriff.",
"maintenance": True
}, 503)
# 3. AUTH ME
if path == "/api/auth/me":
user_token = self.get_auth_user()
if not user_token:
@ -483,6 +540,10 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
return super().do_GET()
def do_POST(self):
# 0. Proxy-Schutz gegen direktes Ansprechen von Port 8008 von außen
if not is_trusted_source(self.client_address[0]):
return self.send_json({"error": "Direkter Serverzugriff verweigert"}, 403)
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
client_ip = self.get_client_ip()
@ -492,8 +553,19 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
except Exception:
return self.send_json({"error": "Ungültiges JSON"}, 400)
# Globaler Wartungsmodus-Filter für schreibende Operationen (außer Login und Admin)
if path.startswith("/api/") and path != "/api/auth/login" and not path.startswith("/api/admin/"):
if is_maintenance_active() and not self.get_admin_user():
return self.send_json({
"error": "Die Plattform befindet sich im Wartungsmodus. Schreibaktionen sind derzeit gesperrt.",
"maintenance": True
}, 503)
# 1. REGISTRIERUNG
if path == "/api/auth/register":
if is_maintenance_active() or get_platform_setting("allow_registration", "1") != "1":
return self.send_json({"error": "Die Registrierung ist derzeit deaktiviert (Wartungsmodus oder Administrator-Sperre)."}, 403)
if not rate_limiter.is_allowed(f"reg_{client_ip}", max_requests=5, window_seconds=60):
return self.send_json({"error": "Zu viele Registrierungsversuche. Bitte warte eine Minute."}, 429)
@ -530,7 +602,7 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
finally:
conn.close()
# 2. LOGIN (mit Constant-Time Schutz gegen Enumeration)
# 2. LOGIN (mit Constant-Time Schutz gegen Enumeration & Wartungsmodus-Prüfung)
elif path == "/api/auth/login":
if not rate_limiter.is_allowed(f"login_{client_ip}", max_requests=8, window_seconds=60):
return self.send_json({"error": "Zu viele Login-Versuche. Bitte warte eine Minute."}, 429)
@ -549,6 +621,13 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
if not verify_password(password, user["password_hash"], user["salt"]):
return self.send_json({"error": "Ungültige E-Mail-Adresse oder Passwort"}, 401)
# Wartungsmodus: Nur Administratoren dürfen sich anmelden
if is_maintenance_active() and user["role"] != "admin":
return self.send_json({
"error": "Die Plattform befindet sich im Wartungsmodus. Der Login ist vorübergehend nur für Administratoren freigeschaltet.",
"maintenance": True
}, 503)
token = generate_token(user["id"], user["email"], user["role"])
return self.send_json({
"message": "Erfolgreich angemeldet!",

View file

@ -126,14 +126,26 @@
<script src="assets/app_auth.js"></script>
<script>
let ALL_CLASSROOMS = [];
let CURRENT_MATRIX = {};
function getAuthToken() {
return (window.AUTH && window.AUTH.token) || sessionStorage.getItem("auth_token") || localStorage.getItem("auth_token") || "";
}
function escapeHtml(text) {
if (!text) return "";
return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
async function loadClassrooms() {
const container = document.getElementById("classrooms-list-container");
const token = localStorage.getItem("auth_token");
const token = getAuthToken();
if (!token) {
container.innerHTML = `
@ -151,6 +163,7 @@
});
const data = await res.json();
const classrooms = data.classrooms || [];
ALL_CLASSROOMS = classrooms;
if (classrooms.length === 0) {
container.innerHTML = `
@ -175,20 +188,20 @@
</div>
<div style="display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap;">
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem;" onclick="loadClassMatrix(${cl.id}, '${escapeHtml(cl.name)}')">📊 Lernmatrix anzeigen</button>
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem;" onclick="exportClassCsv(${cl.id}, '${escapeHtml(cl.name)}')">📥 CSV-Export</button>
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem; color: var(--danger); border-color: var(--danger);" onclick="deleteClassroom(${cl.id}, '${escapeHtml(cl.name)}')">🗑️ Löschen</button>
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem;" onclick="loadClassMatrix(${cl.id})">📊 Lernmatrix anzeigen</button>
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem;" onclick="exportClassCsv(${cl.id})">📥 CSV-Export</button>
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.82rem; color: var(--danger); border-color: var(--danger);" onclick="deleteClassroom(${cl.id})">🗑️ Löschen</button>
</div>
<div id="matrix-container-${cl.id}" style="display: none; margin-top: 14px;"></div>
</div>
`).join("");
} catch (err) {
container.innerHTML = `<div class="box box-danger">Fehler beim Laden: ${err.message}</div>`;
container.innerHTML = `<div class="box box-danger">Fehler beim Laden: ${escapeHtml(err.message)}</div>`;
}
}
async function loadClassMatrix(classId, className) {
async function loadClassMatrix(classId) {
const target = document.getElementById(`matrix-container-${classId}`);
if (target.style.display === "block") {
target.style.display = "none";
@ -198,13 +211,14 @@
target.style.display = "block";
target.innerHTML = "Lade Schüler-Fortschritt...";
const token = localStorage.getItem("auth_token");
const token = getAuthToken();
try {
const res = await fetch(`/api/classrooms/matrix?classroom_id=${classId}`, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await res.json();
const matrix = data.matrix || [];
CURRENT_MATRIX[classId] = matrix;
if (matrix.length === 0) {
target.innerHTML = "<p style='color: var(--text-muted); font-size: 0.88rem;'>Noch keine Schüler in dieser Klasse beigetreten.</p>";
@ -231,22 +245,26 @@
<td><span style="font-weight: 700; color: #0284c7;">${m.student.xp} XP</span></td>
<td>🔥 ${m.student.streak_days} Tage</td>
<td><span style="background: #ecfdf5; color: #059669; padding: 2px 8px; border-radius: 99px; font-weight: 700; font-size: 0.8rem;">${Object.values(m.solved_chapters).filter(Boolean).length} / 27</span></td>
<td><button class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.75rem;" onclick="inspectStudentCode(${classId}, ${m.student.id}, '${escapeHtml(m.student.name)}')">🔍 Code ansehen</button></td>
<td><button class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.75rem;" onclick="inspectStudentCode(${classId}, ${m.student.id})">🔍 Code ansehen</button></td>
</tr>
`).join("")}
</tbody>
</table>
`;
} catch (e) {
target.innerHTML = `<span style="color: var(--danger);">Fehler: ${e.message}</span>`;
target.innerHTML = `<span style="color: var(--danger);">Fehler: ${escapeHtml(e.message)}</span>`;
}
}
async function inspectStudentCode(classId, studentId, studentName) {
async function inspectStudentCode(classId, studentId) {
const matrix = CURRENT_MATRIX[classId] || [];
const entry = matrix.find(m => m.student.id === studentId);
const studentName = entry ? entry.student.name : `#${studentId}`;
const chapterId = prompt("Welches Kapitel möchtest du inspizieren? (z.B. lehrpfad_1_grundlagen/01_erste_schritte_taschenrechner)", "lehrpfad_1_grundlagen/01_erste_schritte_taschenrechner");
if (!chapterId) return;
const token = localStorage.getItem("auth_token");
const token = getAuthToken();
try {
const res = await fetch(`/api/classrooms/student-code?classroom_id=${classId}&student_id=${studentId}&chapter_id=${encodeURIComponent(chapterId)}`, {
headers: { Authorization: `Bearer ${token}` }
@ -262,8 +280,10 @@
}
}
async function exportClassCsv(classId, className) {
const token = localStorage.getItem("auth_token");
async function exportClassCsv(classId) {
const cl = ALL_CLASSROOMS.find(c => c.id === classId);
const className = cl ? cl.name : `Klasse_${classId}`;
const token = getAuthToken();
try {
const res = await fetch(`/api/classrooms/matrix?classroom_id=${classId}`, {
headers: { Authorization: `Bearer ${token}` }
@ -274,7 +294,7 @@
let csv = "Name,E-Mail,Level,XP,Streak,Geloeste_Kapitel_Anzahl\n";
matrix.forEach(m => {
const solved = Object.values(m.solved_chapters).filter(Boolean).length;
csv += `"${m.student.name}","${m.student.email}",${m.student.level},${m.student.xp},${m.student.streak_days},${solved}\n`;
csv += `"${m.student.name.replace(/"/g, '""')}","${m.student.email.replace(/"/g, '""')}",${m.student.level},${m.student.xp},${m.student.streak_days},${solved}\n`;
});
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
@ -288,9 +308,11 @@
}
}
async function deleteClassroom(classId, className) {
async function deleteClassroom(classId) {
const cl = ALL_CLASSROOMS.find(c => c.id === classId);
const className = cl ? cl.name : `Klasse_${classId}`;
if (!confirm(`Möchtest du die Klasse '${className}' wirklich unwiderruflich löschen?`)) return;
const token = localStorage.getItem("auth_token");
const token = getAuthToken();
try {
await fetch("/api/classrooms/delete", {
method: "POST",
@ -307,7 +329,7 @@
document.getElementById("create-class-form").onsubmit = async (e) => {
e.preventDefault();
const name = document.getElementById("input-class-name").value.trim();
const token = localStorage.getItem("auth_token");
const token = getAuthToken();
if (!token) {
window.openAuthModal("login");

View file

@ -454,6 +454,17 @@
}
async function loadWorkspaceData() {
// 🔒 Strikter Zugangsschutz für den Alpha-Test
if (window.AUTH && typeof window.AUTH.requireAuth === "function") {
if (!window.AUTH.requireAuth({
allowClose: false,
title: "🔒 Anmeldung für die Web-IDE erforderlich",
message: "Um deinen Code-Editor zu laden und interaktiv zu programmieren, melde dich bitte mit deinem Test-Account an."
})) {
return;
}
}
try {
await loadCourseManifest();
setupNextChapterButton();