security: fix CSRF/ownership/timing vulns; add billing reconciliation

- auth/guard: добавить CSRF-проверку Origin/Referer для cookie-мутаций,
  исправить инвертированную логику admin_guard (пускал всех, блокировал админов),
  перевести admin_guard на from_fn_with_state
- auth/service: timing-safe сравнение HMAC через verify_slice вместо != по hex;
  криптостойкий Seed через Alphanumeric (был цифровой 0-9)
- billing: confirm_payment проверяет owner (caller_id == invoice.user_id);
  активация триала только для Telegram-аккаунтов (Sybil-защита);
  реализована reconcile_pending_payments — сверка blockchain->pending-инвойсы
- billing/providers: get_recent_transactions перенесён в трейт PaymentProvider
- nodes/service: unwrap() -> map_err по всему SSH-пути; убран TcpStream import
- docker: multi-stage build с cargo-chef, non-root user, healthcheck;
  prod-compose добавляет one-shot migrate-сервис, убирает проброс порта БД
- deploy.yml: параметр image_tag, set -e, pull всего стека вместо только app
- users/service: обработка NO_TICKETS / NO_USER из БД вместо catch-all

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kirill
2026-06-29 17:58:24 +07:00
parent 26d6f2db8a
commit f06cc48763
19 changed files with 605 additions and 177 deletions
+53 -19
View File
@@ -1,37 +1,68 @@
use crate::{api::ApiState, db::models::User, error::AppError};
use axum::{
extract::{Request, State},
http::Method,
middleware::Next,
response::Response,
};
use tracing::warn;
/// Проверяет JWT и инжектит User в запрос.
/// Аналог AuthGuard в NestJS.
/// Проверяет JWT и инжектит User в запрос. Добавлена базовая защита от CSRF.
pub async fn auth_guard(
State(state): State<ApiState>,
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let auth_header = req
let method = req.method().clone();
let is_mutating = matches!(
method,
Method::POST | Method::DELETE | Method::PUT | Method::PATCH
);
// Определяем источник токена. Bearer в заголовке Authorization атакующий не может
// выставить из чужого origin, поэтому CSRF ему не грозит. CSRF опасен только для
// cookie-аутентификации, которую браузер подставляет автоматически.
let bearer = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(|t| t.to_string());
// 1. Получаем чистый токен (String)
let token = if let Some(h) = auth_header {
h.replace("Bearer ", "")
let (token, from_cookie) = if let Some(t) = bearer {
(t, false)
} else {
req.headers()
let cookie_token = req
.headers()
.get("Cookie")
.and_then(|c| c.to_str().ok())
.and_then(|c| c.split("; ").find(|part| part.starts_with("nrxp_token=")))
.map(|part| part.replace("nrxp_token=", ""))
.ok_or_else(|| AppError::Unauthorized("Access token required".into()))?
.ok_or_else(|| AppError::Unauthorized("Access token required".into()))?;
(cookie_token, true)
};
// 2. Декодируем токен, используя полученную выше переменную `token`
// Передаем ссылку &token, так как jsonwebtoken ожидает &str
// CSRF-защита нужна только для cookie-сессий и только на мутирующих методах.
if from_cookie && is_mutating {
let origin = req.headers().get("Origin").and_then(|v| v.to_str().ok());
let referer = req.headers().get("Referer").and_then(|v| v.to_str().ok());
let is_valid_origin = origin == Some("https://netrunner-vpn.com");
let is_valid_referer = referer
.map(|r| r.starts_with("https://netrunner-vpn.com"))
.unwrap_or(false);
if !is_valid_origin && !is_valid_referer {
warn!(
"CSRF BLOCK: Request rejected. Origin: {:?}, Referer: {:?}",
origin, referer
);
return Err(AppError::Unauthorized(
"Invalid Origin/Referer (CSRF protection)".into(),
));
}
}
let token_data = jsonwebtoken::decode::<super::service::Claims>(
&token,
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
@@ -53,23 +84,26 @@ pub async fn auth_guard(
}
/// Проверяет права администратора.
/// Должен идти строго после auth_guard.
pub async fn admin_guard(req: Request, next: Next) -> Result<Response, AppError> {
pub async fn admin_guard(
State(state): State<ApiState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let user = req
.extensions()
.get::<User>()
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))?;
let admin_id: i64 = std::env::var("ADMIN_TG_ID").unwrap().parse().unwrap();
if user.tg_id == Some(admin_id) || user.role == "admin" {
// Используем закешированный в памяти ID
if user.tg_id == Some(state.admin_tg_id) || user.role == "admin" {
Ok(next.run(req).await)
} else {
warn!(
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}",
user.tg_username
);
return Err(AppError::Unauthorized(
Err(AppError::Unauthorized(
"Insufficient clearance level".into(),
));
))
}
Ok(next.run(req).await)
}
+19 -13
View File
@@ -2,11 +2,11 @@ use argon2::Argon2;
use chrono::Utc;
use hmac::{Hmac, KeyInit, Mac};
use jsonwebtoken::{encode, EncodingKey, Header};
use rand::Rng;
use rand::{distributions::Alphanumeric, Rng};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tracing::{error, info, instrument};
use uuid::Uuid; // Не забудь добавить rand в Cargo.toml
use uuid::Uuid;
use crate::{
db::{models::User, repository::AppRepository},
@@ -30,7 +30,6 @@ impl AuthService {
Self { repo }
}
/// Генерирует JWT на основе внутреннего ID и опционального Telegram ID
#[instrument(skip(self, secret))]
pub fn generate_auth_token(
&self,
@@ -41,7 +40,6 @@ impl AuthService {
let claims = Claims {
sub: user_id.to_string(),
tg_id,
// Время жизни токена сокращено до 10 минут
exp: (Utc::now() + chrono::Duration::minutes(10)).timestamp() as usize,
};
@@ -65,18 +63,20 @@ impl AuthService {
Ok(hex::encode(hash_output))
}
/// Ghost Protocol: Регистрация нового анонимного юзера
pub async fn register_seed(
&self,
salt: &str,
jwt_secret: &str,
) -> Result<(String, String), AppError> {
let seed: String = {
let mut rng = rand::thread_rng();
(0..16).map(|_| rng.gen_range(0..10).to_string()).collect()
};
// Генерация криптостойкого 16-символьного Seed (Alphanumeric)
let seed: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
// Используем мощный Argon2 вместо быстрого SHA256
// Используем тот же детерминированный Argon2 (фиксированная соль), что и login_seed,
// иначе сгенерированный при регистрации хеш никогда не совпадёт при входе.
let hash = Self::hash_seed_argon2(&seed, salt)?;
let user = self.repo.create_seed_user(&hash).await?;
@@ -104,6 +104,8 @@ impl AuthService {
}
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---
// Сигнатура повторяет поля payload-а Telegram Login Widget — это намеренно.
#[allow(clippy::too_many_arguments)]
#[instrument(skip(self, bot_token, hash))]
pub fn verify_tg_widget_auth(
&self,
@@ -141,9 +143,13 @@ impl AuthService {
.map_err(|e| AppError::Internal(format!("HMAC Error: {}", e)))?;
mac.update(data_check_string.as_bytes());
if hex::encode(mac.finalize().into_bytes()) != hash {
return Err(AppError::Unauthorized("Signature mismatch".into()));
}
// Сравнение тега в постоянном времени (verify_slice), чтобы исключить
// timing-атаку на побайтовый подбор валидной подписи. Сырое != по hex-строке
// завершается на первом несовпавшем байте и утекает информацию по таймингу.
let provided = hex::decode(hash)
.map_err(|_| AppError::Unauthorized("Malformed auth signature".into()))?;
mac.verify_slice(&provided)
.map_err(|_| AppError::Unauthorized("Signature mismatch".into()))?;
if Utc::now().timestamp() - auth_date > 86400 {
return Err(AppError::Unauthorized("Session expired".into()));
}