feat(api,frontend): add qr code login

This commit is contained in:
hh
2026-08-06 00:23:38 +02:00
parent 9c265af3d3
commit e887dfc5ce
9 changed files with 315 additions and 80 deletions
+81 -6
View File
@@ -1,16 +1,19 @@
import asyncio
import contextlib import contextlib
import secrets import secrets
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from pyrogram.errors import SessionPasswordNeeded from pyrogram.errors import AuthTokenExpired, SessionPasswordNeeded
from pyrogram.qrlogin import QRLogin
from pyrogram.types import User from pyrogram.types import User
from userbot import PyroClient from userbot import PyroClient
from utils.env import env from utils.env import env
LOGIN_TTL_SECONDS = 900 LOGIN_TTL_SECONDS = 900
QR_POLL_SECONDS = 25
PENDING_DIRNAME = "pending" PENDING_DIRNAME = "pending"
@@ -18,12 +21,27 @@ class LoginError(Exception):
pass pass
@dataclass
class QrState:
url: str
user: User | None = None
password_needed: bool = False
error: str | None = None
changed: asyncio.Event = field(default_factory=asyncio.Event)
@property
def settled(self) -> bool:
return self.user is not None or self.password_needed or self.error is not None
@dataclass @dataclass
class PendingLogin: class PendingLogin:
client: PyroClient client: PyroClient
phone: str
phone_code_hash: str
started_at: float started_at: float
phone: str = ""
phone_code_hash: str = ""
qr: QrState | None = None
watcher: asyncio.Task[None] | None = None
def _sessions_dir() -> Path: def _sessions_dir() -> Path:
@@ -38,6 +56,29 @@ def _pending_dir() -> Path:
return path return path
async def _stop_watcher(login: PendingLogin) -> None:
if login.watcher is None or login.watcher.done():
return
login.watcher.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await login.watcher
async def _watch_qr(qr: QRLogin, state: QrState) -> None:
while not state.settled:
try:
try:
state.user = await qr.wait()
except (TimeoutError, AuthTokenExpired):
await qr.recreate()
state.url = qr.url
except SessionPasswordNeeded:
state.password_needed = True
except Exception as exc:
state.error = str(exc)
state.changed.set()
class LoginManager: class LoginManager:
def __init__(self) -> None: def __init__(self) -> None:
self._logins: dict[str, PendingLogin] = {} self._logins: dict[str, PendingLogin] = {}
@@ -49,21 +90,53 @@ class LoginManager:
raise LoginError(msg) raise LoginError(msg)
return login return login
async def start(self, phone: str) -> str: async def _connect(self) -> tuple[str, PyroClient]:
await self._sweep() await self._sweep()
login_id = secrets.token_hex(8) login_id = secrets.token_hex(8)
client = PyroClient(login_id, workdir=str(_pending_dir()), load_handlers=False) client = PyroClient(login_id, workdir=str(_pending_dir()), load_handlers=False)
await client.connect() await client.connect()
return login_id, client
async def start(self, phone: str) -> str:
login_id, client = await self._connect()
try: try:
sent = await client.send_code(phone) sent = await client.send_code(phone)
except Exception: except Exception:
await self._discard(login_id, client) await self._discard(login_id, client)
raise raise
self._logins[login_id] = PendingLogin( self._logins[login_id] = PendingLogin(
client, phone, sent.phone_code_hash, time.monotonic() client, time.monotonic(), phone=phone, phone_code_hash=sent.phone_code_hash
) )
return login_id return login_id
async def start_qr(self) -> tuple[str, str]:
login_id, client = await self._connect()
qr = QRLogin(client)
try:
await qr.recreate()
except Exception:
await self._discard(login_id, client)
raise
state = QrState(qr.url)
self._logins[login_id] = PendingLogin(
client,
time.monotonic(),
qr=state,
watcher=asyncio.create_task(_watch_qr(qr, state)),
)
return login_id, state.url
async def wait_qr(self, login_id: str) -> QrState:
login = self._get(login_id)
if login.qr is None:
msg = "Этот вход начат по номеру телефона"
raise LoginError(msg)
with contextlib.suppress(TimeoutError):
async with asyncio.timeout(QR_POLL_SECONDS):
await login.qr.changed.wait()
login.qr.changed.clear()
return login.qr
async def submit_code(self, login_id: str, code: str) -> User | None: async def submit_code(self, login_id: str, code: str) -> User | None:
login = self._get(login_id) login = self._get(login_id)
try: try:
@@ -81,6 +154,7 @@ class LoginManager:
async def finalize(self, login_id: str, session_name: str) -> None: async def finalize(self, login_id: str, session_name: str) -> None:
login = self._logins.pop(login_id) login = self._logins.pop(login_id)
await _stop_watcher(login)
await login.client.disconnect() await login.client.disconnect()
source = _pending_dir() / f"{login_id}.session" source = _pending_dir() / f"{login_id}.session"
source.replace(_sessions_dir() / f"{session_name}.session") source.replace(_sessions_dir() / f"{session_name}.session")
@@ -88,6 +162,7 @@ class LoginManager:
async def cancel(self, login_id: str) -> None: async def cancel(self, login_id: str) -> None:
login = self._logins.pop(login_id, None) login = self._logins.pop(login_id, None)
if login is not None: if login is not None:
await _stop_watcher(login)
await self._discard(login_id, login.client) await self._discard(login_id, login.client)
async def _discard(self, login_id: str, client: PyroClient) -> None: async def _discard(self, login_id: str, client: PyroClient) -> None:
+20 -1
View File
@@ -37,7 +37,8 @@ class PasswordRequest(BaseModel):
class LoginState(BaseModel): class LoginState(BaseModel):
login_id: str login_id: str
stage: Literal["code", "password", "done"] stage: Literal["code", "qr", "password", "done"]
qr_url: str | None = None
account: AccountView | None = None account: AccountView | None = None
@@ -79,6 +80,24 @@ async def start_login(body: PhoneRequest) -> LoginState:
return LoginState(login_id=login_id, stage="code") return LoginState(login_id=login_id, stage="code")
@router.post("/accounts/login/qr")
async def start_qr_login() -> LoginState:
login_id, url = await _guard(login_manager.start_qr())
return LoginState(login_id=login_id, stage="qr", qr_url=url)
@router.get("/accounts/login/{login_id}/qr")
async def poll_qr_login(login_id: str, pool: FromDishka[asyncpg.Pool]) -> LoginState:
state = await _guard(login_manager.wait_qr(login_id))
if state.user is not None:
return await _complete(pool, login_id, state.user)
if state.password_needed:
return LoginState(login_id=login_id, stage="password")
if state.error is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, state.error)
return LoginState(login_id=login_id, stage="qr", qr_url=state.url)
@router.post("/accounts/login/{login_id}/code") @router.post("/accounts/login/{login_id}/code")
async def submit_code( async def submit_code(
login_id: str, body: CodeRequest, pool: FromDishka[asyncpg.Pool] login_id: str, body: CodeRequest, pool: FromDishka[asyncpg.Pool]
+1 -1
View File
@@ -17,7 +17,7 @@ class PyroClient(Client):
api_hash="b18441a1ff607e10a989891a5462e627", api_hash="b18441a1ff607e10a989891a5462e627",
device_model="Desktop", device_model="Desktop",
system_version="Windows 11 x64", system_version="Windows 11 x64",
app_version="6.2.4 x64", app_version="6.7.8 x64",
lang_pack="tdesktop", lang_pack="tdesktop",
client_platform=enums.ClientPlatform.DESKTOP, client_platform=enums.ClientPlatform.DESKTOP,
) )
+3 -1
View File
@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "frontend", "name": "frontend",
@@ -8,6 +7,7 @@
"bits-ui": "^2.18.1", "bits-ui": "^2.18.1",
"lottie-web": "^5.13.0", "lottie-web": "^5.13.0",
"pako": "^2.1.0", "pako": "^2.1.0",
"uqr": "^0.1.3",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.15", "@biomejs/biome": "2.4.15",
@@ -388,6 +388,8 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
+2 -1
View File
@@ -34,6 +34,7 @@
"dependencies": { "dependencies": {
"bits-ui": "^2.18.1", "bits-ui": "^2.18.1",
"lottie-web": "^5.13.0", "lottie-web": "^5.13.0",
"pako": "^2.1.0" "pako": "^2.1.0",
"uqr": "^0.1.3"
} }
} }
+8
View File
@@ -53,6 +53,14 @@ export function startLogin(phone: string): Promise<LoginState> {
}); });
} }
export function startQrLogin(): Promise<LoginState> {
return request<LoginState>("/accounts/login/qr", { method: "POST" });
}
export function pollQrLogin(loginId: string): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/qr`);
}
export function submitLoginCode( export function submitLoginCode(
loginId: string, loginId: string,
code: string code: string
+2 -1
View File
@@ -22,11 +22,12 @@ export interface Account {
tg_user_id: number | null; tg_user_id: number | null;
} }
export type LoginStage = "code" | "password" | "done"; export type LoginStage = "code" | "qr" | "password" | "done";
export interface LoginState { export interface LoginState {
account: Account | null; account: Account | null;
login_id: string; login_id: string;
qr_url: string | null;
stage: LoginStage; stage: LoginStage;
} }
@@ -1,30 +1,37 @@
<script lang="ts"> <script lang="ts">
import { Dialog } from "bits-ui"; import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { ApiError } from "$lib/api/client"; import { ApiError } from "$lib/api/client";
import { import {
cancelLogin, cancelLogin,
pollQrLogin,
startLogin, startLogin,
startQrLogin,
submitLoginCode, submitLoginCode,
submitLoginPassword, submitLoginPassword,
} from "$lib/api/endpoints"; } from "$lib/api/endpoints";
import type { LoginState } from "$lib/api/types"; import type { LoginState } from "$lib/api/types";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import QrCode from "$lib/components/ui/QrCode.svelte";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
type Step = "phone" | "code" | "password"; type Step = "qr" | "phone" | "code" | "password";
let { open = $bindable(false) }: { open?: boolean } = $props(); let { open = $bindable(false) }: { open?: boolean } = $props();
let step = $state<Step>("phone"); let step = $state<Step>("qr");
let loginId = $state(""); let loginId = $state("");
let qrUrl = $state("");
let phone = $state(""); let phone = $state("");
let code = $state(""); let code = $state("");
let password = $state(""); let password = $state("");
let busy = $state(false); let busy = $state(false);
let attempt = 0;
const hints: Record<Step, string> = { const hints: Record<Step, string> = {
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
phone: "Номер телефона в международном формате, например +79991234567.", phone: "Номер телефона в международном формате, например +79991234567.",
code: "Код отправлен в Telegram на этот номер.", code: "Код отправлен в Telegram на этот номер.",
password: "Аккаунт защищён двухэтапной аутентификацией.", password: "Аккаунт защищён двухэтапной аутентификацией.",
@@ -40,24 +47,6 @@
return password.length > 0; return password.length > 0;
}); });
function reset() {
step = "phone";
loginId = "";
phone = "";
code = "";
password = "";
}
function next(): Promise<LoginState> {
if (step === "phone") {
return startLogin(phone.trim());
}
if (step === "code") {
return submitLoginCode(loginId, code.trim());
}
return submitLoginPassword(loginId, password);
}
async function apply(state: LoginState) { async function apply(state: LoginState) {
if (state.stage !== "done") { if (state.stage !== "done") {
loginId = state.login_id; loginId = state.login_id;
@@ -73,6 +62,69 @@
open = false; open = false;
} }
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
}
function drop() {
attempt += 1;
if (loginId) {
cancelLogin(loginId).catch(() => undefined);
loginId = "";
}
}
async function watchQr() {
const mine = attempt;
try {
let state = await startQrLogin();
while (mine === attempt && state.stage === "qr") {
loginId = state.login_id;
qrUrl = state.qr_url ?? "";
state = await pollQrLogin(state.login_id);
}
if (mine === attempt) {
await apply(state);
}
} catch (error) {
if (mine === attempt) {
fail(error, "Не удалось получить QR-код");
}
}
}
function useQr() {
drop();
step = "qr";
qrUrl = "";
watchQr();
}
function usePhone() {
drop();
step = "phone";
qrUrl = "";
}
function begin() {
attempt += 1;
loginId = "";
phone = "";
code = "";
password = "";
useQr();
}
function next(): Promise<LoginState> {
if (step === "phone") {
return startLogin(phone.trim());
}
if (step === "code") {
return submitLoginCode(loginId, code.trim());
}
return submitLoginPassword(loginId, password);
}
async function submit(event: SubmitEvent) { async function submit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (busy || !filled) { if (busy || !filled) {
@@ -82,20 +134,23 @@
try { try {
await apply(await next()); await apply(await next());
} catch (error) { } catch (error) {
toasts.error( fail(error, "Не удалось войти");
error instanceof ApiError ? error.detail : "Не удалось войти"
);
} finally { } finally {
busy = false; busy = false;
} }
} }
function onOpenChange(value: boolean) { function onOpenChange(value: boolean) {
if (!value && loginId) { if (!value) {
cancelLogin(loginId).catch(() => undefined); drop();
} }
reset();
} }
$effect(() => {
if (open) {
untrack(begin);
}
});
</script> </script>
<Dialog.Root bind:open {onOpenChange}> <Dialog.Root bind:open {onOpenChange}>
@@ -108,6 +163,21 @@
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
{#if step === "qr"}
<div class="dialog-body">
<p class="hint">{hints.qr}</p>
<div class="qr-slot">
{#if qrUrl}
<QrCode value={qrUrl} label="QR-код для входа в Telegram" />
{/if}
</div>
</div>
<div class="dialog-actions">
<Button variant="text" pill onclick={usePhone}>
Войти по номеру телефона
</Button>
</div>
{:else}
<form onsubmit={submit}> <form onsubmit={submit}>
<div class="dialog-body"> <div class="dialog-body">
<p class="hint">{hints[step]}</p> <p class="hint">{hints[step]}</p>
@@ -151,11 +221,20 @@
{/if} {/if}
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<Button type="submit" pill loading={busy} disabled={busy || !filled}> {#if step === "phone"}
<Button variant="text" pill onclick={useQr}>QR-код</Button>
{/if}
<Button
type="submit"
pill
loading={busy}
disabled={busy || !filled}
>
{step === "phone" ? "Отправить код" : "Продолжить"} {step === "phone" ? "Отправить код" : "Продолжить"}
</Button> </Button>
</div> </div>
</form> </form>
{/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
@@ -166,4 +245,14 @@
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.qr-slot {
display: grid;
place-items: center;
width: min(15rem, 100%);
aspect-ratio: 1;
margin: 0 auto;
background: var(--color-background-secondary);
border-radius: 0.75rem;
}
</style> </style>
@@ -0,0 +1,40 @@
<script lang="ts">
import { encode } from "uqr";
const QUIET_ZONE = 2;
let { value, label }: { value: string; label: string } = $props();
const code = $derived.by(() => {
const qr = encode(value, { ecc: "M", border: 0 });
const path = qr.data
.flatMap((row, y) =>
row.flatMap((filled, x) => (filled ? [`M${x} ${y}h1v1h-1z`] : []))
)
.join("");
return { extent: qr.size + QUIET_ZONE * 2, path };
});
</script>
<svg
class="qr"
viewBox="0 0 {code.extent} {code.extent}"
role="img"
aria-label={label}
>
<rect width={code.extent} height={code.extent} fill="var(--qr-bg, #fff)" />
<path
d={code.path}
fill="var(--qr-fg, #000)"
transform="translate({QUIET_ZONE} {QUIET_ZONE})"
/>
</svg>
<style lang="scss">
.qr {
display: block;
width: 100%;
height: auto;
border-radius: 0.75rem;
}
</style>