Добавить проверку токена прокси, динамические лимиты трафика и 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:
@@ -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`, кнопки
|
||||
|
||||
@@ -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 — это намеренно.
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod nodes;
|
||||
pub mod proxy_internal;
|
||||
pub mod referrals;
|
||||
pub mod users;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Внутренний контракт для `netrunner-proxy`: проверка JWT-токена клиента и учёт
|
||||
//! расхода трафика. Это не пользовательская сессия — прокси не логинится как юзер,
|
||||
//! поэтому вместо `auth_guard`/cookie здесь общий секрет (`X-Internal-Secret`,
|
||||
//! см. [`internal_secret_guard`]), которым делятся все прокси-ноды и бэкенд.
|
||||
|
||||
use crate::{api::ApiState, error::AppError};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn internal_secret_guard(
|
||||
State(state): State<ApiState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let provided = req
|
||||
.headers()
|
||||
.get("X-Internal-Secret")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
if provided != Some(state.proxy_internal_secret.as_str()) {
|
||||
return Err(AppError::Unauthorized("Invalid internal secret".into()));
|
||||
}
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/validate", post(validate_api))
|
||||
.route("/usage", post(usage_api))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ValidatePayload {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ValidateResponse {
|
||||
user_id: Uuid,
|
||||
limit_bytes: Option<i64>,
|
||||
used_bytes: i64,
|
||||
}
|
||||
|
||||
/// Декодирует access-JWT тем же путём, что и `auth::guard::auth_guard`, но без
|
||||
/// cookie/CSRF-логики (прокси всегда шлёт Bearer в теле, не в заголовках браузера).
|
||||
async fn validate_api(
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<ValidatePayload>,
|
||||
) -> Result<Json<ValidateResponse>, AppError> {
|
||||
let token_data = jsonwebtoken::decode::<crate::modules::auth::service::Claims>(
|
||||
&payload.token,
|
||||
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||
&jsonwebtoken::Validation::default(),
|
||||
)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user_id = Uuid::parse_str(&token_data.claims.sub)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid token payload".into()))?;
|
||||
|
||||
let user = state
|
||||
.repo
|
||||
.get_user_by_id(user_id)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
|
||||
Ok(Json(ValidateResponse {
|
||||
user_id: user.id,
|
||||
limit_bytes: user.data_limit_bytes,
|
||||
used_bytes: user.data_used_bytes,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsagePayload {
|
||||
user_id: Uuid,
|
||||
delta_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UsageResponse {
|
||||
used_bytes: i64,
|
||||
limit_bytes: Option<i64>,
|
||||
over_limit: bool,
|
||||
}
|
||||
|
||||
/// Прибавляет репортнутую прокси дельту трафика к счётчику юзера и сразу
|
||||
/// отвечает, не превышен ли лимит — прокси разрывает сессию в тот же тик, если
|
||||
/// `over_limit == true`, не делая отдельный round-trip.
|
||||
async fn usage_api(
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<UsagePayload>,
|
||||
) -> Result<Json<UsageResponse>, AppError> {
|
||||
let (used_bytes, limit_bytes) = state
|
||||
.repo
|
||||
.apply_usage_delta(payload.user_id, payload.delta_bytes)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
|
||||
let over_limit = limit_bytes.is_some_and(|limit| used_bytes >= limit);
|
||||
|
||||
Ok(Json(UsageResponse {
|
||||
used_bytes,
|
||||
limit_bytes,
|
||||
over_limit,
|
||||
}))
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
use axum::{
|
||||
extract::{Extension, State},
|
||||
extract::{Extension, Path, State},
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
routing::{get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard};
|
||||
use crate::{
|
||||
api::ApiState,
|
||||
db::models::User,
|
||||
error::AppError,
|
||||
modules::auth::{admin_guard, guard::auth_guard},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct HackResultPayload {
|
||||
@@ -26,6 +32,41 @@ pub fn router(state: ApiState) -> Router<ApiState> {
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetLimitPayload {
|
||||
/// `None`/`null` — снять лимит (безлимит).
|
||||
pub data_limit_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
/// Секретный роутер для Nexus (Admin) — динамический лимит трафика на юзера,
|
||||
/// читается прокси-нодами на каждой проверке (`internal/validate`, `internal/usage`),
|
||||
/// поэтому вступает в силу без перезапуска/передеплоя прокси.
|
||||
pub fn admin_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/{id}/limit", patch(set_user_limit))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), admin_guard))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
#[instrument(skip(state, payload))]
|
||||
async fn set_user_limit(
|
||||
State(state): State<ApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<SetLimitPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let user = state
|
||||
.repo
|
||||
.set_data_limit(id, payload.data_limit_bytes)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "success",
|
||||
"user_id": user.id,
|
||||
"data_limit_bytes": user.data_limit_bytes,
|
||||
})))
|
||||
}
|
||||
|
||||
#[instrument(skip(state, user), fields(tg_id = user.tg_id))]
|
||||
async fn get_me(
|
||||
Extension(user): Extension<User>,
|
||||
|
||||
Reference in New Issue
Block a user