feat(api,frontend): add qr code login
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
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 userbot import PyroClient
|
||||
from utils.env import env
|
||||
|
||||
LOGIN_TTL_SECONDS = 900
|
||||
QR_POLL_SECONDS = 25
|
||||
PENDING_DIRNAME = "pending"
|
||||
|
||||
|
||||
@@ -18,12 +21,27 @@ class LoginError(Exception):
|
||||
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
|
||||
class PendingLogin:
|
||||
client: PyroClient
|
||||
phone: str
|
||||
phone_code_hash: str
|
||||
started_at: float
|
||||
phone: str = ""
|
||||
phone_code_hash: str = ""
|
||||
qr: QrState | None = None
|
||||
watcher: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
def _sessions_dir() -> Path:
|
||||
@@ -38,6 +56,29 @@ def _pending_dir() -> 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:
|
||||
def __init__(self) -> None:
|
||||
self._logins: dict[str, PendingLogin] = {}
|
||||
@@ -49,21 +90,53 @@ class LoginManager:
|
||||
raise LoginError(msg)
|
||||
return login
|
||||
|
||||
async def start(self, phone: str) -> str:
|
||||
async def _connect(self) -> tuple[str, PyroClient]:
|
||||
await self._sweep()
|
||||
login_id = secrets.token_hex(8)
|
||||
client = PyroClient(login_id, workdir=str(_pending_dir()), load_handlers=False)
|
||||
await client.connect()
|
||||
return login_id, client
|
||||
|
||||
async def start(self, phone: str) -> str:
|
||||
login_id, client = await self._connect()
|
||||
try:
|
||||
sent = await client.send_code(phone)
|
||||
except Exception:
|
||||
await self._discard(login_id, client)
|
||||
raise
|
||||
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
|
||||
|
||||
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:
|
||||
login = self._get(login_id)
|
||||
try:
|
||||
@@ -81,6 +154,7 @@ class LoginManager:
|
||||
|
||||
async def finalize(self, login_id: str, session_name: str) -> None:
|
||||
login = self._logins.pop(login_id)
|
||||
await _stop_watcher(login)
|
||||
await login.client.disconnect()
|
||||
source = _pending_dir() / f"{login_id}.session"
|
||||
source.replace(_sessions_dir() / f"{session_name}.session")
|
||||
@@ -88,6 +162,7 @@ class LoginManager:
|
||||
async def cancel(self, login_id: str) -> None:
|
||||
login = self._logins.pop(login_id, None)
|
||||
if login is not None:
|
||||
await _stop_watcher(login)
|
||||
await self._discard(login_id, login.client)
|
||||
|
||||
async def _discard(self, login_id: str, client: PyroClient) -> None:
|
||||
|
||||
@@ -37,7 +37,8 @@ class PasswordRequest(BaseModel):
|
||||
|
||||
class LoginState(BaseModel):
|
||||
login_id: str
|
||||
stage: Literal["code", "password", "done"]
|
||||
stage: Literal["code", "qr", "password", "done"]
|
||||
qr_url: str | 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")
|
||||
|
||||
|
||||
@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")
|
||||
async def submit_code(
|
||||
login_id: str, body: CodeRequest, pool: FromDishka[asyncpg.Pool]
|
||||
|
||||
@@ -17,7 +17,7 @@ class PyroClient(Client):
|
||||
api_hash="b18441a1ff607e10a989891a5462e627",
|
||||
device_model="Desktop",
|
||||
system_version="Windows 11 x64",
|
||||
app_version="6.2.4 x64",
|
||||
app_version="6.7.8 x64",
|
||||
lang_pack="tdesktop",
|
||||
client_platform=enums.ClientPlatform.DESKTOP,
|
||||
)
|
||||
|
||||
+3
-1
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
@@ -8,6 +7,7 @@
|
||||
"bits-ui": "^2.18.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"pako": "^2.1.0",
|
||||
"uqr": "^0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
@@ -388,6 +388,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.18.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"pako": "^2.1.0"
|
||||
"pako": "^2.1.0",
|
||||
"uqr": "^0.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
loginId: string,
|
||||
code: string
|
||||
|
||||
@@ -22,11 +22,12 @@ export interface Account {
|
||||
tg_user_id: number | null;
|
||||
}
|
||||
|
||||
export type LoginStage = "code" | "password" | "done";
|
||||
export type LoginStage = "code" | "qr" | "password" | "done";
|
||||
|
||||
export interface LoginState {
|
||||
account: Account | null;
|
||||
login_id: string;
|
||||
qr_url: string | null;
|
||||
stage: LoginStage;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import { untrack } from "svelte";
|
||||
import { ApiError } from "$lib/api/client";
|
||||
import {
|
||||
cancelLogin,
|
||||
pollQrLogin,
|
||||
startLogin,
|
||||
startQrLogin,
|
||||
submitLoginCode,
|
||||
submitLoginPassword,
|
||||
} from "$lib/api/endpoints";
|
||||
import type { LoginState } from "$lib/api/types";
|
||||
import Button from "$lib/components/ui/Button.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 { 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 step = $state<Step>("phone");
|
||||
let step = $state<Step>("qr");
|
||||
let loginId = $state("");
|
||||
let qrUrl = $state("");
|
||||
let phone = $state("");
|
||||
let code = $state("");
|
||||
let password = $state("");
|
||||
let busy = $state(false);
|
||||
let attempt = 0;
|
||||
|
||||
const hints: Record<Step, string> = {
|
||||
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
|
||||
phone: "Номер телефона в международном формате, например +79991234567.",
|
||||
code: "Код отправлен в Telegram на этот номер.",
|
||||
password: "Аккаунт защищён двухэтапной аутентификацией.",
|
||||
@@ -40,24 +47,6 @@
|
||||
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) {
|
||||
if (state.stage !== "done") {
|
||||
loginId = state.login_id;
|
||||
@@ -73,6 +62,69 @@
|
||||
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) {
|
||||
event.preventDefault();
|
||||
if (busy || !filled) {
|
||||
@@ -82,20 +134,23 @@
|
||||
try {
|
||||
await apply(await next());
|
||||
} catch (error) {
|
||||
toasts.error(
|
||||
error instanceof ApiError ? error.detail : "Не удалось войти"
|
||||
);
|
||||
fail(error, "Не удалось войти");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && loginId) {
|
||||
cancelLogin(loginId).catch(() => undefined);
|
||||
if (!value) {
|
||||
drop();
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
untrack(begin);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
@@ -108,6 +163,21 @@
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</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}>
|
||||
<div class="dialog-body">
|
||||
<p class="hint">{hints[step]}</p>
|
||||
@@ -151,11 +221,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
<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" ? "Отправить код" : "Продолжить"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -166,4 +245,14 @@
|
||||
font-size: 0.9375rem;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user