""" Authentication Endpoints for Mitai Jinkendo Handles login, logout, password reset, and profile authentication. """ import os import secrets import smtplib from typing import Optional from datetime import datetime, timedelta from email.mime.text import MIMEText from fastapi import APIRouter, HTTPException, Header, Depends from starlette.requests import Request from slowapi import Limiter from slowapi.util import get_remote_address from db import get_db, get_cursor from auth import hash_pin, verify_pin, make_token, require_auth from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm router = APIRouter(prefix="/api/auth", tags=["auth"]) limiter = Limiter(key_func=get_remote_address) @router.post("/login") @limiter.limit("5/minute") async def login(req: LoginRequest, request: Request): """Login with email + password.""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),)) prof = cur.fetchone() if not prof: raise HTTPException(401, "Ungültige Zugangsdaten") # Verify password if not verify_pin(req.password, prof['pin_hash']): raise HTTPException(401, "Ungültige Zugangsdaten") # Auto-upgrade from SHA256 to bcrypt if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'): new_hash = hash_pin(req.password) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id'])) # Create session token = make_token() session_days = prof.get('session_days', 30) expires = datetime.now() + timedelta(days=session_days) cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", (token, prof['id'], expires.isoformat())) return { "token": token, "profile_id": prof['id'], "name": prof['name'], "role": prof['role'], "expires_at": expires.isoformat() } @router.post("/logout") def logout(x_auth_token: Optional[str]=Header(default=None)): """Logout (delete session).""" if x_auth_token: with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,)) return {"ok": True} @router.get("/me") def get_me(session: dict=Depends(require_auth)): """Get current user info.""" pid = session['profile_id'] # Import here to avoid circular dependency from routers.profiles import get_profile return get_profile(pid, session) @router.get("/status") def auth_status(): """Health check endpoint.""" return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"} @router.put("/pin") def change_pin(req: dict, session: dict=Depends(require_auth)): """Change PIN/password for current user.""" pid = session['profile_id'] new_pin = req.get('pin', '') if len(new_pin) < 4: raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") new_hash = hash_pin(new_pin) with get_db() as conn: cur = get_cursor(conn) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) return {"ok": True} @router.post("/forgot-password") @limiter.limit("3/minute") async def password_reset_request(req: PasswordResetRequest, request: Request): """Request password reset email.""" email = req.email.lower().strip() with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,)) prof = cur.fetchone() if not prof: # Don't reveal if email exists return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} # Generate reset token token = secrets.token_urlsafe(32) expires = datetime.now() + timedelta(hours=1) # Store in sessions table (reuse mechanism) cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", (f"reset_{token}", prof['id'], expires.isoformat())) # Send email try: smtp_host = os.getenv("SMTP_HOST") smtp_port = int(os.getenv("SMTP_PORT", 587)) smtp_user = os.getenv("SMTP_USER") smtp_pass = os.getenv("SMTP_PASS") smtp_from = os.getenv("SMTP_FROM") app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") if smtp_host and smtp_user and smtp_pass: msg = MIMEText(f"""Hallo {prof['name']}, Du hast einen Passwort-Reset angefordert. Reset-Link: {app_url}/reset-password?token={token} Der Link ist 1 Stunde gültig. Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. Dein Mitai Jinkendo Team """) msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo" msg['From'] = smtp_from msg['To'] = email with smtplib.SMTP(smtp_host, smtp_port) as server: server.starttls() server.login(smtp_user, smtp_pass) server.send_message(msg) except Exception as e: print(f"Email error: {e}") return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} @router.post("/reset-password") def password_reset_confirm(req: PasswordResetConfirm): """Confirm password reset with token.""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP", (f"reset_{req.token}",)) sess = cur.fetchone() if not sess: raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link") pid = sess['profile_id'] new_hash = hash_pin(req.new_password) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",)) return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}