Добавить проверку токена прокси, динамические лимиты трафика и Telegram magic-link вход

Прокси теперь может валидировать клиентские JWT и отчитываться о расходе
трафика через новые internal-эндпоинты (защищены X-Internal-Secret), лимит
на юзера настраивается динамически через admin PATCH без передеплоя прокси.
Заодно реализован задокументированный, но не подключённый флоу входа через
Telegram-бота (magic-link) для десктоп/мобильного приложения, и seed-эндпоинты
теперь возвращают access_token в JSON (не только в cookie) — без этого
Tauri-приложение не могло ими пользоваться.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:30:25 +07:00
parent 1a26403afb
commit c8eea8203e
11 changed files with 485 additions and 17 deletions
+49 -3
View File
@@ -3,7 +3,11 @@ use crate::{api::ApiState, error::AppError};
use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT};
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use axum::{extract::State, routing::post, Json, Router};
use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::json;
use tracing::instrument;
@@ -32,6 +36,8 @@ pub struct ExchangePayload {
pub fn router() -> Router<ApiState> {
Router::new()
.route("/telegram", post(auth_telegram_api))
.route("/telegram/session", post(create_telegram_session_api))
.route("/telegram/session/{code}", get(get_telegram_session_api))
.route("/seed/generate", post(generate_seed_api))
.route("/seed/login", post(login_seed_api))
.route("/exchange", post(exchange_bootstrap_api))
@@ -156,6 +162,41 @@ async fn auth_telegram_api(
Ok((out_headers, Json(json!({ "status": "success" }))))
}
/// Приложение (десктоп/мобильный, без домена для Telegram Login Widget) создаёт
/// код и открывает `deep_link` в системном Telegram — юзер подтверждает вход
/// командой `/start <auth_code>` в боте (см. `bot.rs::handle_commands`), а
/// приложение тем временем поллит `GET .../session/{code}`.
async fn create_telegram_session_api(
State(state): State<ApiState>,
) -> Result<impl IntoResponse, AppError> {
let auth_code = state.auth_service.create_telegram_login_code().await?;
let deep_link = format!("https://t.me/{}?start={}", state.bot_username, auth_code);
Ok(Json(json!({ "auth_code": auth_code, "deep_link": deep_link })))
}
#[instrument(skip(state, headers))]
async fn get_telegram_session_api(
State(state): State<ApiState>,
Path(code): Path<String>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let session = state
.auth_service
.poll_telegram_login(&code, &state.jwt_secret, user_agent(&headers))
.await?;
let Some(session) = session else {
return Ok(with_headers(HeaderMap::new(), Json(json!({ "status": "pending" }))));
};
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
Ok(with_headers(
out_headers,
Json(json!({ "status": "success", "token": session.access_token })),
))
}
#[axum::debug_handler]
async fn generate_seed_api(
State(state): State<ApiState>,
@@ -171,9 +212,11 @@ async fn generate_seed_api(
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
// Cookie — для веб-фронта; access_token в теле — для Tauri-приложения
// (кросс-origin custom scheme, Lax-cookie туда не долетают, см. `src/lib/api.ts`).
Ok((
out_headers,
Json(json!({ "status": "success", "seed": seed })),
Json(json!({ "status": "success", "seed": seed, "token": session.access_token })),
))
}
@@ -196,7 +239,10 @@ async fn login_seed_api(
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
Ok((out_headers, Json(json!({ "status": "success" }))))
Ok((
out_headers,
Json(json!({ "status": "success", "token": session.access_token })),
))
}
/// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки
+70
View File
@@ -239,6 +239,76 @@ impl AuthService {
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await
}
// --- Magic-link вход через Telegram-бота для десктоп/мобильного приложения ---
// Приложение не может встроить Telegram Login Widget (нет домена/WebView-моста
// как у веб-ЛК), поэтому вместо него: приложение создаёт auth_code и открывает
// `t.me/<bot>?start=<auth_code>` (см. `bot.rs::handle_commands`), а само поллит
// `GET /auth/telegram/session/{code}`, пока бот не подтвердит код.
/// Создаёт ожидающую подтверждения сессию, возвращает код для deep-link.
pub async fn create_telegram_login_code(&self) -> Result<String, AppError> {
let auth_code: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
self.repo
.create_auth_session(&auth_code)
.await
.map_err(AppError::Database)?;
Ok(auth_code)
}
/// Вызывается ботом на `/start <auth_code>`: если код существует, ещё не
/// подтверждён и не истёк — привязывает его к вошедшему в боте юзеру.
pub async fn confirm_telegram_login_code(
&self,
auth_code: &str,
user_id: Uuid,
) -> Result<bool, AppError> {
self.repo
.verify_auth_session(auth_code, user_id)
.await
.map_err(AppError::Database)
}
/// Опрашивается приложением. `Ok(None)` — код существует, ждём подтверждения
/// в боте; `Err(Unauthorized)` — код не найден или истёк.
#[instrument(skip(self, jwt_secret))]
pub async fn poll_telegram_login(
&self,
auth_code: &str,
jwt_secret: &str,
user_agent: Option<&str>,
) -> Result<Option<Session>, AppError> {
let (user_id, is_verified, expires_at) = self
.repo
.get_auth_session(auth_code)
.await
.map_err(AppError::Database)?
.ok_or_else(|| AppError::Unauthorized("Invalid auth code".into()))?;
if expires_at < Utc::now() {
return Err(AppError::Unauthorized("Auth code expired".into()));
}
if !is_verified {
return Ok(None);
}
let user_id = user_id
.ok_or_else(|| AppError::Internal("Verified auth session missing user_id".into()))?;
let user = self
.repo
.get_user_by_id(user_id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::UserNotFound)?;
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await
.map(Some)
}
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
// Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно.