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

View file

@ -1,8 +1,11 @@
/** /**
* 🔐 AUTHENTIFIZIERUNG & ZUGANGSSCHUTZ (AUTH GUARD) 🔐 * 🔐 AUTHENTIFIZIERUNG & ZUGANGSSCHUTZ (AUTH GUARD) 🔐
* ======================================================= * =======================================================
* IT-Praxisportal – Benutzer-Authentifizierung und Schutz aller Kurse & Web-IDE. * IT-Praxisportal – Benutzer-Authentifizierung, Wartungsmodus und Zugangsschutz.
* Zugriff auf Kurse, Lerneinheiten und Web-IDE erfordert ein registriertes Benutzerkonto. * - 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 () { (function () {
@ -18,85 +21,117 @@
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
// Initialisiere Auth-Objekt // Session-Speicher auslesen (sessionStorage bevorzugt für Sitzungsbindung im Test)
const initialUser = JSON.parse(localStorage.getItem('auth_user') || 'null'); function getStoredToken() {
// Alte Gast-Sitzungen ungültig machen return sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token') || null;
if (initialUser && initialUser.isGuest) { }
localStorage.removeItem('auth_user');
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_token');
localStorage.removeItem('auth_user');
} }
window.AUTH = { window.AUTH = {
token: localStorage.getItem('auth_token') || null, token: getStoredToken(),
user: (initialUser && !initialUser.isGuest) ? initialUser : null, user: getStoredUser(),
platformStatus: { maintenance: false, announcement: '', allow_registration: true },
isLoggedIn() { isLoggedIn() {
return !!(this.token && this.user && !this.user.isGuest && this.user.email); return !!(this.token && this.user && !this.user.isGuest && this.user.email);
}, },
setSession(token, user) { isAdmin() {
this.token = token; return !!(this.isLoggedIn() && this.user && this.user.role === 'admin');
this.user = user;
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
this.updateUI();
}, },
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.token = null;
this.user = null; this.user = null;
sessionStorage.removeItem('auth_token');
sessionStorage.removeItem('auth_user');
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user'); localStorage.removeItem('auth_user');
this.updateUI(); this.updateUI();
// Wenn auf einer geschützten Kursseite, zur Startseite leiten if (redirect) {
const p = window.location.pathname; const p = window.location.pathname;
if (p.includes('/courses/') || p.includes('/lehrpfad_') || p.endsWith('workspace.html')) { 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'; 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 { try {
const res = await fetch('/api/auth/register', { const res = await fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password, role }) body: JSON.stringify({ name, email, password, role })
}); });
const data = await res.json(); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen'); 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 }; return { success: true, user: data.user };
} catch (err) { } catch (err) {
// Lokaler Fallback falls Backend temporär nicht erreichbar return { success: false, error: 'Netzwerkfehler: Backend-Server 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 };
} }
}, },
async login(email, password) { async login(email, password, rememberMe = false) {
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })
}); });
const data = await res.json(); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Anmeldung fehlgeschlagen');
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 }; return { success: true, user: data.user };
} catch (err) { } catch (err) {
console.warn('API-Login fehlgeschlagen, versuche lokalen Fallback:', err.message); return { success: false, error: 'Netzwerkfehler: Backend-Server nicht erreichbar.' };
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' };
} }
}, },
@ -106,17 +141,156 @@
const res = await fetch('/api/auth/me', { const res = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${this.token}` } headers: { 'Authorization': `Bearer ${this.token}` }
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
this.user = data.user; this.user = data.user;
if (localStorage.getItem('auth_token')) {
localStorage.setItem('auth_user', JSON.stringify(data.user)); localStorage.setItem('auth_user', JSON.stringify(data.user));
} else {
sessionStorage.setItem('auth_user', JSON.stringify(data.user));
}
this.updateUI(); 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) { } 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 = {}) { requireAuth(options = {}) {
if (this.isLoggedIn()) { if (this.isLoggedIn()) {
return true; return true;
@ -126,10 +300,10 @@
}, },
showAuthBarrier(options = {}) { showAuthBarrier(options = {}) {
window.openAuthModal(options.mode || 'register', { window.openAuthModal(options.mode || 'login', {
allowClose: options.allowClose !== undefined ? options.allowClose : false, allowClose: options.allowClose !== undefined ? options.allowClose : false,
title: options.title || '🔒 Registrierung erforderlich', title: options.title || '🔒 Anmeldung 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.', message: options.message || 'Dieser Lernbereich erfordert ein Benutzerkonto. Bitte melde dich an oder erstelle einen kostenlosen Account.',
onSuccess: () => { onSuccess: () => {
if (options.onSuccess) options.onSuccess(); if (options.onSuccess) options.onSuccess();
else window.location.reload(); else window.location.reload();
@ -239,18 +413,20 @@
}; };
// Auth Modal UI // Auth Modal UI
window.openAuthModal = function (initialMode = 'register', options = {}) { window.openAuthModal = function (initialMode = 'login', options = {}) {
const allowClose = options.allowClose !== false; const allowClose = options.allowClose !== false;
let modal = document.getElementById('auth-modal-overlay'); let modal = document.getElementById('auth-modal-overlay');
if (modal) modal.remove(); if (modal) modal.remove();
const allowReg = window.AUTH.platformStatus.allow_registration !== false && !window.AUTH.platformStatus.maintenance;
modal = document.createElement('div'); modal = document.createElement('div');
modal.id = 'auth-modal-overlay'; modal.id = 'auth-modal-overlay';
modal.className = 'certificate-overlay'; modal.className = 'certificate-overlay';
modal.style.zIndex = '99999'; modal.style.zIndex = '999999';
modal.innerHTML = ` 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 ? ` ${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> <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="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 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>
<div style="font-size: 1.15rem; font-weight: 800; color: var(--text-main);" id="auth-modal-title"> <div style="font-size: 1.15rem; font-weight: 800; color: #0f172a;" id="auth-modal-title">
${options.title || (initialMode === 'register' ? '✨ Kostenlos registrieren' : '🔑 Anmelden')} ${escapeHtml(options.title || (initialMode === 'register' && allowReg ? '✨ Kostenlos registrieren' : '🔑 Anmelden'))}
</div> </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>
</div> </div>
<p id="auth-modal-desc" style="font-size: 0.88rem; color: var(--text-muted); margin-bottom: 18px; line-height: 1.5;"> <p id="auth-modal-desc" style="font-size: 0.88rem; color: #64748b; 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.'} ${escapeHtml(options.message || 'Erhalte sofortigen Zugriff auf 13 Praxis-Kurse, 216 interaktive Module und die Web-IDE.')}
</p> </p>
<!-- Mode Tabs --> <!-- Mode Tabs -->
<div style="display: flex; background: #f1f5f9; border-radius: 8px; padding: 4px; margin-bottom: 18px;"> <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> <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> </div>
<form id="auth-form" style="display: flex; flex-direction: column; gap: 13px;"> <form id="auth-form" style="display: flex; flex-direction: column; gap: 13px;">
<div id="auth-field-name"> <div id="auth-field-name" style="display: none;">
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--text-main); margin-bottom: 4px;">Dein vollständiger Name:</label> <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 var(--border-color); border-radius: 8px; font-size: 0.92rem; outline: none;" placeholder="z.B. Alex Müller"> <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>
<div> <div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--text-main); margin-bottom: 4px;">E-Mail-Adresse:</label> <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 var(--border-color); border-radius: 8px; font-size: 0.92rem; outline: none;" placeholder="name@beispiel.de"> <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>
<div> <div>
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--text-main); margin-bottom: 4px;">Passwort:</label> <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 var(--border-color); border-radius: 8px; font-size: 0.92rem; outline: none;" placeholder="Mindestens 6 Zeichen"> <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>
<div id="auth-field-role"> <div id="auth-field-role" style="display: none;">
<label style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--text-main); margin-bottom: 4px;">Deine Rolle / Lernziel:</label> <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 var(--border-color); border-radius: 8px; font-size: 0.88rem; outline: none; background: white;"> <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="solo">🎓 Solo-Selbstlerner & Quereinsteiger</option>
<option value="student">💼 Auszubildender Fachinformatiker (FIAE / FISI)</option> <option value="student">💼 Auszubildender Fachinformatiker (FIAE / FISI)</option>
<option value="teacher">👨‍🏫 Lehrkraft / Dozent / Ausbilder</option> <option value="teacher">👨‍🏫 Lehrkraft / Dozent / Ausbilder</option>
</select> </select>
</div> </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> <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;"> <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;">
Jetzt kostenlos registrieren &rarr; Anmelden &rarr;
</button> </button>
${!allowClose ? ` ${!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 &larr; Zurück zur Startseite
</a> </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 Abbrechen
</button> </button>
`} `}
</form> </form>
<div style="margin-top: 14px; text-align: center; font-size: 0.76rem; color: #94a3b8;"> <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>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
let isRegister = (initialMode === 'register'); let isRegister = (initialMode === 'register' && allowReg);
const tabReg = document.getElementById('auth-tab-register');
const tabLog = document.getElementById('auth-tab-login'); const tabLog = document.getElementById('auth-tab-login');
const tabReg = document.getElementById('auth-tab-register');
const nameField = document.getElementById('auth-field-name'); const nameField = document.getElementById('auth-field-name');
const roleField = document.getElementById('auth-field-role'); const roleField = document.getElementById('auth-field-role');
const submitBtn = document.getElementById('auth-submit-btn'); const submitBtn = document.getElementById('auth-submit-btn');
@ -335,11 +521,13 @@
const form = document.getElementById('auth-form'); const form = document.getElementById('auth-form');
function switchMode(reg) { function switchMode(reg) {
isRegister = reg; isRegister = reg && allowReg;
if (isRegister) { if (isRegister) {
if (tabReg) {
tabReg.style.background = '#ffffff'; tabReg.style.background = '#ffffff';
tabReg.style.color = '#0284c7'; tabReg.style.color = '#0284c7';
tabReg.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)'; tabReg.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)';
}
tabLog.style.background = 'transparent'; tabLog.style.background = 'transparent';
tabLog.style.color = '#64748b'; tabLog.style.color = '#64748b';
tabLog.style.boxShadow = 'none'; tabLog.style.boxShadow = 'none';
@ -352,9 +540,11 @@
tabLog.style.background = '#ffffff'; tabLog.style.background = '#ffffff';
tabLog.style.color = '#0284c7'; tabLog.style.color = '#0284c7';
tabLog.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)'; tabLog.style.boxShadow = '0 1px 3px rgba(0,0,0,0.1)';
if (tabReg) {
tabReg.style.background = 'transparent'; tabReg.style.background = 'transparent';
tabReg.style.color = '#64748b'; tabReg.style.color = '#64748b';
tabReg.style.boxShadow = 'none'; tabReg.style.boxShadow = 'none';
}
nameField.style.display = 'none'; nameField.style.display = 'none';
roleField.style.display = 'none'; roleField.style.display = 'none';
@ -364,8 +554,8 @@
errorBox.style.display = 'none'; errorBox.style.display = 'none';
} }
tabReg.onclick = () => switchMode(true);
tabLog.onclick = () => switchMode(false); tabLog.onclick = () => switchMode(false);
if (tabReg) tabReg.onclick = () => switchMode(true);
switchMode(isRegister); switchMode(isRegister);
if (allowClose) { if (allowClose) {
@ -386,15 +576,16 @@
const password = document.getElementById('auth-input-password').value; const password = document.getElementById('auth-input-password').value;
const name = document.getElementById('auth-input-name')?.value.trim() || email.split('@')[0]; const name = document.getElementById('auth-input-name')?.value.trim() || email.split('@')[0];
const role = document.getElementById('auth-select-role')?.value || 'solo'; const role = document.getElementById('auth-select-role')?.value || 'solo';
const rememberMe = document.getElementById('auth-remember-me')?.checked || false;
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.innerText = 'Bitte warten...'; submitBtn.innerText = 'Bitte warten...';
let result; let result;
if (isRegister) { if (isRegister) {
result = await window.AUTH.register(name, email, password, role); result = await window.AUTH.register(name, email, password, role, rememberMe);
} else { } else {
result = await window.AUTH.login(email, password); result = await window.AUTH.login(email, password, rememberMe);
} }
submitBtn.disabled = false; submitBtn.disabled = false;
@ -405,8 +596,8 @@
if (options.onSuccess) { if (options.onSuccess) {
options.onSuccess(result.user); options.onSuccess(result.user);
} else { } else {
// Wenn auf der Startseite: UI aktualisieren
window.AUTH.updateUI(); window.AUTH.updateUI();
window.location.reload();
} }
} else { } else {
errorBox.innerText = result.error || 'Fehler bei der Authentifizierung'; 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', () => { document.addEventListener('DOMContentLoaded', () => {
window.AUTH.updateUI(); window.AUTH.updateUI();
window.AUTH.checkStatus();
window.AUTH.fetchMe(); window.AUTH.fetchMe();
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);

109
server.py
View file

@ -24,6 +24,7 @@ import os
import re import re
import uuid import uuid
import urllib.parse import urllib.parse
import ipaddress
from pathlib import Path from pathlib import Path
from collections import defaultdict from collections import defaultdict
@ -32,6 +33,14 @@ BASE_DIR = Path(__file__).parent.resolve()
DB_FILE = BASE_DIR / "platform_data.db" DB_FILE = BASE_DIR / "platform_data.db"
KEY_FILE = BASE_DIR / ".secret_key" 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 # 1. PERSISTENTER SECRET KEY
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -161,6 +170,20 @@ def init_db():
conn.commit() conn.commit()
conn.close() 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 # 4. AUTH & HASHING
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -185,7 +208,7 @@ def generate_token(user_id: int, email: str, role: str) -> str:
"uid": user_id, "uid": user_id,
"email": email, "email": email,
"role": role, "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) payload_json = json.dumps(payload, sort_keys=True)
sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_json.encode("utf-8"), hashlib.sha256).hexdigest() 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", "") auth_header = self.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header[7:].strip() 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 return None
def get_admin_user(self) -> dict | None: def get_admin_user(self) -> dict | None:
user = self.get_auth_user() user = self.get_auth_user()
if not user: if user and user.get("role") == "admin":
return None return user
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)
return None return None
def read_json_body(self) -> dict: def read_json_body(self) -> dict:
@ -267,24 +298,50 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
return json.loads(body.decode("utf-8")) return json.loads(body.decode("utf-8"))
def is_path_blocked(self, path: str) -> bool: def is_path_blocked(self, path: str) -> bool:
clean_path = path.split("?")[0].lower() clean_path = urllib.parse.unquote(path).split("?")[0].lower()
blocked_extensions = (".db", ".sqlite", ".secret_key", ".py", ".php", ".jsonl") 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): if any(clean_path.endswith(ext) for ext in blocked_extensions):
return True return True
if "/." in clean_path or clean_path.startswith("/."): if "/." in clean_path or clean_path.startswith("."):
return True 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 True
return False return False
def do_GET(self): 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) parsed = urllib.parse.urlparse(self.path)
path = parsed.path path = parsed.path
if self.is_path_blocked(path): if self.is_path_blocked(path):
return self.send_json({"error": "Zugriff verweigert (Geschützte Datei)"}, 403) 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": if path == "/api/auth/me":
user_token = self.get_auth_user() user_token = self.get_auth_user()
if not user_token: if not user_token:
@ -483,6 +540,10 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
return super().do_GET() return super().do_GET()
def do_POST(self): 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) parsed = urllib.parse.urlparse(self.path)
path = parsed.path path = parsed.path
client_ip = self.get_client_ip() client_ip = self.get_client_ip()
@ -492,8 +553,19 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
except Exception: except Exception:
return self.send_json({"error": "Ungültiges JSON"}, 400) 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 # 1. REGISTRIERUNG
if path == "/api/auth/register": 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): 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) return self.send_json({"error": "Zu viele Registrierungsversuche. Bitte warte eine Minute."}, 429)
@ -530,7 +602,7 @@ class PlatformRequestHandler(http.server.SimpleHTTPRequestHandler):
finally: finally:
conn.close() 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": elif path == "/api/auth/login":
if not rate_limiter.is_allowed(f"login_{client_ip}", max_requests=8, window_seconds=60): 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) 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"]): if not verify_password(password, user["password_hash"], user["salt"]):
return self.send_json({"error": "Ungültige E-Mail-Adresse oder Passwort"}, 401) 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"]) token = generate_token(user["id"], user["email"], user["role"])
return self.send_json({ return self.send_json({
"message": "Erfolgreich angemeldet!", "message": "Erfolgreich angemeldet!",

View file

@ -126,14 +126,26 @@
<script src="assets/app_auth.js"></script> <script src="assets/app_auth.js"></script>
<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) { function escapeHtml(text) {
if (!text) return ""; 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() { async function loadClassrooms() {
const container = document.getElementById("classrooms-list-container"); const container = document.getElementById("classrooms-list-container");
const token = localStorage.getItem("auth_token"); const token = getAuthToken();
if (!token) { if (!token) {
container.innerHTML = ` container.innerHTML = `
@ -151,6 +163,7 @@
}); });
const data = await res.json(); const data = await res.json();
const classrooms = data.classrooms || []; const classrooms = data.classrooms || [];
ALL_CLASSROOMS = classrooms;
if (classrooms.length === 0) { if (classrooms.length === 0) {
container.innerHTML = ` container.innerHTML = `
@ -175,20 +188,20 @@
</div> </div>
<div style="display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap;"> <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="loadClassMatrix(${cl.id})">📊 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;" 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}, '${escapeHtml(cl.name)}')">🗑️ Löschen</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>
<div id="matrix-container-${cl.id}" style="display: none; margin-top: 14px;"></div> <div id="matrix-container-${cl.id}" style="display: none; margin-top: 14px;"></div>
</div> </div>
`).join(""); `).join("");
} catch (err) { } 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}`); const target = document.getElementById(`matrix-container-${classId}`);
if (target.style.display === "block") { if (target.style.display === "block") {
target.style.display = "none"; target.style.display = "none";
@ -198,13 +211,14 @@
target.style.display = "block"; target.style.display = "block";
target.innerHTML = "Lade Schüler-Fortschritt..."; target.innerHTML = "Lade Schüler-Fortschritt...";
const token = localStorage.getItem("auth_token"); const token = getAuthToken();
try { try {
const res = await fetch(`/api/classrooms/matrix?classroom_id=${classId}`, { const res = await fetch(`/api/classrooms/matrix?classroom_id=${classId}`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
const data = await res.json(); const data = await res.json();
const matrix = data.matrix || []; const matrix = data.matrix || [];
CURRENT_MATRIX[classId] = matrix;
if (matrix.length === 0) { 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>"; 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><span style="font-weight: 700; color: #0284c7;">${m.student.xp} XP</span></td>
<td>🔥 ${m.student.streak_days} Tage</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><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> </tr>
`).join("")} `).join("")}
</tbody> </tbody>
</table> </table>
`; `;
} catch (e) { } 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"); 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; if (!chapterId) return;
const token = localStorage.getItem("auth_token"); const token = getAuthToken();
try { try {
const res = await fetch(`/api/classrooms/student-code?classroom_id=${classId}&student_id=${studentId}&chapter_id=${encodeURIComponent(chapterId)}`, { const res = await fetch(`/api/classrooms/student-code?classroom_id=${classId}&student_id=${studentId}&chapter_id=${encodeURIComponent(chapterId)}`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
@ -262,8 +280,10 @@
} }
} }
async function exportClassCsv(classId, className) { async function exportClassCsv(classId) {
const token = localStorage.getItem("auth_token"); const cl = ALL_CLASSROOMS.find(c => c.id === classId);
const className = cl ? cl.name : `Klasse_${classId}`;
const token = getAuthToken();
try { try {
const res = await fetch(`/api/classrooms/matrix?classroom_id=${classId}`, { const res = await fetch(`/api/classrooms/matrix?classroom_id=${classId}`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
@ -274,7 +294,7 @@
let csv = "Name,E-Mail,Level,XP,Streak,Geloeste_Kapitel_Anzahl\n"; let csv = "Name,E-Mail,Level,XP,Streak,Geloeste_Kapitel_Anzahl\n";
matrix.forEach(m => { matrix.forEach(m => {
const solved = Object.values(m.solved_chapters).filter(Boolean).length; 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;" }); 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; if (!confirm(`Möchtest du die Klasse '${className}' wirklich unwiderruflich löschen?`)) return;
const token = localStorage.getItem("auth_token"); const token = getAuthToken();
try { try {
await fetch("/api/classrooms/delete", { await fetch("/api/classrooms/delete", {
method: "POST", method: "POST",
@ -307,7 +329,7 @@
document.getElementById("create-class-form").onsubmit = async (e) => { document.getElementById("create-class-form").onsubmit = async (e) => {
e.preventDefault(); e.preventDefault();
const name = document.getElementById("input-class-name").value.trim(); const name = document.getElementById("input-class-name").value.trim();
const token = localStorage.getItem("auth_token"); const token = getAuthToken();
if (!token) { if (!token) {
window.openAuthModal("login"); window.openAuthModal("login");

View file

@ -454,6 +454,17 @@
} }
async function loadWorkspaceData() { 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 { try {
await loadCourseManifest(); await loadCourseManifest();
setupNextChapterButton(); setupNextChapterButton();