431 lines
19 KiB
Python
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)
|