diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 832c04d..49032e4 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -71,6 +71,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = { "/api/auth/token", "/api/auth/email/request", "/api/auth/email/verify", + "/api/auth/email/magic", "/api/auth/logout", } @@ -93,6 +94,12 @@ class WebAppEmailCodePayload(WebAppEmailPayload): code: str = "" +class WebAppEmailMagicPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + token: constr(min_length=8, max_length=512) + + class WebAppPaymentCreatePayload(BaseModel): model_config = ConfigDict(extra="ignore") @@ -166,6 +173,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/auth/token", auth_token_route) app.router.add_post("/api/auth/email/request", email_auth_request_route) app.router.add_post("/api/auth/email/verify", email_auth_verify_route) + app.router.add_post("/api/auth/email/magic", email_auth_magic_route) app.router.add_post("/api/auth/logout", logout_route) app.router.add_get("/api/me", me_route) app.router.add_post("/api/account/email/request", account_email_request_route) @@ -909,6 +917,113 @@ async def email_auth_verify_route(request: web.Request) -> web.Response: ) +async def email_auth_magic_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + payload = await _read_json(request) + magic_payload, validation_error = _validate_model_payload(WebAppEmailMagicPayload, payload) + if validation_error: + return validation_error + token_value = str(magic_payload.token).strip() + referral_param = str(payload.get("referral_code") or payload.get("start_param") or "") + email_service: EmailAuthService = request.app["email_auth_service"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + created_user = False + new_user_referrer_id: Optional[int] = None + verified_email: Optional[str] = None + + async with async_session_factory() as session: + try: + magic_result = await email_service.verify_magic_token( + session, + token=token_value, + purpose="login", + target_user_id=None, + ) + if not magic_result.ok: + await session.commit() + return web.json_response( + { + "ok": False, + "error": magic_result.error or "invalid_token", + "message": "Invalid login link", + }, + status=400, + ) + + verified_email = magic_result.email or "" + db_user = await user_dal.get_user_by_email(session, verified_email) + if not db_user: + referred_by_id = await _resolve_referrer_id( + session, + referral_param, + current_user_id=None, + ) + db_user, _ = await user_dal.create_email_user( + session, + email=verified_email, + language_code=_normalize_language(settings.DEFAULT_LANGUAGE), + email_verified_at=datetime.now(timezone.utc), + referred_by_id=referred_by_id, + ) + created_user = True + new_user_referrer_id = referred_by_id + elif not db_user.email_verified_at: + db_user.email_verified_at = datetime.now(timezone.utc) + + referral_applied = await _apply_referral_to_existing_user( + request, + session, + db_user, + referral_param, + ) + if created_user or referral_applied: + await _apply_referral_welcome_bonus_if_needed( + request, + session, + db_user, + referral_param, + ) + + if db_user.is_banned: + await session.rollback() + return _json_error(403, "banned", "Access denied") + + await session.commit() + except Exception: + await session.rollback() + logger.exception("Email magic-link auth failed") + return _json_error(500, "auth_failed", "Auth failed") + + if created_user and verified_email: + try: + from bot.services.notification_service import NotificationService + + bot: Bot = request.app["bot"] + notification_service = NotificationService( + bot, + settings, + request.app.get("i18n"), + ) + await notification_service.notify_new_email_user_registration( + user_id=int(db_user.user_id), + email=verified_email, + referred_by_id=new_user_referrer_id, + ) + except Exception: + logger.exception("Failed to send new email user notification") + + session_token = create_webapp_session_token(settings, int(db_user.user_id)) + return _build_webapp_auth_response( + settings, + { + "ok": True, + "user_id": int(db_user.user_id), + "telegram_id": _telegram_id_for_user(db_user), + }, + token=session_token, + ) + + async def account_email_request_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) settings: Settings = request.app["settings"] diff --git a/bot/app/web/templates/subscription_webapp.js b/bot/app/web/templates/subscription_webapp.js index 17a17bc..1065110 100644 --- a/bot/app/web/templates/subscription_webapp.js +++ b/bot/app/web/templates/subscription_webapp.js @@ -511,6 +511,14 @@ const MOCK = (() => { return; } + const magicToken = readMagicLoginToken(); + if (magicToken) { + const authenticated = await finalizeMagicLogin(magicToken); + if (authenticated) { + return; + } + } + // Explicit logout should survive refresh, even if the old cookie session is still valid. if (isManuallyLoggedOut()) { await startExternalAuth(); @@ -549,6 +557,54 @@ const MOCK = (() => { await startExternalAuth(); } + function readMagicLoginToken() { + const query = new URLSearchParams(window.location.search); + const token = (query.get('login_token') || '').trim(); + return token || null; + } + + function clearMagicLoginQuery() { + const url = new URL(window.location.href); + ['login_token', 'login_purpose'].forEach(key => url.searchParams.delete(key)); + if (window.history && window.history.replaceState) { + window.history.replaceState({}, document.title, url.pathname + url.search + url.hash); + } + } + + async function finalizeMagicLogin(token) { + if (state.authInProgress) return false; + state.authInProgress = true; + setAuthStatus(t('telegram_auth_verifying')); + try { + const payload = {token}; + if (state.referralParam) payload.referral_code = state.referralParam; + const data = await publicApi('/auth/email/magic', payload); + if (data && data.ok && data.token) { + setToken(data.token, data.csrf_token); + clearManualLogoutFlag(); + clearMagicLoginQuery(); + try { + setAuthStatus(''); + await loadData(); + return true; + } catch (e) { + clearToken(); + setAuthStatus(t('telegram_auth_failed'), true); + return false; + } + } + clearMagicLoginQuery(); + setAuthStatus(emailErrorMessage(data, 'email_code_invalid'), true); + return false; + } catch (e) { + clearMagicLoginQuery(); + setAuthStatus(t('telegram_auth_failed'), true); + return false; + } finally { + state.authInProgress = false; + } + } + function readTelegramLoginWidgetAuthData() { const query = new URLSearchParams(window.location.search); const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash']; @@ -2183,6 +2239,9 @@ const MOCK = (() => { if (path === '/auth/email/verify') { return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; } + if (path === '/auth/email/magic') { + return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; + } if (path === '/account/email/verify') { MOCK.data.user.email = 'preview@example.com'; MOCK.data.user.email_verified = true; diff --git a/bot/services/email_auth_service.py b/bot/services/email_auth_service.py index 2fad7dc..eec43c7 100644 --- a/bot/services/email_auth_service.py +++ b/bot/services/email_auth_service.py @@ -46,6 +46,15 @@ class EmailCodeVerifyResult: retry_after: Optional[int] = None +@dataclass(frozen=True) +class EmailMagicVerifyResult: + ok: bool + error: Optional[str] = None + email: Optional[str] = None + purpose: Optional[str] = None + target_user_id: Optional[int] = None + + def normalize_email(value: str) -> str: return (value or "").strip().lower() @@ -98,6 +107,31 @@ class EmailAuthService: payload = f"{purpose}:{email}:{code}".encode("utf-8") return hmac.new(secret, payload, hashlib.sha256).hexdigest() + def _hash_magic_token(self, token: str) -> str: + secret = hmac.new( + self.settings.BOT_TOKEN.encode("utf-8"), + b"remnawave-tg-shop-email-magic", + hashlib.sha256, + ).digest() + return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest() + + def _build_magic_link(self, *, token: str, purpose: str) -> Optional[str]: + base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() + if not base_url: + return None + from urllib.parse import urlencode, urlsplit, urlunsplit + + parsed = urlsplit(base_url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + params = {"login_token": token} + if purpose and purpose != "login": + params["login_purpose"] = purpose + existing_query = parsed.query + new_query = urlencode(params) + merged_query = f"{existing_query}&{new_query}" if existing_query else new_query + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment)) + async def request_code( self, session: AsyncSession, @@ -159,9 +193,12 @@ class EmailAuthService: ) code = f"{secrets.randbelow(1_000_000):06d}" + magic_token = secrets.token_urlsafe(32) + magic_link = self._build_magic_link(token=magic_token, purpose=purpose) code_model = EmailVerificationCode( email=normalized_email, code_hash=self._hash_code(normalized_email, purpose, code), + magic_token_hash=self._hash_magic_token(magic_token) if magic_link else None, purpose=purpose, target_user_id=target_user_id, expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))), @@ -174,6 +211,7 @@ class EmailAuthService: email=normalized_email, code=code, language_code=language_code, + magic_link=magic_link, ) return EmailCodeRequestResult(ok=True) @@ -304,18 +342,74 @@ class EmailAuthService: result = await session.execute(stmt) return result.scalar_one_or_none() + async def verify_magic_token( + self, + session: AsyncSession, + *, + token: str, + purpose: str, + target_user_id: Optional[int] = None, + ) -> EmailMagicVerifyResult: + if not token: + return EmailMagicVerifyResult(ok=False, error="invalid_token") + + token_hash = self._hash_magic_token(token) + now = datetime.now(timezone.utc) + stmt = ( + select(EmailVerificationCode) + .where( + EmailVerificationCode.magic_token_hash == token_hash, + EmailVerificationCode.purpose == purpose, + EmailVerificationCode.target_user_id == target_user_id, + EmailVerificationCode.status == "active", + EmailVerificationCode.consumed_at.is_(None), + ) + .limit(1) + ) + result = await session.execute(stmt) + record = result.scalar_one_or_none() + if not record: + return EmailMagicVerifyResult(ok=False, error="invalid_token") + + expires_at = record.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at < now: + return EmailMagicVerifyResult(ok=False, error="expired_token") + + record.consumed_at = now + throttle_identifier = _email_throttle_identifier( + record.email, + purpose, + target_user_id, + ) + await security_dal.clear_throttle_state( + session, + scope=security_dal.EMAIL_CODE_VERIFY_SCOPE, + identifier=throttle_identifier, + ) + await session.flush() + return EmailMagicVerifyResult( + ok=True, + email=record.email, + purpose=record.purpose, + target_user_id=record.target_user_id, + ) + async def _send_code_email( self, *, email: str, code: str, language_code: str, + magic_link: Optional[str] = None, ) -> None: await asyncio.to_thread( self._send_code_email_sync, email=email, code=code, language_code=language_code, + magic_link=magic_link, ) async def send_custom_email( @@ -353,11 +447,13 @@ class EmailAuthService: email: str, code: str, language_code: str, + magic_link: Optional[str] = None, ) -> None: content = render_login_code( self.settings, code=code, language_code=language_code, + magic_link=magic_link, ) message = EmailMessage() diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py index ca54cdf..d976cf2 100644 --- a/bot/services/email_templates.py +++ b/bot/services/email_templates.py @@ -207,6 +207,7 @@ def render_login_code( *, code: str, language_code: Optional[str], + magic_link: Optional[str] = None, i18n: Optional[JsonI18n] = None, ) -> EmailContent: i18n = _resolve_i18n(i18n) @@ -214,6 +215,7 @@ def render_login_code( minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS) accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) brand = _brand_title(settings) + safe_magic_link = (magic_link or "").strip() subject = _t_text(i18n, lang, "email_login_code_subject", code=code) preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes) @@ -222,19 +224,47 @@ def render_login_code( expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes) security = _t_text(i18n, lang, "email_login_code_security") footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) - text = _t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes) + text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)] + if safe_magic_link: + text_lines.append( + _t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link) + ) - body_html = f""" - - - - -
-
{html.escape(code)}
-
-

{expiry_html}

-

{html.escape(security)}

-""" + code_block = ( + f'' + f'
' + f'
' + f'{html.escape(code)}' + f'
' + ) + + magic_block = "" + if safe_magic_link: + cta_label = _t_text(i18n, lang, "email_login_code_magic_cta") + divider_label = _t_text(i18n, lang, "email_login_code_magic_or") + magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro") + magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint") + divider_html = ( + f'' + f'' + f'' + f'' + f'' + f'
 {html.escape(divider_label)} 
' + ) + magic_block = ( + divider_html + + f'

{html.escape(magic_intro)}

' + + _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent) + + f'

{html.escape(magic_hint)}

' + ) + + body_html = ( + code_block + + f'

{expiry_html}

' + + f'

{html.escape(security)}

' + + magic_block + ) rendered = _layout( settings=settings, @@ -244,7 +274,7 @@ def render_login_code( body_html=body_html, footer_html=footer, ) - return EmailContent(subject=subject, text=text, html=rendered) + return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered) def render_account_merged( diff --git a/db/migrator.py b/db/migrator.py index 83da49e..84d914b 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -282,6 +282,27 @@ def _migration_0008_add_email_verification_code_status(connection: Connection) - ) +def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")} + + if "magic_token_hash" not in columns: + connection.execute( + text( + "ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR" + ) + ) + + connection.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash + ON email_verification_codes (magic_token_hash) + """ + ) + ) + + def _migration_0009_add_composite_indexes(connection: Connection) -> None: connection.execute( text( @@ -355,6 +376,11 @@ MIGRATIONS: List[Migration] = [ description="Add composite indexes for subscription and payment lookups", upgrade=_migration_0009_add_composite_indexes, ), + Migration( + id="0010_add_email_magic_token_hash", + description="Store hashed magic-link tokens for email login deeplinks", + upgrade=_migration_0010_add_email_magic_token_hash, + ), ] diff --git a/db/models.py b/db/models.py index a3dc4be..b50f191 100644 --- a/db/models.py +++ b/db/models.py @@ -100,6 +100,7 @@ class EmailVerificationCode(Base): code_id = Column(Integer, primary_key=True, autoincrement=True) email = Column(String, nullable=False, index=True) code_hash = Column(String, nullable=False) + magic_token_hash = Column(String, nullable=True, index=True) purpose = Column(String, nullable=False, index=True) target_user_id = Column( BigInteger, diff --git a/locales/en.json b/locales/en.json index 159ffe3..9c51cb9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -529,7 +529,12 @@ "email_login_code_intro": "Use this 6-digit code to finish signing in to your subscription dashboard.", "email_login_code_expiry_html": "The code is valid for {minutes} min.", "email_login_code_security": "If you didn't request this code, you can ignore this email — your account stays safe.", + "email_login_code_magic_cta": "Sign in with one tap", + "email_login_code_magic_intro": "Don't want to type the code? Sign in with this link.", + "email_login_code_magic_hint": "The link is single-use and expires together with the code.", + "email_login_code_magic_or": "or", "email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.", + "email_login_code_text_magic": "Or sign in with this single-use link: {url}", "email_account_merged_subject": "Accounts merged", "email_account_merged_preheader": "Your accounts were combined into one profile.", diff --git a/locales/ru.json b/locales/ru.json index ed6bc3c..5aeafaf 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -529,7 +529,12 @@ "email_login_code_intro": "Введите этот 6-значный код, чтобы завершить вход в личный кабинет подписки.", "email_login_code_expiry_html": "Код действует {minutes} мин.", "email_login_code_security": "Если вы не запрашивали код, просто проигнорируйте это письмо — ваш аккаунт в безопасности.", + "email_login_code_magic_cta": "Войти одним нажатием", + "email_login_code_magic_intro": "Не хотите вводить код? Войдите по ссылке.", + "email_login_code_magic_hint": "Ссылка одноразовая и действует столько же, сколько код.", + "email_login_code_magic_or": "или", "email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.", + "email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}", "email_account_merged_subject": "Аккаунты объединены", "email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",