Auth: Nur noch Magic Link, Code-Verifizierung entfernt

- /api/auth/verify-code Endpoint entfernt
- generate_magic_code() und VerifyCodeRequest entfernt
- VerifyCodeLimiter (Brute-Force-Schutz) entfernt (nicht mehr noetig)
- E-Mail-Template: Nur noch Anmelde-Link, kein 6-stelliger Code
- Login-Seite: Zeigt nach E-Mail-Eingabe Hinweis statt Code-Feld
- Magic Link Token-Verifikation via URL bleibt bestehen
Dieser Commit ist enthalten in:
Claude Dev
2026-03-25 00:01:19 +01:00
Ursprung 5789cc1706
Commit 8f1a45c1a9
6 geänderte Dateien mit 35 neuen und 213 gelöschten Zeilen

Datei anzeigen

@@ -1,6 +1,5 @@
"""JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy.""" """JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy."""
import secrets import secrets
import string
from datetime import datetime, timedelta from datetime import datetime, timedelta
from jose import jwt, JWTError from jose import jwt, JWTError
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
@@ -77,7 +76,3 @@ def generate_magic_token() -> str:
"""Generiert einen 64-Zeichen URL-safe Token.""" """Generiert einen 64-Zeichen URL-safe Token."""
return secrets.token_urlsafe(48) return secrets.token_urlsafe(48)
def generate_magic_code() -> str:
"""Generiert einen 6-stelligen numerischen Code."""
return ''.join(secrets.choice(string.digits) for _ in range(6))

Datei anzeigen

@@ -1,4 +1,4 @@
"""In-Memory Rate-Limiting fuer Magic-Link-Anfragen und Code-Verifizierung.""" """In-Memory Rate-Limiting fuer Magic-Link-Anfragen."""
import time import time
from collections import defaultdict from collections import defaultdict
@@ -51,52 +51,5 @@ class RateLimiter:
self._ip_requests[ip].append(now) self._ip_requests[ip].append(now)
class VerifyCodeLimiter: # Singleton-Instanz
"""Rate-Limiter fuer Code-Verifizierung (Brute-Force-Schutz).
Zaehlt Fehlversuche pro E-Mail und pro IP.
Nach max_attempts wird gesperrt bis das Zeitfenster ablaeuft.
"""
def __init__(
self,
max_attempts_per_email: int = 5,
max_attempts_per_ip: int = 15,
window_seconds: int = 600, # 10 Minuten (= Magic-Link-Ablaufzeit)
):
self.max_per_email = max_attempts_per_email
self.max_per_ip = max_attempts_per_ip
self.window = window_seconds
self._email_failures: dict[str, list[float]] = defaultdict(list)
self._ip_failures: dict[str, list[float]] = defaultdict(list)
def _clean(self, entries: list[float]) -> list[float]:
cutoff = time.time() - self.window
return [t for t in entries if t > cutoff]
def check(self, email: str, ip: str) -> tuple[bool, str]:
"""Prueft ob ein Verifizierungsversuch erlaubt ist."""
self._email_failures[email] = self._clean(self._email_failures[email])
if len(self._email_failures[email]) >= self.max_per_email:
return False, "Zu viele Fehlversuche. Bitte neuen Code anfordern."
self._ip_failures[ip] = self._clean(self._ip_failures[ip])
if len(self._ip_failures[ip]) >= self.max_per_ip:
return False, "Zu viele Fehlversuche von dieser IP-Adresse."
return True, ""
def record_failure(self, email: str, ip: str):
"""Zeichnet einen fehlgeschlagenen Versuch auf."""
now = time.time()
self._email_failures[email].append(now)
self._ip_failures[ip].append(now)
def clear(self, email: str):
"""Loescht Zaehler nach erfolgreichem Login."""
self._email_failures.pop(email, None)
# Singleton-Instanzen
magic_link_limiter = RateLimiter() magic_link_limiter = RateLimiter()
verify_code_limiter = VerifyCodeLimiter()

Datei anzeigen

