Вход по почте (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:
111
app/main.py
111
app/main.py
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user