Unify auth into refresh-token cookie sessions; server-authoritative nodes and Cyberhack

Auth was effectively broken for every login path: the Telegram handler
built a Set-Cookie header but never attached it to the response, and
Ghost Protocol (seed) login never set a cookie at all — so billing, the
game, and admin actions never actually worked once the frontend was fixed
to stop trying to read an HttpOnly cookie from JS. Replaced the bare
10-minute JWT with AuthService::issue_session: a 15-minute access cookie
plus a rotating 30-day refresh cookie (opaque token, only its SHA-256
hash stored in the new refresh_sessions table); reusing an already-
rotated refresh token now revokes every session for that user. CSRF/CORS
origin allowlists moved from a single hardcoded domain to env-configured
lists so the same session works across the landing and account subdomains.
The bot's WebApp deep links (Личный Кабинет/Тарифы/Синдикат) now bridge
through a short-lived bootstrap token exchanged via POST /auth/exchange
instead of a bare access token in the URL.

Nodes: /nodes/routing now serializes the tunnel_* fields, public_key,
sni_domain and a one-time session token gated on an active subscription
instead of a stub ip/port/protocol list; node provisioning returns 202
immediately and finishes in the background instead of blocking the
request for the whole SSH run; a new background task TCP-pings nodes
every 60s and flips online/offline itself; admin can now force-restart a
node over SSH.

Billing: TON exchange rate is cached in Redis (60s) instead of hitting
TonAPI on every invoice, and the request/response now actually uses the
requested currency and TonAPI's real (uppercase) key casing — previously
only USD/RUB were ever requested and the lookup used the wrong case, so
non-USD/RUB plans could never price correctly.

Cyberhack: the server now generates and stores the board itself and
replays the client's raw click path to compute the score, instead of
clamping a client-reported number — closes the "final score is whatever
the browser sends" hole flagged in the security audit.

