Files
Solo_bot/api/v2/routes/auth/password.py
T
2026-04-19 20:59:15 +00:00

431 lines
19 KiB
Python

import secrets
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
)
from api.v2.routes.auth._common import TOKEN_TTL_HINT, _client_ip, build_login_response
from api.v2.schemas.identities import (
ConfirmPasswordResetRequest,
LoginByCodeRequest,
LoginRequest,
LoginResponse,
RegisterByEmailRequest,
RegisterResponse,
SendLoginCodeRequest,
)
from database import (
add_referral,
get_referral_by_referred_id,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from logger import logger
from mail import (
send_email_verify_code_email,
send_login_code_email,
send_password_reset_code_email,
smtp_configured,
)
from utils import (
web_email_verify_code as email_verify,
web_password_reset_code as pwd_reset,
)
from utils.disposable_emails import is_disposable_email
from utils.referral_codes import decode_referral_code
from utils.turnstile import turnstile_enabled, verify_turnstile_token
from utils.web_login_code import (
delete_code,
normalize_login_email,
redis_ready_for_login_codes,
release_resend_cooldown,
store_code,
try_acquire_resend_cooldown,
try_consume_email_send_budget,
try_consume_email_verify_budget,
try_consume_ip_send_budget,
verify_and_consume_code,
)
router = APIRouter()
_RESET_OK_MESSAGE = {
"ok": True,
"message": "Если для этого адреса есть аккаунт с паролем, мы отправили код. Проверьте почту.",
}
@router.post("/register", response_model=RegisterResponse)
async def register_by_email(
body: RegisterByEmailRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
(
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
if count > 5:
raise HTTPException(status_code=429, detail="Слишком много регистраций с этого IP. Попробуйте позже.")
except HTTPException:
raise
except Exception:
pass
if turnstile_enabled():
if not await verify_turnstile_token(body.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
if is_disposable_email(email):
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
if not body.password or len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
existing = await idb.get_identity_by_email(session, email)
if existing:
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
raw_referral = str(body.referral_code or "").strip()
if "/referral/" in raw_referral:
raw_referral = raw_referral.split("/referral/", 1)[-1]
if "start=referral_" in raw_referral:
raw_referral = raw_referral.split("start=referral_", 1)[-1]
raw_referral = raw_referral.split("?", 1)[0].split("#", 1)[0].strip()
referrer_legacy = decode_referral_code(raw_referral)
referrer_user = None
if body.referral_code and referrer_legacy is None:
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
if referrer_legacy is not None:
referrer_user = await resolve_user_optional(session, referrer_legacy)
if referrer_user is None:
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
identity, token = await idb.create_identity_with_token(
session, email=email, password=body.password, request=request
)
await bind_identity_actor(request, session, identity)
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
await add_referral(session, billing_user_id, referrer_user.id)
if referrer_user.tg_id is not None:
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=int(referrer_user.tg_id),
type="referral_joined",
title="Ваш реферал присоединился",
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
data={"referred_user_id": int(billing_user_id)},
)
except Exception:
pass
if smtp_configured():
try:
code = f"{secrets.randbelow(900000) + 100000}"
await email_verify.store_code(email, code)
await send_email_verify_code_email(email, code)
except Exception as e:
logger.warning("[Auth] Не удалось отправить код подтверждения email при регистрации: {}", e)
logger.info("[Auth] Register success: identity={}, email={}, ip={}", identity.id, email, _client_ip(request))
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return RegisterResponse(identity_id=identity.id)
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_get, cache_incr_checked
lockout_key = f"login_lockout:{email}"
locked = await cache_get(lockout_key)
if locked:
raise HTTPException(status_code=429, detail="Аккаунт временно заблокирован. Попробуйте через 15 минут.")
rkey = f"login_pwd_rate:{ip}"
count, redis_ok = await cache_incr_checked(rkey, 900)
if not redis_ok:
count = check_and_increment(rkey, 10, 900)
if count > 10:
raise HTTPException(status_code=429, detail="Слишком много попыток. Попробуйте позже.")
except HTTPException:
raise
except Exception as e:
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
result = await idb.login_by_email(session, email, body.password, request=request)
if not result:
from database.setup.web_admin_bootstrap import ensure_web_admin
try:
await ensure_web_admin(session)
await session.flush()
result = await idb.login_by_email(session, email, body.password, request=request)
except Exception as exc:
logger.warning("[Auth] lazy web-admin bootstrap failed: {}", exc)
if not result:
try:
from core.redis_cache import cache_incr, cache_set
fail_key = f"login_fail:{email}"
fails = await cache_incr(fail_key, 900)
if fails >= 10:
await cache_set(f"login_lockout:{email}", "1", 900)
except Exception:
pass
raise HTTPException(status_code=401, detail="Неверный email или пароль")
try:
from core.redis_cache import cache_delete
await cache_delete(f"login_fail:{email}")
except Exception:
pass
identity, token = result
await bind_identity_actor(request, session, identity)
if getattr(identity, "is_admin", False):
from database.site_state import mark_site_initialized
await mark_site_initialized(session)
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/send-login-code")
async def send_login_code(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Отправить код входа на email (SMTP + Redis)."""
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
if count > 10:
raise HTTPException(status_code=429, detail="Слишком много запросов кодов. Попробуйте позже.")
except HTTPException:
raise
except Exception:
pass
if turnstile_enabled():
if not await verify_turnstile_token(body.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
email_norm = normalize_login_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Email обязателен")
if is_disposable_email(email_norm):
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
if not smtp_configured():
raise HTTPException(
status_code=503,
detail="Отправка кода недоступна: почта не настроена на сервере",
)
if not await redis_ready_for_login_codes():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
if not body.allow_register:
return {"ok": True, "message": "Код отправлен на почту"}
identity = await idb.create_identity(session, email=email_norm)
ip = _client_ip(request)
if not await try_consume_ip_send_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await try_acquire_resend_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await store_code(email_norm, code):
await release_resend_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_login_code_email(email_norm, code)
except Exception:
await release_resend_cooldown(email_norm)
await delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return {"ok": True, "message": "Код отправлен на почту"}
@router.post("/login-by-code", response_model=LoginResponse)
async def login_by_code(
body: LoginByCodeRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и коду из письма."""
email_norm = normalize_login_email(body.email)
if not email_norm or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if not await redis_ready_for_login_codes():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await verify_and_consume_code(email_norm, body.code.strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
raise HTTPException(status_code=401, detail="Аккаунт не найден")
if not getattr(identity, "email_verified", False):
from sqlalchemy import update as sa_update
from database.models import Identity as IdentityModel
await session.execute(
sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
if getattr(identity, "is_admin", False):
from database.site_state import mark_site_initialized
await mark_site_initialized(session)
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/request-password-reset")
async def request_password_reset(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
email_norm = normalize_login_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Email обязателен")
if not smtp_configured() or not await pwd_reset.redis_ready():
return _RESET_OK_MESSAGE
identity = await idb.get_identity_by_email(session, email_norm)
if not identity or not identity.password_hash:
return _RESET_OK_MESSAGE
ip = _client_ip(request)
if not await pwd_reset.try_consume_ip_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await pwd_reset.try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await pwd_reset.try_acquire_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await pwd_reset.store_code(email_norm, code):
await pwd_reset.release_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_password_reset_code_email(email_norm, code)
except Exception:
await pwd_reset.release_cooldown(email_norm)
await pwd_reset.delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return _RESET_OK_MESSAGE
@router.post("/confirm-password-reset", response_model=LoginResponse)
async def confirm_password_reset(
body: ConfirmPasswordResetRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
email_norm = normalize_login_email(body.email)
if not email_norm or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Пароли не совпадают")
if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
if not await pwd_reset.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await pwd_reset.try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await pwd_reset.verify_and_consume_code(email_norm, body.code.strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
raise HTTPException(status_code=400, detail="Аккаунт не найден")
updated = await idb.set_password_for_identity(session, identity.id, body.password)
if not updated:
raise HTTPException(status_code=400, detail="Не удалось обновить пароль")
await bind_identity_actor(request, session, updated)
token = await idb.issue_token_for_identity(session, updated, request=request)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, updated, request)
return build_login_response(updated)