Auth: Verwaltung auf Magic-Link umstellen (Passwort-Login entfernt)

Backend:
- src/routers/auth.py NEU: POST /api/auth/magic-link + POST /api/auth/verify
- src/auth.py: verify_password/hash_password raus, generate_magic_token rein
- src/main.py: alter Login-Endpoint + Brute-Force-Logik raus, neuer auth-Router eingebunden
- src/config.py: ALLOWED_EMAIL + PORTAL_MAGIC_LINK_* hinzu
- src/models.py: LoginRequest raus, MagicLinkRequest etc. rein
- src/email_utils/templates.py: portal_magic_link_email Template

Frontend:
- src/static/index.html: Email-Eingabe statt Passwort, Token-Verify-Logik fuer ?token= aus URL

Datenbank-Migration (migrations/2026-05-09_portal_magic_link.py):
- portal_magic_links + portal_magic_link_attempts neu
- portal_login_attempts gedroppt
- portal_admins.email Spalte hinzu, password_hash geleert

Whitelist info@aegis-sight.de, Rate-Limit 5/15 Min, Anti-Enumeration generische Antwort.
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 02:21:40 +00:00
Ursprung e6fdc5cfa0
Commit 7c741062a9
9 geänderte Dateien mit 482 neuen und 151 gelöschten Zeilen

Datei anzeigen

@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AegisSight Monitor-Verwaltung - Login</title>
<meta name="robots" content="noindex, nofollow">
<title>AegisSight Monitor-Verwaltung - Anmeldung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
@@ -16,68 +17,143 @@
<p class="subtitle">Monitor-Verwaltung</p>
</div>
<form id="loginForm" class="login-form">
<!-- Schritt 1: Email-Eingabe -->
<form id="magicForm" class="login-form">
<div class="form-group">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" required autocomplete="email" autofocus
placeholder="info@aegis-sight.de">
</div>
<div id="loginError" class="error-msg" style="display:none"></div>
<button type="submit" class="btn btn-primary btn-full" id="loginBtn">Anmelden</button>
<button type="submit" class="btn btn-primary btn-full" id="magicBtn">Login-Link anfordern</button>
<p class="form-hint" style="margin-top:14px;text-align:center;font-size:12px;color:#94a3b8;">
Wir senden dir einen einmaligen Login-Link per E-Mail.
</p>
</form>
<!-- Schritt 2: Bestätigung nach Versand -->
<div id="sentInfo" style="display:none;text-align:center;">
<div style="font-size:42px;margin:8px 0 16px 0;">&#9993;</div>
<h2 style="font-size:17px;margin:0 0 8px 0;">E-Mail unterwegs</h2>
<p style="margin:0 0 18px 0;font-size:14px;color:#94a3b8;line-height:1.5;">
Wenn die Adresse berechtigt ist, hast du gleich einen Login-Link in deinem Posteingang.
Der Link ist 10 Minuten gültig.
</p>
<button type="button" class="btn btn-secondary btn-full" onclick="resetForm()">Andere E-Mail-Adresse</button>
</div>
<!-- Schritt 3: Verify (während Token-Prüfung) -->
<div id="verifying" style="display:none;text-align:center;">
<div class="spinner" style="margin:8px auto 16px;"></div>
<h2 style="font-size:17px;margin:0 0 8px 0;">Anmeldung wird geprüft...</h2>
<p style="margin:0;font-size:14px;color:#94a3b8;">Einen Moment bitte.</p>
</div>
</div>
</div>
<script>
const form = document.getElementById('loginForm');
const errorEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
<style>
.spinner {
width: 36px; height: 36px;
border: 3px solid rgba(240,180,41,0.2);
border-top-color: #f0b429;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.form-hint { font-size: 12px; color: #94a3b8; margin-top: 8px; }
</style>
<script>
const form = document.getElementById('magicForm');
const sentInfo = document.getElementById('sentInfo');
const verifying = document.getElementById('verifying');
const errorEl = document.getElementById('loginError');
const btn = document.getElementById('magicBtn');
function resetForm() {
sentInfo.style.display = 'none';
verifying.style.display = 'none';
form.style.display = '';
errorEl.style.display = 'none';
document.getElementById('email').value = '';
document.getElementById('email').focus();
}
function showError(msg) {
form.style.display = '';
sentInfo.style.display = 'none';
verifying.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
// --- Magic-Link anfordern ---
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'Anmeldung...';
btn.textContent = 'Sende...';
try {
const res = await fetch('/api/auth/login', {
const res = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
body: JSON.stringify({ email: document.getElementById('email').value }),
});
if (res.status === 429) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Zu viele Fehlversuche. Bitte 15 Minuten warten.');
}
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Anmeldung fehlgeschlagen');
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `Fehler ${res.status}`);
}
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('username', data.username);
window.location.href = '/dashboard';
// Erfolg (oder generisch): Bestätigungsanzeige
form.style.display = 'none';
sentInfo.style.display = '';
} catch (err) {
errorEl.textContent = err.message;
errorEl.style.display = 'block';
showError(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Anmelden';
btn.textContent = 'Login-Link anfordern';
}
});
// Redirect if already logged in
if (localStorage.getItem('token')) {
// --- Token aus URL verifizieren (Schritt 3) ---
async function verifyTokenFromUrl() {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) return;
form.style.display = 'none';
verifying.style.display = '';
try {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Login-Link ungültig');
}
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('username', data.username);
if (data.email) localStorage.setItem('email', data.email);
// Token aus URL entfernen, damit er nicht im Verlauf liegt
window.history.replaceState({}, '', '/');
window.location.href = '/dashboard';
} catch (err) {
showError(err.message);
// Token aus URL entfernen bei Fehler
window.history.replaceState({}, '', '/');
}
}
// Schon eingeloggt? -> direkt aufs Dashboard
if (localStorage.getItem('token') && !window.location.search.includes('token=')) {
window.location.href = '/dashboard';
} else {
verifyTokenFromUrl();
}
</script>
</body>