Вход по почте (magic-code через SMTP)

- /api/auth/email/{start,verify}: 6-значный код на email (TTL 10мин, лимит
  1/мин, до 5 попыток), аккаунт email:<addr> + cookie-сессия
- _send_email (smtplib SSL/STARTTLS), таблица WEB_EMAIL_CODES
- Фронт: пункт «Через почту» в меню + модалка (email → код → вход), i18n

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-03 09:08:08 +00:00
parent 836784e56f
commit b0d2580c37
2 changed files with 231 additions and 0 deletions

View File

@@ -66,6 +66,13 @@ SESSION_DAYS = 30
VK_CLIENT_ID = os.getenv("VK_CLIENT_ID", "")
VK_CLIENT_SECRET = os.getenv("VK_CLIENT_SECRET", "")
VK_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/vk/callback"
# ── Вход по почте (magic-code через SMTP) ─────────────────────────────────────
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "")
SMTP_FROM = os.getenv("SMTP_FROM", "") or SMTP_USER
EMAIL_CODE_TTL = 600 # 10 минут на ввод кода
# Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ)
UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы
@@ -280,6 +287,15 @@ def _db_ensure_auth_table():
expires_at REAL
)
""")
# Коды для входа по почте (magic-code).
con.execute("""
CREATE TABLE IF NOT EXISTS WEB_EMAIL_CODES (
email TEXT PRIMARY KEY,
code TEXT,
created_at REAL,
attempts INTEGER DEFAULT 0
)
""")
# Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro/file/sub.
con.execute("""
CREATE TABLE IF NOT EXISTS WEB_PAYMENTS (
@@ -1996,6 +2012,101 @@ async def vk_callback(request: Request, code: str = "", state: str = "", device_
return resp
# ── Вход по почте (magic-code) ────────────────────────────────────────────────
import smtplib as _smtplib
import ssl as _ssl
from email.mime.text import MIMEText as _MIMEText
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_email_rate: dict = {} # email → ts последней отправки (rate-limit)
def _send_email(to: str, subject: str, body: str):
if not (SMTP_HOST and SMTP_USER and SMTP_PASS):
raise RuntimeError("SMTP не настроен")
msg = _MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = to
ctx = _ssl.create_default_context()
if SMTP_PORT == 465:
with _smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ctx, timeout=20) as s:
s.login(SMTP_USER, SMTP_PASS)
s.sendmail(SMTP_FROM, [to], msg.as_string())
else:
with _smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s:
s.starttls(context=ctx)
s.login(SMTP_USER, SMTP_PASS)
s.sendmail(SMTP_FROM, [to], msg.as_string())
@app.post("/api/auth/email/start")
async def email_start(payload: dict):
if not (SMTP_HOST and SMTP_USER):
raise HTTPException(status_code=503, detail="Вход по почте не настроен.")
email = (payload.get("email") or "").strip().lower()[:128] if isinstance(payload, dict) else ""
if not _EMAIL_RE.match(email):
raise HTTPException(status_code=400, detail="Некорректный email")
now = time.time()
if now - _email_rate.get(email, 0) < 60:
raise HTTPException(status_code=429, detail="Код уже отправлен, подождите минуту.")
code = f"{secrets.randbelow(1000000):06d}"
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute(
"INSERT INTO WEB_EMAIL_CODES (email, code, created_at, attempts) VALUES (?,?,?,0) "
"ON CONFLICT(email) DO UPDATE SET code=excluded.code, created_at=excluded.created_at, attempts=0",
(email, code, now))
con.commit()
except Exception:
raise HTTPException(status_code=500, detail="Ошибка БД")
try:
await asyncio.to_thread(
_send_email, email, "Код входа pdf2scan",
f"Ваш код для входа на pdf2scan.online: {code}\n\nКод действует 10 минут.\nЕсли вы не запрашивали вход — проигнорируйте письмо.")
except Exception as e:
print(f"email send error: {e}")
raise HTTPException(status_code=502, detail="Не удалось отправить письмо. Попробуйте позже.")
_email_rate[email] = now
return {"ok": True}
@app.post("/api/auth/email/verify")
async def email_verify(payload: dict):
email = (payload.get("email") or "").strip().lower()[:128] if isinstance(payload, dict) else ""
code = (payload.get("code") or "").strip()[:6]
if not _EMAIL_RE.match(email) or not code.isdigit():
raise HTTPException(status_code=400, detail="Некорректные данные")
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
cur = con.execute("SELECT code, created_at, attempts FROM WEB_EMAIL_CODES WHERE email=?", (email,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Код не найден. Запросите новый.")
saved, created, attempts = row
if time.time() - (created or 0) > EMAIL_CODE_TTL:
raise HTTPException(status_code=400, detail="Код истёк. Запросите новый.")
if (attempts or 0) >= 5:
raise HTTPException(status_code=429, detail="Слишком много попыток. Запросите новый код.")
if code != saved:
con.execute("UPDATE WEB_EMAIL_CODES SET attempts=attempts+1 WHERE email=?", (email,))
con.commit()
raise HTTPException(status_code=400, detail="Неверный код")
con.execute("DELETE FROM WEB_EMAIL_CODES WHERE email=?", (email,))
con.commit()
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="Ошибка БД")
account_id = f"email:{email}"
_db_ensure_account(account_id)
token = _db_create_session(account_id, "email", email.split("@")[0], email)
resp = JSONResponse({"ok": True})
resp.set_cookie(SESSION_COOKIE, token, max_age=SESSION_DAYS * 86400,
httponly=True, secure=True, samesite="lax", path="/")
return resp
_API_DOCS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>