feat: add magic login link to email with login codes

This commit is contained in:
3252a8
2026-04-28 09:50:17 +03:00
parent 0251f939d7
commit dd0e6e75af
8 changed files with 350 additions and 13 deletions
+115
View File
@@ -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"]
@@ -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;
+96
View File
@@ -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()
+43 -13
View File
@@ -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"""
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">
<tr>
<td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">
<div style="font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">{html.escape(code)}</div>
</td>
</tr>
</table>
<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>
<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>
"""
code_block = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">'
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">'
f'<div style="font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">'
f'{html.escape(code)}'
f'</div></td></tr></table>'
)
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'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">'
f'<tr>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>'
f'</tr></table>'
)
magic_block = (
divider_html
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>'
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>'
)
body_html = (
code_block
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>'
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>'
+ 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(
+26
View File
@@ -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,
),
]
+1
View File
@@ -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,
+5
View File
@@ -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 <strong style=\"color:#e6e9ef;\">{minutes} min</strong>.",
"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.",
+5
View File
@@ -529,7 +529,12 @@
"email_login_code_intro": "Введите этот 6-значный код, чтобы завершить вход в личный кабинет подписки.",
"email_login_code_expiry_html": "Код действует <strong style=\"color:#e6e9ef;\">{minutes} мин</strong>.",
"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": "Мы объединили ваши аккаунты в один профиль.",