feat(security): implement robust maintenance mode, session login guard, and comprehensive security hardening
This commit is contained in:
parent
e49c685f25
commit
af4b1dc460
5 changed files with 456 additions and 133 deletions
53
admin.html
53
admin.html
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
return String(t)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ''');
|
||||
}
|
||||
|
||||
// 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 →
|
||||
<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 →
|
||||
</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;">
|
||||
← 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
109
server.py
|
|
@ -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!",
|
||||
|
|
|
|||
58
teacher.html
58
teacher.html
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
return String(text)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue