Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot
Ролевая система вместо привязки к Telegram ---------------------------------------- Раньше единственный способ получить админ-доступ — admin_guard, пускавший по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на Owner/Moderator/Partner: - migrations/0005_roles_and_partners.sql — username/password_hash на users (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/ tg_id), can_manage_nodes, commission_percent, partner_code, referred_by_partner_id; новые таблицы partner_commissions и withdrawal_requests. - auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/ node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs. - auth/service.rs — hash_password/login_staff со случайной Argon2-солью на каждого юзера (не общий фиксированный ARGON_SALT, который годится для высокоэнтропийных seed-логинов, но не для человеческих паролей). - POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем, переиспользует существующий issue_session(). - --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) — вместо ручного SQL для создания первого владельца с VPS-консоли. - src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения can_manage_nodes, списка заявок на вывод и их approve/reject/paid. - Комиссия партнёра начисляется в BillingService::confirm_payment отдельным шагом после существующей рефералки, пересчитывается через get_plan_price(invoice.plan_name, invoice.currency) — НЕ через invoice.amount напрямую, потому что у провайдеров разная внутренняя единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую). - bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа). - frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage поверх существующего Vite-админ-фронта (без отдельного проекта). Оплата через @CryptoBot ------------------------ - modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay API), плюс попутно исправлены два реальных бага в существующем коде: confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко всем провайдерам вместо родной валюты инвойса. - bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,12 @@ pub struct ExchangePayload {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct AdminLoginPayload {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/telegram", post(auth_telegram_api))
|
||||
@@ -40,6 +46,7 @@ pub fn router() -> Router<ApiState> {
|
||||
.route("/telegram/session/{code}", get(get_telegram_session_api))
|
||||
.route("/seed/generate", post(generate_seed_api))
|
||||
.route("/seed/login", post(login_seed_api))
|
||||
.route("/admin/login", post(admin_login_api))
|
||||
.route("/exchange", post(exchange_bootstrap_api))
|
||||
.route("/refresh", post(refresh_api))
|
||||
.route("/logout", post(logout_api))
|
||||
@@ -245,6 +252,32 @@ async fn login_seed_api(
|
||||
))
|
||||
}
|
||||
|
||||
/// Логин Owner/Moderator/Partner по логину+паролю — отдельная админка,
|
||||
/// не завязанная на Telegram/seed (см. `AuthService::login_staff`).
|
||||
async fn admin_login_api(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<AdminLoginPayload>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let session = state
|
||||
.auth_service
|
||||
.login_staff(
|
||||
&payload.username,
|
||||
&payload.password,
|
||||
&state.jwt_secret,
|
||||
user_agent(&headers),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut out_headers = HeaderMap::new();
|
||||
set_session_cookies(&mut out_headers, &state, &session);
|
||||
|
||||
Ok((
|
||||
out_headers,
|
||||
Json(json!({ "status": "success", "token": session.access_token })),
|
||||
))
|
||||
}
|
||||
|
||||
/// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки
|
||||
/// «Личный Кабинет»/«Тарифы»/«Синдикат», токен приходит в `#token=` ссылки
|
||||
/// WebApp) на полноценную cookie-сессию.
|
||||
|
||||
+68
-17
@@ -95,27 +95,78 @@ pub async fn auth_guard(
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Проверяет права администратора.
|
||||
pub async fn admin_guard(
|
||||
State(state): State<ApiState>,
|
||||
// Ролевые гварды админки (Owner/Moderator/Partner) — заменяют старый
|
||||
// `admin_guard`, который держался на одном захардкоженном ADMIN_TG_ID.
|
||||
// Роль читается из уже инжектнутого auth_guard'ом User, второй запрос к БД
|
||||
// не нужен.
|
||||
|
||||
fn current_user(req: &Request) -> Result<&User, AppError> {
|
||||
req.extensions()
|
||||
.get::<User>()
|
||||
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))
|
||||
}
|
||||
|
||||
fn deny(user: &User) -> AppError {
|
||||
warn!(
|
||||
"SECURITY_ALERT: Unauthorized admin access attempt by user_id={} role={:?}",
|
||||
user.id, user.role
|
||||
);
|
||||
AppError::Unauthorized("Insufficient clearance level".into())
|
||||
}
|
||||
|
||||
/// Только Owner.
|
||||
pub async fn owner_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()))?;
|
||||
|
||||
// Используем закешированный в памяти ID
|
||||
if user.tg_id == Some(state.admin_tg_id) || user.role == "admin" {
|
||||
let user = current_user(&req)?;
|
||||
if user.role == "owner" {
|
||||
Ok(next.run(req).await)
|
||||
} else {
|
||||
warn!(
|
||||
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}",
|
||||
user.tg_username
|
||||
);
|
||||
Err(AppError::Unauthorized(
|
||||
"Insufficient clearance level".into(),
|
||||
))
|
||||
Err(deny(user))
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner или Moderator (любой, независимо от `can_manage_nodes`).
|
||||
pub async fn staff_guard(
|
||||
State(_state): State<ApiState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let user = current_user(&req)?;
|
||||
if user.role == "owner" || user.role == "moderator" {
|
||||
Ok(next.run(req).await)
|
||||
} else {
|
||||
Err(deny(user))
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner ИЛИ Moderator с выданным правом `can_manage_nodes` — гейт для
|
||||
/// управления нодами.
|
||||
pub async fn node_manage_guard(
|
||||
State(_state): State<ApiState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let user = current_user(&req)?;
|
||||
if user.role == "owner" || (user.role == "moderator" && user.can_manage_nodes) {
|
||||
Ok(next.run(req).await)
|
||||
} else {
|
||||
Err(deny(user))
|
||||
}
|
||||
}
|
||||
|
||||
/// Только Partner.
|
||||
pub async fn partner_guard(
|
||||
State(_state): State<ApiState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let user = current_user(&req)?;
|
||||
if user.role == "partner" {
|
||||
Ok(next.run(req).await)
|
||||
} else {
|
||||
Err(deny(user))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе)
|
||||
//! и JWT-гварды (`auth_guard`, `admin_guard`). См. README.md рядом с этим файлом.
|
||||
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
|
||||
//! логин Owner/Moderator/Partner по паролю, и ролевые JWT-гварды (`auth_guard` +
|
||||
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`). См. README.md рядом.
|
||||
|
||||
pub mod controller;
|
||||
pub mod guard;
|
||||
pub mod service;
|
||||
|
||||
pub use guard::{admin_guard, auth_guard};
|
||||
pub use guard::{auth_guard, node_manage_guard, owner_guard, partner_guard, staff_guard};
|
||||
pub use service::{AuthService, Claims};
|
||||
|
||||
@@ -239,6 +239,76 @@ impl AuthService {
|
||||
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Логин Owner/Moderator/Partner по паролю (не Telegram/seed) ---
|
||||
// Отдельная схема хеширования от `hash_seed_argon2`: там общая на все
|
||||
// seed'ы фиксированная соль из ARGON_SALT — приемлемо для
|
||||
// высокоэнтропийных сгенерированных seed'ов, но не для человеческих
|
||||
// паролей (одна утечка ARGON_SALT — и все пароли колются разом одной
|
||||
// radeoduga-таблицей). Здесь — самодостаточная PHC-строка со случайной
|
||||
// солью на каждый пароль (`argon2::password_hash`).
|
||||
|
||||
/// Хеширует пароль со случайной солью. Используется и при создании
|
||||
/// Owner/Moderator/Partner (`staff` controller), и в CLI-бутстрапе
|
||||
/// (`--create-owner`) — важно, чтобы схема была идентичной везде.
|
||||
pub fn hash_password(password: &str) -> Result<String, AppError> {
|
||||
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| AppError::Internal(format!("Ошибка хеширования пароля: {e}")))
|
||||
}
|
||||
|
||||
/// Логин Owner/Moderator/Partner по логину+паролю. Одинаковая ошибка на
|
||||
/// "нет юзера" и "неверный пароль" — без user enumeration.
|
||||
#[instrument(skip(self, password, jwt_secret))]
|
||||
pub async fn login_staff(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
jwt_secret: &str,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<Session, AppError> {
|
||||
use argon2::password_hash::{PasswordHash, PasswordVerifier};
|
||||
|
||||
let generic_err = || AppError::Unauthorized("Invalid credentials".into());
|
||||
|
||||
let user = self
|
||||
.repo
|
||||
.get_user_by_username(username)
|
||||
.await?
|
||||
.ok_or_else(generic_err)?;
|
||||
let hash = user.password_hash.as_deref().ok_or_else(generic_err)?;
|
||||
let parsed_hash = PasswordHash::new(hash).map_err(|_| generic_err())?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.map_err(|_| generic_err())?;
|
||||
|
||||
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ищет партнёра по коду диплинка (`t.me/<bot>?start=p_XXXXXX`).
|
||||
pub async fn find_partner_by_code(&self, code: &str) -> Result<Option<User>, AppError> {
|
||||
self.repo
|
||||
.find_partner_by_code(code)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Привязывает покупателя к партнёру (first-wins, no-op если уже привязан).
|
||||
pub async fn attribute_partner_referral(
|
||||
&self,
|
||||
partner_id: Uuid,
|
||||
buyer_id: Uuid,
|
||||
) -> Result<bool, AppError> {
|
||||
self.repo
|
||||
.set_referred_by_partner(buyer_id, partner_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
// --- Magic-link вход через Telegram-бота для десктоп/мобильного приложения ---
|
||||
// Приложение не может встроить Telegram Login Widget (нет домена/WebView-моста
|
||||
// как у веб-ЛК), поэтому вместо него: приложение создаёт auth_code и открывает
|
||||
@@ -272,6 +342,36 @@ impl AuthService {
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Код для входа, который генерирует **сам бот** по кнопке в меню — в
|
||||
/// отличие от `create_telegram_login_code` (deep-link из приложения),
|
||||
/// боту не нужен отдельный шаг подтверждения: сам факт диалога уже
|
||||
/// удостоверяет tg_id, поэтому код создаётся сразу подтверждённым. Юзер
|
||||
/// вручную вводит его в приложении. Deep-link-флоу ненадёжен для юзеров,
|
||||
/// уже открывавших бота раньше — Telegram не пересылает `?start=` заново
|
||||
/// для уже начатых чатов, только пустой `/start`.
|
||||
pub async fn create_bot_login_code(
|
||||
&self,
|
||||
tg_id: i64,
|
||||
username: Option<String>,
|
||||
) -> Result<String, AppError> {
|
||||
let user = self.process_login(tg_id, username, None).await?;
|
||||
|
||||
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)?;
|
||||
self.repo
|
||||
.verify_auth_session(&auth_code, user.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(auth_code)
|
||||
}
|
||||
|
||||
/// Опрашивается приложением. `Ok(None)` — код существует, ждём подтверждения
|
||||
/// в боте; `Err(Unauthorized)` — код не найден или истёк.
|
||||
#[instrument(skip(self, jwt_secret))]
|
||||
|
||||
Reference in New Issue
Block a user