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:
@@ -1,6 +1,5 @@
|
||||
"""JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy."""
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from jose import jwt, JWTError
|
||||
from fastapi import Depends, HTTPException, status
|
||||
@@ -77,7 +76,3 @@ def generate_magic_token() -> str:
|
||||
"""Generiert einen 64-Zeichen URL-safe Token."""
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -51,52 +51,5 @@ class RateLimiter:
|
||||
self._ip_requests[ip].append(now)
|
||||
|
||||
|
||||
class VerifyCodeLimiter:
|
||||
"""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
|
||||
# Singleton-Instanz
|
||||
magic_link_limiter = RateLimiter()
|
||||
verify_code_limiter = VerifyCodeLimiter()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""HTML-E-Mail-Vorlagen fuer Magic Links, Einladungen und Benachrichtigungen."""
|
||||
|
||||
|
||||
def magic_link_login_email(username: str, code: str, link: str) -> tuple[str, str]:
|
||||
"""Erzeugt Login-E-Mail mit Magic Link und Code.
|
||||
def magic_link_login_email(username: str, link: str) -> tuple[str, str]:
|
||||
"""Erzeugt Login-E-Mail mit Magic Link.
|
||||
|
||||
Returns:
|
||||
(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 24px 0;">Klicken Sie auf den Link oder geben Sie den Code ein, 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>
|
||||
<p style="margin: 0 0 24px 0;">Klicken Sie auf den Button, um sich anzumelden:</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</body>
|
||||
@@ -54,10 +53,10 @@ def incident_notification_email(
|
||||
subject = f"AegisSight - {incident_title}"
|
||||
|
||||
icon_map = {
|
||||
"success": "✓", # Haekchen
|
||||
"warning": "⚠", # Warndreieck
|
||||
"error": "✗", # Kreuz
|
||||
"info": "ⓘ", # Info-Kreis
|
||||
"success": "✓",
|
||||
"warning": "⚠",
|
||||
"error": "✗",
|
||||
"info": "ⓘ",
|
||||
}
|
||||
color_map = {
|
||||
"success": "#22c55e",
|
||||
|
||||
@@ -18,10 +18,6 @@ class VerifyTokenRequest(BaseModel):
|
||||
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):
|
||||
access_token: str
|
||||
|
||||
@@ -6,7 +6,6 @@ from models import (
|
||||
MagicLinkRequest,
|
||||
MagicLinkResponse,
|
||||
VerifyTokenRequest,
|
||||
VerifyCodeRequest,
|
||||
TokenResponse,
|
||||
UserMeResponse,
|
||||
)
|
||||
@@ -14,13 +13,12 @@ from auth import (
|
||||
create_token,
|
||||
get_current_user,
|
||||
generate_magic_token,
|
||||
generate_magic_code,
|
||||
)
|
||||
from database import db_dependency
|
||||
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
|
||||
from email_utils.sender import send_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
|
||||
|
||||
logger = logging.getLogger("osint.auth")
|
||||
@@ -34,7 +32,7 @@ async def request_magic_link(
|
||||
request: Request,
|
||||
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()
|
||||
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)
|
||||
if not allowed:
|
||||
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.")
|
||||
|
||||
# Nutzer suchen
|
||||
@@ -75,9 +72,8 @@ async def request_magic_link(
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
# Token + Code generieren
|
||||
# Token generieren
|
||||
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')
|
||||
|
||||
# Alte ungenutzte Magic Links fuer diese E-Mail invalidieren
|
||||
@@ -89,14 +85,14 @@ async def request_magic_link(
|
||||
# Neuen Magic Link speichern
|
||||
await db.execute(
|
||||
"""INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address)
|
||||
VALUES (?, ?, ?, 'login', ?, ?, ?)""",
|
||||
(email, token, code, user["id"], expires_at, ip),
|
||||
VALUES (?, ?, '', 'login', ?, ?, ?)""",
|
||||
(email, token, user["id"], expires_at, ip),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# E-Mail senden
|
||||
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)
|
||||
|
||||
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)
|
||||
async def get_me(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
|
||||
@@ -35,20 +35,20 @@
|
||||
<button type="submit" class="btn btn-primary btn-full" id="email-btn">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<!-- Schritt 2: Code eingeben -->
|
||||
<form id="code-form" style="display:none;">
|
||||
<p style="color: var(--text-secondary); margin: 0 0 16px 0; font-size: 14px;">
|
||||
Ein 6-stelliger Code wurde an <strong id="sent-email"></strong> gesendet.
|
||||
<!-- Schritt 2: Link gesendet -->
|
||||
<div id="link-sent" style="display:none;">
|
||||
<div style="text-align:center; padding: 20px 0;">
|
||||
<div style="font-size: 40px; margin-bottom: 16px;">✉</div>
|
||||
<p style="color: var(--text-secondary); margin: 0 0 8px 0; font-size: 14px;">
|
||||
Ein Anmelde-Link wurde an
|
||||
</p>
|
||||
<p style="color: var(--accent); font-weight: 600; font-size: 16px; margin: 0 0 16px 0;" id="sent-email"></p>
|
||||
<p style="color: var(--text-secondary); margin: 0 0 24px 0; font-size: 14px;">
|
||||
gesendet. Bitte prüfen Sie Ihr Postfach und klicken Sie auf den Link.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="code">Code eingeben</label>
|
||||
<input type="text" id="code" name="code" autocomplete="one-time-code" required aria-required="true"
|
||||
placeholder="000000" maxlength="6" pattern="[0-9]{6}"
|
||||
style="text-align:center; font-size:24px; letter-spacing:8px; font-family:monospace;">
|
||||
</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" style="margin-top:8px;">Zurück</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-secondary btn-full" id="back-btn">Andere E-Mail verwenden</button>
|
||||
</div>
|
||||
|
||||
<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">☼</button>
|
||||
@@ -148,11 +148,10 @@
|
||||
throw new Error(data.detail || 'Anfrage fehlgeschlagen');
|
||||
}
|
||||
|
||||
// Zu Code-Eingabe wechseln
|
||||
// Link-gesendet-Hinweis anzeigen
|
||||
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('code').focus();
|
||||
|
||||
} catch (err) {
|
||||
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
|
||||
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('login-error').style.display = 'none';
|
||||
document.getElementById('code').value = '';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren