diff --git a/requirements.txt b/requirements.txt index ea3951c..7a11b2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,7 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 httpx>=0.27 websockets>=13.0 +aiosqlite>=0.20 +python-jose[cryptography]>=3.3 +aiosmtplib>=3.0 +pydantic[email]>=2.0 diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..21b8f6a --- /dev/null +++ b/src/auth.py @@ -0,0 +1,77 @@ +"""Auth: JWT + Magic Link fuer Globe.""" +import secrets +import string +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from fastapi import Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS +from database import get_db + +security = HTTPBearer(auto_error=False) + + +def create_token(user_id: int, username: str, email: str, role: str = "member") -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "username": username, + "email": email, + "role": role, + "iss": "aegissight-globe", + "aud": "aegissight-globe", + "iat": now, + "exp": now + timedelta(hours=JWT_EXPIRE_HOURS), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def decode_token(token: str) -> dict: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM], audience="aegissight-globe") + + +# Also accept Monitor tokens (audience: intelsight-osint) +def decode_any_token(token: str) -> dict: + for aud in ["aegissight-globe", "intelsight-osint"]: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM], audience=aud) + except JWTError: + continue + raise JWTError("Invalid token") + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db=Depends(get_db), +) -> dict: + if not credentials: + raise HTTPException(status_code=401, detail="Nicht authentifiziert") + try: + payload = decode_any_token(credentials.credentials) + user_id = int(payload["sub"]) + except (JWTError, KeyError, ValueError): + raise HTTPException(status_code=401, detail="Token ungueltig") + + cursor = await db.execute( + "SELECT id, email, username, is_active, globe_access FROM users WHERE id = ?", + (user_id,), + ) + user = await cursor.fetchone() + if not user: + raise HTTPException(status_code=401, detail="Nutzer nicht gefunden") + if not user["is_active"]: + raise HTTPException(status_code=403, detail="Account deaktiviert") + if not user["globe_access"]: + raise HTTPException(status_code=403, detail="Kein Globe-Zugang") + + return dict(user) + + +def generate_magic_token() -> str: + return secrets.token_urlsafe(48) + + +def generate_magic_code() -> str: + return "".join(secrets.choice(string.digits) for _ in range(6)) diff --git a/src/auth_router.py b/src/auth_router.py new file mode 100644 index 0000000..d282865 --- /dev/null +++ b/src/auth_router.py @@ -0,0 +1,137 @@ +"""Auth-Router: Magic Link Login fuer Globe.""" +import logging +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr + +from auth import create_token, generate_magic_token, generate_magic_code, get_current_user +from config import GLOBE_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES +from database import get_db +from email_utils import send_magic_link_email + +logger = logging.getLogger("globe.auth") +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + email: EmailStr + + +class CodeVerifyRequest(BaseModel): + email: EmailStr + code: str + + +@router.post("/request-link") +async def request_magic_link(req: LoginRequest, db=Depends(get_db)): + """Sendet Magic Link + Code per E-Mail.""" + email = req.email.lower().strip() + + # User pruefen + cursor = await db.execute( + "SELECT id, username, email, is_active, globe_access FROM users WHERE LOWER(email) = ?", + (email,), + ) + user = await cursor.fetchone() + if not user: + raise HTTPException(status_code=404, detail="Kein Account mit dieser E-Mail-Adresse gefunden.") + if not user["is_active"]: + raise HTTPException(status_code=403, detail="Account ist deaktiviert.") + if not user["globe_access"]: + raise HTTPException(status_code=403, detail="Kein Globe-Zugang. Bitte wenden Sie sich an Ihren Administrator.") + + # Magic Token + Code erzeugen + token = generate_magic_token() + code = generate_magic_code() + expires = datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES) + + await db.execute( + """INSERT INTO magic_links (user_id, token, code, expires_at, purpose) + VALUES (?, ?, ?, ?, 'globe_login')""", + (user["id"], token, code, expires.isoformat()), + ) + await db.commit() + + link = f"{GLOBE_BASE_URL}/api/auth/verify?token={token}" + + try: + await send_magic_link_email(email, code, link) + except Exception: + raise HTTPException(status_code=502, detail="E-Mail konnte nicht gesendet werden.") + + logger.info(f"Magic Link gesendet an {email}") + return {"ok": True, "message": "Zugangscode wurde per E-Mail gesendet."} + + +@router.get("/verify") +async def verify_token(token: str, db=Depends(get_db)): + """Verifiziert Magic Link Token, gibt JWT zurueck.""" + cursor = await db.execute( + """SELECT ml.user_id, ml.expires_at, ml.is_used, + u.username, u.email, u.is_active, u.globe_access, u.role + FROM magic_links ml JOIN users u ON ml.user_id = u.id + WHERE ml.token = ? AND ml.purpose = 'globe_login'""", + (token,), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=400, detail="Ungueltiger Link.") + if row["is_used"]: + raise HTTPException(status_code=400, detail="Link wurde bereits verwendet.") + if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="Link ist abgelaufen.") + if not row["is_active"] or not row["globe_access"]: + raise HTTPException(status_code=403, detail="Kein Zugang.") + + await db.execute("UPDATE magic_links SET is_used = 1 WHERE token = ?", (token,)) + await db.execute("UPDATE users SET last_login_at = ? WHERE id = ?", + (datetime.now(timezone.utc).isoformat(), row["user_id"])) + await db.commit() + + jwt_token = create_token(row["user_id"], row["username"], row["email"], row["role"]) + + # Redirect zum Frontend mit Token als Query-Parameter + from fastapi.responses import HTMLResponse + return HTMLResponse(f""" +
+ """) + + +@router.post("/verify-code") +async def verify_code(req: CodeVerifyRequest, db=Depends(get_db)): + """Verifiziert 6-stelligen Code, gibt JWT zurueck.""" + email = req.email.lower().strip() + cursor = await db.execute( + """SELECT ml.id, ml.user_id, ml.expires_at, ml.is_used, + u.username, u.email, u.is_active, u.globe_access, u.role + FROM magic_links ml JOIN users u ON ml.user_id = u.id + WHERE ml.code = ? AND LOWER(u.email) = ? AND ml.purpose = 'globe_login' + ORDER BY ml.created_at DESC LIMIT 1""", + (req.code, email), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=400, detail="Ungueltiger Code.") + if row["is_used"]: + raise HTTPException(status_code=400, detail="Code wurde bereits verwendet.") + if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="Code ist abgelaufen.") + if not row["is_active"] or not row["globe_access"]: + raise HTTPException(status_code=403, detail="Kein Zugang.") + + await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (row["id"],)) + await db.execute("UPDATE users SET last_login_at = ? WHERE id = ?", + (datetime.now(timezone.utc).isoformat(), row["user_id"])) + await db.commit() + + jwt_token = create_token(row["user_id"], row["username"], row["email"], row["role"]) + return {"ok": True, "token": jwt_token} + + +@router.get("/me") +async def get_me(user: dict = Depends(get_current_user)): + return {"id": user["id"], "email": user["email"], "username": user["username"]} diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..52248f3 --- /dev/null +++ b/src/config.py @@ -0,0 +1,17 @@ +"""Globe Konfiguration.""" +import os + +DB_PATH = os.environ.get("DB_PATH", "/mnt/gitea/osint-data/osint.db") +JWT_SECRET = os.environ.get("JWT_SECRET", "") +JWT_ALGORITHM = "HS256" +JWT_EXPIRE_HOURS = 24 + +SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.ionos.de") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) +SMTP_USER = os.environ.get("SMTP_USER", "info@aegis-sight.de") +SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") +SMTP_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL", "noreply@aegis-sight.de") +SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "AegisSight Globe") + +GLOBE_BASE_URL = os.environ.get("GLOBE_BASE_URL", "https://globe.aegis-sight.de") +MAGIC_LINK_EXPIRE_MINUTES = 10 diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..0610ad0 --- /dev/null +++ b/src/database.py @@ -0,0 +1,12 @@ +"""Datenbank-Zugriff auf geteilte osint.db.""" +import aiosqlite +from config import DB_PATH + + +async def get_db(): + db = await aiosqlite.connect(DB_PATH) + db.row_factory = aiosqlite.Row + try: + yield db + finally: + await db.close() diff --git a/src/email_utils.py b/src/email_utils.py new file mode 100644 index 0000000..bc7eca9 --- /dev/null +++ b/src/email_utils.py @@ -0,0 +1,50 @@ +"""E-Mail-Versand fuer Globe Magic Links.""" +import logging +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from config import SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_EMAIL, SMTP_FROM_NAME + +logger = logging.getLogger("globe.email") + + +async def send_magic_link_email(to_email: str, code: str, link: str): + """Sendet Magic Link + Code per E-Mail.""" + html = f""" +Dein Zugangscode:
+
+ Oder klicke auf diesen Link:
+ {link}
+
+ Dieser Code ist 10 Minuten gueltig. Falls du diese Anfrage nicht gesendet hast, ignoriere diese E-Mail. +
+