diff --git a/src/auth.py b/src/auth.py index 233f08b..d79148e 100644 --- a/src/auth.py +++ b/src/auth.py @@ -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)) diff --git a/src/email_utils/rate_limiter.py b/src/email_utils/rate_limiter.py index 57c596c..6381bf3 100644 --- a/src/email_utils/rate_limiter.py +++ b/src/email_utils/rate_limiter.py @@ -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() diff --git a/src/email_utils/templates.py b/src/email_utils/templates.py index 83f2b7d..adcd0e1 100644 --- a/src/email_utils/templates.py +++ b/src/email_utils/templates.py @@ -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

Hallo {username},

-

Klicken Sie auf den Link oder geben Sie den Code ein, um sich anzumelden:

- -
-
{code}
-
+

Klicken Sie auf den Button, um sich anzumelden:

- Jetzt anmelden + Jetzt anmelden
+

Oder kopieren Sie diesen Link in Ihren Browser:

+

{link}

+

Dieser Link ist 10 Minuten gueltig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.

@@ -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", diff --git a/src/models.py b/src/models.py index f16b53b..2766984 100644 --- a/src/models.py +++ b/src/models.py @@ -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 diff --git a/src/routers/auth.py b/src/routers/auth.py index 8145ee0..f60ecae 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -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), diff --git a/src/static/index.html b/src/static/index.html index 1cffb20..06ad975 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -35,20 +35,20 @@ - -