@@ -1,8 +1,8 @@
"""HTML-E-Mail-Vorlagen fuer Magic Links, Einladungen und Benachrichtigungen.""" """HTML-E-Mail-Vorlagen fuer Magic Links, Einladungen und Benachrichtigungen."""
def magic_link_login_email(username: str, code: str, link: str) -> tuple[str, str]: def magic_link_login_email(username: str, link: str) -> tuple[str, str]:
"""Erzeugt Login-E-Mail mit Magic Link und Code. """Erzeugt Login-E-Mail mit Magic Link.
Returns: Returns:
(subject, html_body) (subject, html_body)
@@ -17,16 +17,15 @@ def magic_link_login_email(username: str, code: str, link: str) -> tuple[str, st
<p style="margin: 0 0 16px 0;">Hallo {username},</p> <p style="margin: 0 0 16px 0;">Hallo {username},</p>
<p style="margin: 0 0 24px 0;">Klicken Sie auf den Link oder geben Sie den Code ein, um sich anzumelden:</p> <p style="margin: 0 0 24px 0;">Klicken Sie auf den Button, um sich anzumelden:</p>
<div style="background: #0f172a; border-radius: 8px; padding: 20px; text-align: center; margin: 0 0 24px 0;">
<div style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #f0b429; font-family: monospace;">{code}</div>
</div>
<div style="text-align: center; margin: 0 0 24px 0;"> <div style="text-align: center; margin: 0 0 24px 0;">
<a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">Jetzt anmelden</a> <a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 14px 40px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
</div> </div>
<p style="color: #94a3b8; font-size: 13px; margin: 0 0 12px 0;">Oder kopieren Sie diesen Link in Ihren Browser:</p>
<p style="color: #64748b; font-size: 11px; word-break: break-all; margin: 0 0 24px 0;">{link}</p>
<p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist 10 Minuten gueltig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p> <p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist 10 Minuten gueltig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
</div> </div>
</body> </body>
@@ -54,10 +53,10 @@ def incident_notification_email(
subject = f"AegisSight - {incident_title}" subject = f"AegisSight - {incident_title}"
icon_map = { icon_map = {
"success": "&#10003;", # Haekchen "success": "&#10003;",
"warning": "&#9888;", # Warndreieck "warning": "&#9888;",
"error": "&#10007;", # Kreuz "error": "&#10007;",
"info": "&#9432;", # Info-Kreis "info": "&#9432;",
} }
color_map = { color_map = {
"success": "#22c55e", "success": "#22c55e",

Datei anzeigen

@@ -18,10 +18,6 @@ class VerifyTokenRequest(BaseModel):
token: str token: str
class VerifyCodeRequest(BaseModel):
email: str = Field(min_length=1, max_length=254)
code: str = Field(min_length=6, max_length=6)
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str

Datei anzeigen

@@ -6,7 +6,6 @@ from models import (
MagicLinkRequest, MagicLinkRequest,
MagicLinkResponse, MagicLinkResponse,
VerifyTokenRequest, VerifyTokenRequest,
VerifyCodeRequest,
TokenResponse, TokenResponse,
UserMeResponse, UserMeResponse,
) )
@@ -14,13 +13,12 @@ from auth import (
create_token, create_token,
get_current_user, get_current_user,
generate_magic_token, generate_magic_token,
generate_magic_code,
) )
from database import db_dependency from database import db_dependency
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
from email_utils.sender import send_email from email_utils.sender import send_email
from email_utils.templates import magic_link_login_email from email_utils.templates import magic_link_login_email
from email_utils.rate_limiter import magic_link_limiter, verify_code_limiter from email_utils.rate_limiter import magic_link_limiter
import aiosqlite import aiosqlite
logger = logging.getLogger("osint.auth") logger = logging.getLogger("osint.auth")
@@ -34,7 +32,7 @@ async def request_magic_link(
request: Request, request: Request,
db: aiosqlite.Connection = Depends(db_dependency), db: aiosqlite.Connection = Depends(db_dependency),
): ):
"""Magic Link anfordern. Sendet E-Mail mit Link + Code.""" """Magic Link anfordern. Sendet E-Mail mit Link."""
email = data.email.lower().strip() email = data.email.lower().strip()
ip = request.client.host if request.client else "unknown" ip = request.client.host if request.client else "unknown"
@@ -42,7 +40,6 @@ async def request_magic_link(
allowed, reason = magic_link_limiter.check(email, ip) allowed, reason = magic_link_limiter.check(email, ip)
if not allowed: if not allowed:
logger.warning(f"Rate-Limit fuer {email} von {ip}: {reason}") logger.warning(f"Rate-Limit fuer {email} von {ip}: {reason}")
# Trotzdem 200 zurueckgeben (kein Information-Leak)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.") return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
# Nutzer suchen # Nutzer suchen
@@ -75,9 +72,8 @@ async def request_magic_link(
magic_link_limiter.record(email, ip) magic_link_limiter.record(email, ip)
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.") return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
# Token + Code generieren # Token generieren
token = generate_magic_token() token = generate_magic_token()
code = generate_magic_code()
expires_at = (datetime.now(TIMEZONE) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S') expires_at = (datetime.now(TIMEZONE) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S')
# Alte ungenutzte Magic Links fuer diese E-Mail invalidieren # Alte ungenutzte Magic Links fuer diese E-Mail invalidieren
@@ -89,14 +85,14 @@ async def request_magic_link(
# Neuen Magic Link speichern # Neuen Magic Link speichern
await db.execute( await db.execute(
"""INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address) """INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address)
VALUES (?, ?, ?, 'login', ?, ?, ?)""", VALUES (?, ?, '', 'login', ?, ?, ?)""",
(email, token, code, user["id"], expires_at, ip), (email, token, user["id"], expires_at, ip),
) )
await db.commit() await db.commit()
# E-Mail senden # E-Mail senden
link = f"{MAGIC_LINK_BASE_URL}/?token={token}" link = f"{MAGIC_LINK_BASE_URL}/?token={token}"
subject, html = magic_link_login_email(user["email"].split("@")[0], code, link) subject, html = magic_link_login_email(user["email"].split("@")[0], link)
await send_email(email, subject, html) await send_email(email, subject, html)
magic_link_limiter.record(email, ip) magic_link_limiter.record(email, ip)
@@ -160,84 +156,6 @@ async def verify_magic_link(
) )
@router.post("/verify-code", response_model=TokenResponse)
async def verify_magic_code(
data: VerifyCodeRequest,
request: Request,
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Magic Code verifizieren (6-stelliger Code + E-Mail)."""
email = data.email.lower().strip()
ip = request.client.host if request.client else "unknown"
# Brute-Force-Schutz: Fehlversuche pruefen
allowed, reason = verify_code_limiter.check(email, ip)
if not allowed:
logger.warning(f"Verify-Code Rate-Limit fuer {email} von {ip}: {reason}")
# Bei Sperre alle offenen Magic Links fuer diese E-Mail invalidieren
await db.execute(
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
(email,),
)
await db.commit()
raise HTTPException(status_code=429, detail=reason)
cursor = await db.execute(
"""SELECT ml.*, u.username, u.email as user_email, u.role, u.organization_id, u.is_active,
o.slug as org_slug, o.is_active as org_active
FROM magic_links ml
JOIN users u ON u.id = ml.user_id
JOIN organizations o ON o.id = u.organization_id
WHERE LOWER(ml.email) = ? AND ml.code = ? AND ml.is_used = 0
ORDER BY ml.created_at DESC LIMIT 1""",
(email, data.code),
)
ml = await cursor.fetchone()
if not ml:
verify_code_limiter.record_failure(email, ip)
logger.warning(f"Fehlgeschlagener Code-Versuch fuer {email} von {ip}")
raise HTTPException(status_code=400, detail="Ungueltiger Code")
# Ablauf pruefen
now = datetime.now(TIMEZONE)
expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None:
expires = expires.replace(tzinfo=TIMEZONE)
if now > expires:
raise HTTPException(status_code=400, detail="Code abgelaufen. Bitte neuen Code anfordern.")
if not ml["is_active"] or not ml["org_active"]:
raise HTTPException(status_code=403, detail="Konto oder Organisation deaktiviert")
# Magic Link als verwendet markieren
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (ml["id"],))
# Letzten Login aktualisieren
await db.execute(
"UPDATE users SET last_login_at = ? WHERE id = ?",
(now.isoformat(), ml["user_id"]),
)
await db.commit()
# Fehlversuche-Zaehler nach Erfolg zuruecksetzen
verify_code_limiter.clear(email)
token = create_token(
user_id=ml["user_id"],
username=ml["username"],
email=ml["user_email"],
role=ml["role"],
tenant_id=ml["organization_id"],
org_slug=ml["org_slug"],
)
return TokenResponse(
access_token=token,
username=ml["username"],
)
@router.get("/me", response_model=UserMeResponse) @router.get("/me", response_model=UserMeResponse)
async def get_me( async def get_me(
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),

Datei anzeigen

@@ -35,20 +35,20 @@
<button type="submit" class="btn btn-primary btn-full" id="email-btn">Anmelden</button> <button type="submit" class="btn btn-primary btn-full" id="email-btn">Anmelden</button>
</form> </form>
<!-- Schritt 2: Code eingeben --> <!-- Schritt 2: Link gesendet -->
<form id="code-form" style="display:none;"> <div id="link-sent" style="display:none;">
<p style="color: var(--text-secondary); margin: 0 0 16px 0; font-size: 14px;"> <div style="text-align:center; padding: 20px 0;">
Ein 6-stelliger Code wurde an <strong id="sent-email"></strong> gesendet. <div style="font-size: 40px; margin-bottom: 16px;">&#9993;</div>
</p> <p style="color: var(--text-secondary); margin: 0 0 8px 0; font-size: 14px;">
<div class="form-group"> Ein Anmelde-Link wurde an
<label for="code">Code eingeben</label> </p>
<input type="text" id="code" name="code" autocomplete="one-time-code" required aria-required="true" <p style="color: var(--accent); font-weight: 600; font-size: 16px; margin: 0 0 16px 0;" id="sent-email"></p>
placeholder="000000" maxlength="6" pattern="[0-9]{6}" <p style="color: var(--text-secondary); margin: 0 0 24px 0; font-size: 14px;">
style="text-align:center; font-size:24px; letter-spacing:8px; font-family:monospace;"> gesendet. Bitte prüfen Sie Ihr Postfach und klicken Sie auf den Link.
</p>
</div> </div>
<button type="submit" class="btn btn-primary btn-full" id="code-btn">Verifizieren</button> <button type="button" class="btn btn-secondary btn-full" id="back-btn">Andere E-Mail verwenden</button>
<button type="button" class="btn btn-secondary btn-full" id="back-btn" style="margin-top:8px;">Zurück</button> </div>
</form>
<div style="text-align:center;margin-top:16px;"> <div style="text-align:center;margin-top:16px;">
<button class="btn btn-secondary btn-small theme-toggle-btn" id="theme-toggle" onclick="ThemeManager.toggle()" title="Theme wechseln" aria-label="Theme wechseln">&#9788;</button> <button class="btn btn-secondary btn-small theme-toggle-btn" id="theme-toggle" onclick="ThemeManager.toggle()" title="Theme wechseln" aria-label="Theme wechseln">&#9788;</button>
@@ -148,11 +148,10 @@
throw new Error(data.detail || 'Anfrage fehlgeschlagen'); throw new Error(data.detail || 'Anfrage fehlgeschlagen');
} }
// Zu Code-Eingabe wechseln // Link-gesendet-Hinweis anzeigen
document.getElementById('email-form').style.display = 'none'; document.getElementById('email-form').style.display = 'none';
document.getElementById('code-form').style.display = 'block'; document.getElementById('link-sent').style.display = 'block';
document.getElementById('sent-email').textContent = currentEmail; document.getElementById('sent-email').textContent = currentEmail;
document.getElementById('code').focus();
} catch (err) { } catch (err) {
errorEl.textContent = err.message; errorEl.textContent = err.message;
@@ -163,49 +162,11 @@
} }
}); });
// Schritt 2: Code verifizieren
document.getElementById('code-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
const btn = document.getElementById('code-btn');
errorEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'Wird geprüft...';
try {
const response = await fetch('/api/auth/verify-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: currentEmail,
code: document.getElementById('code').value.trim(),
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Verifizierung fehlgeschlagen');
}
const data = await response.json();
localStorage.setItem('osint_token', data.access_token);
localStorage.setItem('osint_username', data.username);
window.location.href = '/dashboard';
} catch (err) {
errorEl.textContent = err.message;
errorEl.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Verifizieren';
}
});
// Zurück-Button // Zurück-Button
document.getElementById('back-btn').addEventListener('click', () => { document.getElementById('back-btn').addEventListener('click', () => {
document.getElementById('code-form').style.display = 'none'; document.getElementById('link-sent').style.display = 'none';
document.getElementById('email-form').style.display = 'block'; document.getElementById('email-form').style.display = 'block';
document.getElementById('login-error').style.display = 'none'; document.getElementById('login-error').style.display = 'none';
document.getElementById('code').value = '';
}); });
</script> </script>
</body> </body>