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:
@@ -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
@@ -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" }))))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 (без изменений, кроме вызова генерации токена) ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user