409 lines
14 KiB
Python
409 lines
14 KiB
Python
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import smtplib
|
|
import ssl
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone, timedelta
|
|
from email.message import EmailMessage
|
|
from email.utils import formataddr
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from config.settings import Settings
|
|
from db.dal import security_dal
|
|
from db.models import EmailVerificationCode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SmtpAttempt:
|
|
port: int
|
|
use_ssl: bool
|
|
starttls: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EmailCodeRequestResult:
|
|
ok: bool
|
|
error: Optional[str] = None
|
|
retry_after: Optional[int] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EmailCodeVerifyResult:
|
|
ok: bool
|
|
error: Optional[str] = None
|
|
retry_after: Optional[int] = None
|
|
|
|
|
|
def normalize_email(value: str) -> str:
|
|
return (value or "").strip().lower()
|
|
|
|
|
|
def is_valid_email(value: str) -> bool:
|
|
email = normalize_email(value)
|
|
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
|
|
|
|
|
|
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
|
|
target_part = "none" if target_user_id is None else str(target_user_id)
|
|
return f"{purpose}:{target_part}:{email}"
|
|
|
|
|
|
class EmailAuthService:
|
|
def __init__(self, settings: Settings):
|
|
self.settings = settings
|
|
|
|
def _smtp_attempts(self) -> list[SmtpAttempt]:
|
|
attempts: list[SmtpAttempt] = []
|
|
primary_port = int(self.settings.SMTP_PORT)
|
|
|
|
for port in self.settings.smtp_ports_to_try:
|
|
if port == primary_port:
|
|
use_ssl = bool(self.settings.SMTP_USE_SSL or port == 465)
|
|
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
|
|
else:
|
|
use_ssl = port == 465
|
|
starttls = bool(self.settings.SMTP_STARTTLS and not use_ssl)
|
|
attempts.append(SmtpAttempt(port=port, use_ssl=use_ssl, starttls=starttls))
|
|
|
|
return attempts or [
|
|
SmtpAttempt(
|
|
port=primary_port,
|
|
use_ssl=bool(self.settings.SMTP_USE_SSL or primary_port == 465),
|
|
starttls=bool(
|
|
self.settings.SMTP_STARTTLS
|
|
and not self.settings.SMTP_USE_SSL
|
|
and primary_port != 465
|
|
),
|
|
)
|
|
]
|
|
|
|
def _hash_code(self, email: str, purpose: str, code: str) -> str:
|
|
secret = hmac.new(
|
|
self.settings.BOT_TOKEN.encode("utf-8"),
|
|
b"remnawave-tg-shop-email-code",
|
|
hashlib.sha256,
|
|
).digest()
|
|
payload = f"{purpose}:{email}:{code}".encode("utf-8")
|
|
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
|
|
|
async def request_code(
|
|
self,
|
|
session: AsyncSession,
|
|
*,
|
|
email: str,
|
|
purpose: str,
|
|
language_code: str,
|
|
target_user_id: Optional[int] = None,
|
|
) -> EmailCodeRequestResult:
|
|
normalized_email = normalize_email(email)
|
|
if not self.settings.email_auth_configured:
|
|
return EmailCodeRequestResult(ok=False, error="email_auth_not_configured")
|
|
if not is_valid_email(normalized_email):
|
|
return EmailCodeRequestResult(ok=False, error="invalid_email")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
throttle = await security_dal.check_throttle(
|
|
session,
|
|
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
|
identifier=_email_throttle_identifier(normalized_email, purpose, target_user_id),
|
|
now=now,
|
|
)
|
|
if throttle.locked:
|
|
return EmailCodeRequestResult(
|
|
ok=False,
|
|
error="rate_limited",
|
|
retry_after=throttle.retry_after,
|
|
)
|
|
|
|
latest_code = await self._get_latest_code(
|
|
session,
|
|
email=normalized_email,
|
|
purpose=purpose,
|
|
target_user_id=target_user_id,
|
|
)
|
|
if latest_code and latest_code.created_at:
|
|
created_at = latest_code.created_at
|
|
if created_at.tzinfo is None:
|
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
resend_after = max(1, int(self.settings.EMAIL_CODE_RESEND_SECONDS))
|
|
elapsed = int((now - created_at).total_seconds())
|
|
if elapsed < resend_after and latest_code.consumed_at is None:
|
|
return EmailCodeRequestResult(
|
|
ok=False,
|
|
error="rate_limited",
|
|
retry_after=resend_after - elapsed,
|
|
)
|
|
|
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
|
code_model = EmailVerificationCode(
|
|
email=normalized_email,
|
|
code_hash=self._hash_code(normalized_email, purpose, code),
|
|
purpose=purpose,
|
|
target_user_id=target_user_id,
|
|
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
|
|
)
|
|
session.add(code_model)
|
|
await session.flush()
|
|
|
|
await self._send_code_email(
|
|
email=normalized_email,
|
|
code=code,
|
|
language_code=language_code,
|
|
)
|
|
return EmailCodeRequestResult(ok=True)
|
|
|
|
async def verify_code(
|
|
self,
|
|
session: AsyncSession,
|
|
*,
|
|
email: str,
|
|
purpose: str,
|
|
code: str,
|
|
target_user_id: Optional[int] = None,
|
|
) -> EmailCodeVerifyResult:
|
|
normalized_email = normalize_email(email)
|
|
normalized_code = re.sub(r"\D", "", code or "")
|
|
if not is_valid_email(normalized_email):
|
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
throttle_identifier = _email_throttle_identifier(
|
|
normalized_email,
|
|
purpose,
|
|
target_user_id,
|
|
)
|
|
throttle = await security_dal.check_throttle(
|
|
session,
|
|
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
|
identifier=throttle_identifier,
|
|
now=now,
|
|
)
|
|
if throttle.locked:
|
|
return EmailCodeVerifyResult(
|
|
ok=False,
|
|
error="rate_limited",
|
|
retry_after=throttle.retry_after,
|
|
)
|
|
|
|
latest_code = await self._get_latest_code(
|
|
session,
|
|
email=normalized_email,
|
|
purpose=purpose,
|
|
target_user_id=target_user_id,
|
|
)
|
|
if not latest_code or latest_code.consumed_at is not None:
|
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
|
|
|
expires_at = latest_code.expires_at
|
|
if expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
if expires_at < now:
|
|
return EmailCodeVerifyResult(ok=False, error="expired_code")
|
|
|
|
max_attempts = max(1, int(self.settings.EMAIL_CODE_MAX_ATTEMPTS))
|
|
if int(latest_code.attempts or 0) >= max_attempts:
|
|
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
|
|
|
|
if len(normalized_code) != 6:
|
|
latest_code.attempts = int(latest_code.attempts or 0) + 1
|
|
throttle_result = await security_dal.record_throttle_failure(
|
|
session,
|
|
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
|
identifier=throttle_identifier,
|
|
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
|
|
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
|
|
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
|
|
now=now,
|
|
)
|
|
await session.flush()
|
|
if throttle_result.locked:
|
|
return EmailCodeVerifyResult(
|
|
ok=False,
|
|
error="rate_limited",
|
|
retry_after=throttle_result.retry_after,
|
|
)
|
|
if int(latest_code.attempts or 0) >= max_attempts:
|
|
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
|
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
|
|
|
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
|
|
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
|
|
latest_code.attempts = int(latest_code.attempts or 0) + 1
|
|
throttle_result = await security_dal.record_throttle_failure(
|
|
session,
|
|
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
|
identifier=throttle_identifier,
|
|
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
|
|
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
|
|
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
|
|
now=now,
|
|
)
|
|
await session.flush()
|
|
if throttle_result.locked:
|
|
return EmailCodeVerifyResult(
|
|
ok=False,
|
|
error="rate_limited",
|
|
retry_after=throttle_result.retry_after,
|
|
)
|
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
|
|
|
latest_code.consumed_at = now
|
|
await security_dal.clear_throttle_state(
|
|
session,
|
|
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
|
identifier=throttle_identifier,
|
|
)
|
|
await session.flush()
|
|
return EmailCodeVerifyResult(ok=True)
|
|
|
|
async def _get_latest_code(
|
|
self,
|
|
session: AsyncSession,
|
|
*,
|
|
email: str,
|
|
purpose: str,
|
|
target_user_id: Optional[int],
|
|
) -> Optional[EmailVerificationCode]:
|
|
stmt = (
|
|
select(EmailVerificationCode)
|
|
.where(
|
|
EmailVerificationCode.email == email,
|
|
EmailVerificationCode.purpose == purpose,
|
|
EmailVerificationCode.target_user_id == target_user_id,
|
|
)
|
|
.order_by(EmailVerificationCode.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
result = await session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def _send_code_email(
|
|
self,
|
|
*,
|
|
email: str,
|
|
code: str,
|
|
language_code: str,
|
|
) -> None:
|
|
await asyncio.to_thread(
|
|
self._send_code_email_sync,
|
|
email=email,
|
|
code=code,
|
|
language_code=language_code,
|
|
)
|
|
|
|
def _send_code_email_sync(
|
|
self,
|
|
*,
|
|
email: str,
|
|
code: str,
|
|
language_code: str,
|
|
) -> None:
|
|
lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
|
if lang == "en":
|
|
subject = "Your login code"
|
|
body = (
|
|
f"Your verification code: {code}\n\n"
|
|
f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes."
|
|
)
|
|
else:
|
|
subject = "Код подтверждения"
|
|
body = (
|
|
f"Ваш код подтверждения: {code}\n\n"
|
|
f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин."
|
|
)
|
|
|
|
message = EmailMessage()
|
|
message["Subject"] = subject
|
|
message["From"] = formataddr(
|
|
(
|
|
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
|
self.settings.SMTP_FROM_EMAIL or "",
|
|
)
|
|
)
|
|
message["To"] = email
|
|
message.set_content(body)
|
|
|
|
context = ssl.create_default_context()
|
|
smtp_host = self.settings.SMTP_HOST
|
|
timeout = max(5, int(self.settings.SMTP_TIMEOUT_SECONDS))
|
|
attempts = self._smtp_attempts()
|
|
last_error: Optional[BaseException] = None
|
|
|
|
for attempt_number, attempt in enumerate(attempts, start=1):
|
|
try:
|
|
self._send_message_via_smtp(
|
|
message=message,
|
|
smtp_host=smtp_host,
|
|
smtp_port=attempt.port,
|
|
timeout=timeout,
|
|
context=context,
|
|
use_ssl=attempt.use_ssl,
|
|
starttls=attempt.starttls,
|
|
)
|
|
logger.info(
|
|
"Email verification code sent to %s via %s:%s",
|
|
email,
|
|
smtp_host,
|
|
attempt.port,
|
|
)
|
|
return
|
|
except (OSError, smtplib.SMTPException, TimeoutError) as exc:
|
|
last_error = exc
|
|
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
|
|
logger.log(
|
|
log_level,
|
|
"SMTP send attempt %s/%s failed via %s:%s (ssl=%s, starttls=%s): %s",
|
|
attempt_number,
|
|
len(attempts),
|
|
smtp_host,
|
|
attempt.port,
|
|
attempt.use_ssl,
|
|
attempt.starttls,
|
|
exc,
|
|
)
|
|
|
|
if last_error:
|
|
raise last_error
|
|
|
|
def _send_message_via_smtp(
|
|
self,
|
|
*,
|
|
message: EmailMessage,
|
|
smtp_host: str,
|
|
smtp_port: int,
|
|
timeout: int,
|
|
context: ssl.SSLContext,
|
|
use_ssl: bool,
|
|
starttls: bool,
|
|
) -> None:
|
|
if use_ssl:
|
|
with smtplib.SMTP_SSL(
|
|
smtp_host,
|
|
smtp_port,
|
|
context=context,
|
|
timeout=timeout,
|
|
) as smtp:
|
|
smtp.ehlo()
|
|
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
|
|
smtp.send_message(message)
|
|
return
|
|
|
|
with smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) as smtp:
|
|
smtp.ehlo()
|
|
if starttls:
|
|
smtp.starttls(context=context)
|
|
smtp.ehlo()
|
|
smtp.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD)
|
|
smtp.send_message(message)
|