Frontend: api.ts no longer tries to read the HttpOnly session cookie from
JS (that never worked) and instead relies on same-origin credentials plus
a silent refresh-and-retry on 401; AdminDashboard's restart/delete actions
are wired up; Auth.tsx drops the artificial delays and requires an
explicit seed download/confirmation before continuing, since a lost Ghost
seed is unrecoverable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:29:51 +07:00
parent 588adbb92b
commit 52531dd41a
41 changed files with 2219 additions and 508 deletions
+30 -7
View File
@@ -1,29 +1,52 @@
# `modules/auth` — авторизация
Три способа попасть в систему, всех их объединяет один и тот же JWT (`Claims { sub, tg_id, exp }`,
живёт 10 минут — предполагается, что клиент кладёт его в cookie/заголовок и продлевает
через повторный логин/бота, отдельного refresh-флоу нет):
Четыре способа попасть в систему сходятся в одной точке — `AuthService::issue_session`,
которая выдаёт **пару** токенов, а не один JWT:
- **access** — JWT (`Claims { sub, tg_id, exp }`, 15 минут), кладётся в cookie
`nrxp_token` (`Path=/`).
- **refresh** — непрозрачный случайный токен (30 дней), в БД хранится только его
SHA-256-хеш (`refresh_sessions`); кладётся в cookie `nrxp_refresh`, строго ограниченную
`Path=/api/v1/auth/refresh`, чтобы не улетать на остальные ручки.
Обе cookie — `HttpOnly; Secure; SameSite=Lax`, в проде дополнительно `Domain` из
`COOKIE_DOMAIN` (`.netrunner-vpn.com`) — одна сессия работает и на лендинге, и на ЛК на
сабдомене. Никакой JS-читаемый токен нигде не хранится (ни cookie без `HttpOnly`, ни
`localStorage`).
`POST /auth/refresh` **ротирует** refresh-токен при каждом использовании: старая запись
помечается `revoked_at`, выдаётся новая пара. Предъявление уже отозванного токена
(признак кражи/повторного использования после ротации) отзывает **все** сессии юзера.
`POST /auth/logout` отзывает текущую сессию и чистит cookie.
Способы входа:
1. **Telegram Login Widget** (`POST /auth/telegram`, `controller::auth_telegram_api`).
Фронтенд получает от виджета подписанный payload, бэкенд проверяет HMAC-SHA256 подпись
ключом `sha256(bot_token)` в **постоянное время** (`mac.verify_slice`, не строковое
`!=`) — это защита от timing-атаки на подбор подписи (см. `service::verify_tg_widget_auth`).
2. **Telegram-бот** (`/start [ref_code]`, обрабатывается в [`bot.rs`](../../bot.rs), не
здесь) — вызывает тот же `AuthService::process_login`.
здесь) — вызывает тот же `AuthService::process_login`. Кнопки «Личный Кабинет»/«Тарифы»/
«Синдикат» открывают WebApp с короткоживущим (60с) bootstrap-JWT в URL-фрагменте;
фронт меняет его на полноценную сессию через `POST /auth/exchange`.
3. **Ghost Protocol** (`POST /auth/seed/generate`, `POST /auth/seed/login`) — анонимный
вход без привязки к Telegram. Сервер генерирует случайный 16-символьный seed,
хеширует его Argon2 с **фиксированной** солью из `ARGON_SALT` (детерминированно —
иначе логин никогда не совпадёт с хешем, сохранённым при регистрации) и хранит только
хеш. Seed невосстановим: его теряет — теряет аккаунт.
4. **Bootstrap-обмен** (`POST /auth/exchange`) — принимает короткоживущий JWT из
WebApp-ссылки бота, проверяет подпись/`exp` и выдаёт обычную сессию через
`issue_session`. Это единственный способ входа, не создающий пользователя заново —
он уже существует (создан через Telegram/Ghost).
## Гварды (`guard.rs`)
- `auth_guard` — достаёт JWT из `Authorization: Bearer` **или** из cookie `nrxp_token`,
валидирует подпись, подтягивает юзера из БД и кладёт его в `req.extensions()` для
контроллеров. Для мутирующих запросов (`POST/PUT/PATCH/DELETE`), пришедших **через
cookie**, дополнительно проверяет `Origin`/`Referer` — это CSRF-защита. Bearer-запросы
её не проходят и не должны: подделать `Authorization`-заголовок с чужого origin браузер
не даст, поэтому CSRF для них неактуален.
cookie**, дополнительно проверяет `Origin`/`Referer` против `CSRF_ALLOWED_ORIGINS` — это
CSRF-защита. Bearer-запросы её не проходят и не должны: подделать `Authorization`-заголовок
с чужого origin браузер не даст, поэтому CSRF для них неактуален.
- `admin_guard` — должен идти **после** `auth_guard` в цепочке слоёв (читает юзера из
extensions). Пропускает, если `tg_id` юзера совпадает с `ADMIN_TG_ID` из `.env` или его
`role == "admin"`.
+204 -23
View File
@@ -1,10 +1,11 @@
use super::service::{Claims, Session};
use crate::{api::ApiState, error::AppError};
use axum::http::header::SET_COOKIE;
use axum::http::header::{COOKIE, SET_COOKIE, USER_AGENT};
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use axum::response::{IntoResponse, Response};
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::json;
use tracing::instrument;
#[derive(Deserialize, Debug)]
@@ -23,18 +24,89 @@ pub struct SeedLoginPayload {
pub seed: String,
}
#[derive(Deserialize, Debug)]
pub struct ExchangePayload {
pub token: String,
}
pub fn router() -> Router<ApiState> {
Router::new()
.route("/telegram", post(auth_telegram_api))
.route("/seed/generate", post(generate_seed_api))
.route("/seed/login", post(login_seed_api))
.route("/exchange", post(exchange_bootstrap_api))
.route("/refresh", post(refresh_api))
.route("/logout", post(logout_api))
}
#[instrument(skip(state, payload), fields(tg_id = payload.id, username = ?payload.username))]
const ACCESS_COOKIE: &str = "nrxp_token";
const REFRESH_COOKIE: &str = "nrxp_refresh";
const REFRESH_COOKIE_PATH: &str = "/api/v1/auth/refresh";
const ACCESS_MAX_AGE_SEC: i64 = 15 * 60;
const REFRESH_MAX_AGE_SEC: i64 = 30 * 24 * 60 * 60;
fn cookie_domain_attr(state: &ApiState) -> String {
if state.cookie_domain.is_empty() {
String::new()
} else {
format!("; Domain={}", state.cookie_domain)
}
}
/// Ставит обе сессионные cookie: короткоживущий access-JWT на весь `/`, и
/// непрозрачный refresh-токен, ограниченный путём `/api/v1/auth/refresh`
/// (чтобы он не улетал на каждый обычный запрос — там он не нужен, а
/// поверхность атаки только растёт).
fn set_session_cookies(headers: &mut HeaderMap, state: &ApiState, session: &Session) {
let domain = cookie_domain_attr(state);
let access_cookie = format!(
"{}={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age={}{}",
ACCESS_COOKIE, session.access_token, ACCESS_MAX_AGE_SEC, domain
);
let refresh_cookie = format!(
"{}={}; HttpOnly; Secure; SameSite=Lax; Path={}; Max-Age={}{}",
REFRESH_COOKIE, session.refresh_token, REFRESH_COOKIE_PATH, REFRESH_MAX_AGE_SEC, domain
);
headers.append(SET_COOKIE, access_cookie.parse().unwrap());
headers.append(SET_COOKIE, refresh_cookie.parse().unwrap());
}
/// Немедленно затирает обе cookie (logout / отозванная сессия).
fn clear_session_cookies(headers: &mut HeaderMap, state: &ApiState) {
let domain = cookie_domain_attr(state);
let clear_access = format!(
"{}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0{}",
ACCESS_COOKIE, domain
);
let clear_refresh = format!(
"{}=; HttpOnly; Secure; SameSite=Lax; Path={}; Max-Age=0{}",
REFRESH_COOKIE, REFRESH_COOKIE_PATH, domain
);
headers.append(SET_COOKIE, clear_access.parse().unwrap());
headers.append(SET_COOKIE, clear_refresh.parse().unwrap());
}
fn read_cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get(COOKIE)
.and_then(|c| c.to_str().ok())
.and_then(|c| c.split("; ").find(|part| part.starts_with(name)))
.and_then(|part| part.split_once('='))
.map(|(_, v)| v)
}
fn user_agent(headers: &HeaderMap) -> Option<&str> {
headers.get(USER_AGENT).and_then(|v| v.to_str().ok())
}
#[instrument(skip(state, headers, payload), fields(tg_id = payload.id, username = ?payload.username))]
async fn auth_telegram_api(
State(state): State<ApiState>,
headers: HeaderMap,
Json(payload): Json<TgWidgetPayload>,
) -> Result<Json<Value>, AppError> {
) -> Result<impl IntoResponse, AppError> {
// 1. Проверяем подпись Telegram
state.auth_service.verify_tg_widget_auth(
&state.bot_token,
@@ -53,44 +125,153 @@ async fn auth_telegram_api(
.process_login(payload.id, payload.username.clone(), None)
.await?;
// 3. Генерируем токен. Теперь user.tg_id передается как Option автоматически.
let token = state
// 3. Выдаём полноценную сессию (access + refresh) — единая точка для всех
// способов входа, см. AuthService::issue_session.
let session = state
.auth_service
.generate_auth_token(user.id, user.tg_id, &state.jwt_secret)?;
.issue_session(user.id, user.tg_id, &state.jwt_secret, user_agent(&headers))
.await?;
let mut headers = HeaderMap::new();
let cookie = format!(
"nrxp_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=600",
token
);
headers.insert(SET_COOKIE, cookie.parse().unwrap());
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
Ok(Json(json!({ "status": "success", "token": token })))
Ok((out_headers, Json(json!({ "status": "success" }))))
}
#[axum::debug_handler]
async fn generate_seed_api(State(state): State<ApiState>) -> Result<impl IntoResponse, AppError> {
async fn generate_seed_api(
State(state): State<ApiState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
// Fail-fast для соли
let salt = std::env::var("ARGON_SALT").expect("КРИТИЧЕСКАЯ ОШИБКА: ARGON_SALT не задан!");
let (seed, token) = state
let (seed, session) = state
.auth_service
.register_seed(&salt, &state.jwt_secret)
.register_seed(&salt, &state.jwt_secret, user_agent(&headers))
.await?;
Ok(Json(
json!({ "status": "success", "seed": seed, "token": token }),
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
Ok((
out_headers,
Json(json!({ "status": "success", "seed": seed })),
))
}
async fn login_seed_api(
State(state): State<ApiState>,
headers: HeaderMap,
Json(payload): Json<SeedLoginPayload>,
) -> Result<impl IntoResponse, AppError> {
let salt = std::env::var("ARGON_SALT").expect("КРИТИЧЕСКАЯ ОШИБКА: ARGON_SALT не задан!");
let token = state
let session = state
.auth_service
.login_seed(&payload.seed, &salt, &state.jwt_secret)
.login_seed(
&payload.seed,
&salt,
&state.jwt_secret,
user_agent(&headers),
)
.await?;
Ok(Json(json!({ "status": "success", "token": token })))
let mut out_headers = HeaderMap::new();
set_session_cookies(&mut out_headers, &state, &session);
Ok((out_headers, Json(json!({ "status": "success" }))))
}
/// Обменивает короткоживущий bootstrap-токен (см. `bot.rs`, кнопки
/// «Личный Кабинет»/«Тарифы»/«Синдикат», токен приходит в `#token=` ссылки
/// WebApp) на полноценную cookie-сессию.
#[instrument(skip(state, headers, payload))]
async fn exchange_bootstrap_api(
State(state): State<ApiState>,
headers: HeaderMap,
Json(payload): Json<ExchangePayload>,
) -> Result<impl IntoResponse, AppError> {
let token_data = jsonwebtoken::decode::<Claims>(
&payload.token,
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&jsonwebtoken::Validation::default(),
)
.map_err(|_| AppError::Unauthorized("Invalid or expired bootstrap token".into()))?;
let user_id = uuid::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)?;
let session = state
.auth_service
.issue_session(user.id, user.tg_id, &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" }))))
}
/// Присоединяет заголовки (Set-Cookie и т.п.) к уже готовому ответу, не
/// перезаписывая существующие — нужно, чтобы чистить/переустанавливать cookie
/// и на успешном, и на ошибочном пути (`AppError::into_response()` иначе не
/// понесёт с собой Set-Cookie).
fn with_headers(extra: HeaderMap, resp: impl IntoResponse) -> Response {
let mut resp = resp.into_response();
resp.headers_mut().extend(extra);
resp
}
/// Тихое обновление сессии: фронт зовёт это при 401 от access-JWT вместо
/// полного релогина. Ротирует refresh-токен при каждом использовании.
#[instrument(skip(state, headers))]
async fn refresh_api(State(state): State<ApiState>, headers: HeaderMap) -> Response {
let mut out_headers = HeaderMap::new();
let Some(refresh_token) = read_cookie(&headers, REFRESH_COOKIE) else {
clear_session_cookies(&mut out_headers, &state);
return with_headers(
out_headers,
AppError::Unauthorized("No refresh token".into()),
);
};
let refresh_token = refresh_token.to_string();
match state
.auth_service
.refresh_session(&refresh_token, &state.jwt_secret, user_agent(&headers))
.await
{
Ok(session) => {
set_session_cookies(&mut out_headers, &state, &session);
with_headers(out_headers, Json(json!({ "status": "success" })))
}
Err(e) => {
// Невалидный/отозванный refresh — сразу чистим cookie, чтобы фронт не
// зациклился на бесконечных попытках обновления.
clear_session_cookies(&mut out_headers, &state);
with_headers(out_headers, e)
}
}
}
/// Разлогин: отзывает текущую refresh-сессию и чистит обе cookie.
#[instrument(skip(state, headers))]
async fn logout_api(
State(state): State<ApiState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
if let Some(refresh_token) = read_cookie(&headers, REFRESH_COOKIE) {
state.auth_service.logout(refresh_token).await?;
}
let mut out_headers = HeaderMap::new();
clear_session_cookies(&mut out_headers, &state);
Ok((out_headers, Json(json!({ "status": "success" }))))
}
+14 -2
View File
@@ -47,9 +47,21 @@ pub async fn auth_guard(
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_origin = origin
.map(|o| {
state
.csrf_allowed_origins
.iter()
.any(|allowed| allowed == o)
})
.unwrap_or(false);
let is_valid_referer = referer
.map(|r| r.starts_with("https://netrunner-vpn.com"))
.map(|r| {
state
.csrf_allowed_origins
.iter()
.any(|allowed| r.starts_with(allowed.as_str()))
})
.unwrap_or(false);
if !is_valid_origin && !is_valid_referer {
+151 -14
View File
@@ -13,6 +13,17 @@ use crate::{
error::AppError,
};
/// Access-токен живёт коротко: истечение закрывается тихим `POST /auth/refresh`
/// на фронте, а не полным релогином (раньше TTL был 10 минут вообще без
/// обновления — юзера тихо разлогинивало каждые 10 минут).
const ACCESS_TOKEN_TTL_MIN: i64 = 15;
/// Refresh-токен переживает много access-токенов; ротируется при каждом использовании.
const REFRESH_TOKEN_TTL_DAYS: i64 = 30;
/// Bootstrap-токен кладётся в URL (hash-фрагмент WebApp-ссылки бота) — живёт
/// секунды, ровно чтобы фронт успел обменять его на полноценную cookie-сессию
/// через `POST /auth/exchange`.
const BOOTSTRAP_TOKEN_TTL_SEC: i64 = 60;
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct Claims {
pub sub: String, // Внутренний UUID юзера
@@ -20,6 +31,14 @@ pub struct Claims {
pub exp: usize,
}
/// Пара токенов, выдаваемая при входе: короткий access-JWT (кладётся в
/// `Authorization`/cookie на каждый запрос) и непрозрачный refresh-токен
/// (только в отдельной cookie, только на `/auth/refresh`).
pub struct Session {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Clone)]
pub struct AuthService {
repo: Arc<dyn AppRepository>,
@@ -30,6 +49,18 @@ impl AuthService {
Self { repo }
}
fn sign_claims(claims: &Claims, secret: &str) -> Result<String, AppError> {
encode(
&Header::default(),
claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| {
error!("[Auth] JWT Generation Error: {}", e);
AppError::Internal(format!("Failed to generate JWT: {}", e))
})
}
#[instrument(skip(self, secret))]
pub fn generate_auth_token(
&self,
@@ -40,20 +71,121 @@ impl AuthService {
let claims = Claims {
sub: user_id.to_string(),
tg_id,
exp: (Utc::now() + chrono::Duration::minutes(10)).timestamp() as usize,
exp: (Utc::now() + chrono::Duration::minutes(ACCESS_TOKEN_TTL_MIN)).timestamp()
as usize,
};
Self::sign_claims(&claims, secret)
}
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| {
error!("[Auth] JWT Generation Error: {}", e);
AppError::Internal(format!("Failed to generate JWT: {}", e))
/// Короткоживущий одноразовый токен для WebApp-ссылок из бота (см.
/// `bot.rs::handle_callbacks`) — фронт немедленно обменивает его на
/// полноценную cookie-сессию через `exchange_bootstrap_token`.
#[instrument(skip(self, secret))]
pub fn generate_bootstrap_token(
&self,
user_id: Uuid,
tg_id: Option<i64>,
secret: &str,
) -> Result<String, AppError> {
let claims = Claims {
sub: user_id.to_string(),
tg_id,
exp: (Utc::now() + chrono::Duration::seconds(BOOTSTRAP_TOKEN_TTL_SEC)).timestamp()
as usize,
};
Self::sign_claims(&claims, secret)
}
fn hash_refresh_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn generate_opaque_token() -> String {
let bytes: [u8; 32] = rand::thread_rng().gen();
hex::encode(bytes)
}
/// Единая точка выдачи сессии для всех способов входа (Telegram/Ghost seed/
/// bootstrap-обмен) — гарантирует, что каждый логин ставит одинаковую пару
/// access+refresh, а не только часть из них (раньше Ghost Protocol вообще не
/// ставил cookie, а Telegram-логин — только access, без refresh).
#[instrument(skip(self, secret))]
pub async fn issue_session(
&self,
user_id: Uuid,
tg_id: Option<i64>,
secret: &str,
user_agent: Option<&str>,
) -> Result<Session, AppError> {
let access_token = self.generate_auth_token(user_id, tg_id, secret)?;
let refresh_token = Self::generate_opaque_token();
let token_hash = Self::hash_refresh_token(&refresh_token);
let expires_at = Utc::now() + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS);
self.repo
.create_refresh_session(user_id, &token_hash, user_agent, expires_at)
.await?;
Ok(Session {
access_token,
refresh_token,
})
}
/// Ротация refresh-токена: старый немедленно отзывается, выдаётся новая пара.
/// Предъявление уже отозванного токена — сигнал, что его украли и
/// переиспользуют после легитимной ротации: в этом случае отзываем **все**
/// сессии юзера, а не только эту.
#[instrument(skip(self, refresh_token, secret))]
pub async fn refresh_session(
&self,
refresh_token: &str,
secret: &str,
user_agent: Option<&str>,
) -> Result<Session, AppError> {
let token_hash = Self::hash_refresh_token(refresh_token);
let session = self
.repo
.get_refresh_session_by_hash(&token_hash)
.await?
.ok_or_else(|| AppError::Unauthorized("Invalid refresh token".into()))?;
if session.revoked_at.is_some() {
error!(
user_id = %session.user_id,
"[Auth] SECURITY_ALERT: переиспользование отозванного refresh-токена — вероятная компрометация, отзываю все сессии"
);
self.repo
.revoke_all_refresh_sessions(session.user_id)
.await?;
return Err(AppError::Unauthorized("Session revoked".into()));
}
if session.expires_at < Utc::now() {
return Err(AppError::Unauthorized("Refresh token expired".into()));
}
self.repo.revoke_refresh_session(&token_hash).await?;
let user = self
.repo
.get_user_by_id(session.user_id)
.await?
.ok_or(AppError::UserNotFound)?;
self.issue_session(user.id, user.tg_id, secret, user_agent)
.await
}
#[instrument(skip(self, refresh_token))]
pub async fn logout(&self, refresh_token: &str) -> Result<(), AppError> {
let token_hash = Self::hash_refresh_token(refresh_token);
self.repo.revoke_refresh_session(&token_hash).await?;
Ok(())
}
fn hash_seed_argon2(seed: &str, salt: &str) -> Result<String, AppError> {
let mut hash_output = [0u8; 32];
Argon2::default()
@@ -67,7 +199,8 @@ impl AuthService {
&self,
salt: &str,
jwt_secret: &str,
) -> Result<(String, String), AppError> {
user_agent: Option<&str>,
) -> Result<(String, Session), AppError> {
// Генерация криптостойкого 16-символьного Seed (Alphanumeric)
let seed: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
@@ -80,9 +213,11 @@ impl AuthService {
let hash = Self::hash_seed_argon2(&seed, salt)?;
let user = self.repo.create_seed_user(&hash).await?;
let token = self.generate_auth_token(user.id, user.tg_id, jwt_secret)?;
let session = self
.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await?;
Ok((seed, token))
Ok((seed, session))
}
/// Ghost Protocol: Вход по существующему Seed
@@ -91,7 +226,8 @@ impl AuthService {
seed: &str,
salt: &str,
jwt_secret: &str,
) -> Result<String, AppError> {
user_agent: Option<&str>,
) -> Result<Session, AppError> {
let hash = Self::hash_seed_argon2(seed, salt)?;
let user = self
@@ -100,7 +236,8 @@ impl AuthService {
.await?
.ok_or_else(|| AppError::Unauthorized("Invalid credentials".into()))?;
self.generate_auth_token(user.id, user.tg_id, jwt_secret)
self.issue_session(user.id, user.tg_id, jwt_secret, user_agent)
.await
}
// --- Логика Telegram (без изменений, кроме вызова генерации токена) ---