backend sliced by modules
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{api::ApiState, error::AppError};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct TgWidgetPayload {
|
||||
pub id: i64,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub photo_url: Option<String>,
|
||||
pub auth_date: i64,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<ApiState> {
|
||||
Router::new().route("/telegram", post(auth_telegram_api))
|
||||
}
|
||||
|
||||
#[instrument(skip(state, payload), fields(tg_id = payload.id, username = ?payload.username))]
|
||||
async fn auth_telegram_api(
|
||||
State(state): State<ApiState>,
|
||||
Json(payload): Json<TgWidgetPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
state.auth_service.verify_tg_widget_auth(
|
||||
&state.bot_token,
|
||||
payload.id,
|
||||
payload.first_name.as_deref(),
|
||||
payload.last_name.as_deref(),
|
||||
payload.username.as_deref(),
|
||||
payload.photo_url.as_deref(),
|
||||
payload.auth_date,
|
||||
&payload.hash,
|
||||
)?;
|
||||
|
||||
let user = state
|
||||
.auth_service
|
||||
.process_login(payload.id, payload.username.clone(), None)
|
||||
.await?;
|
||||
let token = state
|
||||
.auth_service
|
||||
.generate_auth_token(user.id, user.tg_id, &state.jwt_secret)?;
|
||||
|
||||
Ok(Json(json!({ "status": "success", "token": token })))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::{api::ApiState, db::models::User, error::AppError};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
/// Проверяет JWT и инжектит User в запрос.
|
||||
/// Аналог AuthGuard в NestJS.
|
||||
pub async fn auth_guard(
|
||||
State(state): State<ApiState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.filter(|h| h.starts_with("Bearer "))
|
||||
.map(|h| &h[7..])
|
||||
.ok_or_else(|| AppError::Unauthorized("Access token required".into()))?;
|
||||
|
||||
let token_data = jsonwebtoken::decode::<super::service::Claims>(
|
||||
auth_header,
|
||||
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||
&jsonwebtoken::Validation::default(),
|
||||
)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid session signature".into()))?;
|
||||
|
||||
let user = state
|
||||
.repo
|
||||
.get_user_by_tg_id(token_data.claims.tg_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Operative not found in grid".into()))?;
|
||||
|
||||
req.extensions_mut().insert(user);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Проверяет права администратора.
|
||||
/// Должен идти строго после auth_guard.
|
||||
pub async fn admin_guard(req: Request, next: Next) -> Result<Response, AppError> {
|
||||
let user = req
|
||||
.extensions()
|
||||
.get::<User>()
|
||||
.ok_or_else(|| AppError::Unauthorized("Context lost: Auth required".into()))?;
|
||||
|
||||
if user.role != "admin" {
|
||||
warn!(
|
||||
"SECURITY_ALERT: Unauthorized admin access attempt by @{:?}",
|
||||
user.tg_username
|
||||
);
|
||||
return Err(AppError::Unauthorized(
|
||||
"Insufficient clearance level".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod controller;
|
||||
pub mod guard;
|
||||
pub mod service;
|
||||
|
||||
pub use guard::{admin_guard, auth_guard};
|
||||
pub use service::{AuthService, Claims};
|
||||
@@ -0,0 +1,135 @@
|
||||
use chrono::Utc;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::{models::User, repository::AppRepository},
|
||||
error::AppError,
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub tg_id: i64,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
repo: Arc<dyn AppRepository>,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
#[instrument(skip(self, secret))]
|
||||
pub fn generate_auth_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
tg_id: i64,
|
||||
secret: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
tg_id,
|
||||
exp: (Utc::now() + chrono::Duration::hours(24)).timestamp() as usize,
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("[Auth] Failed to generate JWT: {}", e);
|
||||
AppError::Internal
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, bot_token, hash))]
|
||||
pub fn verify_tg_widget_auth(
|
||||
&self,
|
||||
bot_token: &str,
|
||||
id: i64,
|
||||
first_name: Option<&str>,
|
||||
last_name: Option<&str>,
|
||||
username: Option<&str>,
|
||||
photo_url: Option<&str>,
|
||||
auth_date: i64,
|
||||
hash: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let mut data_check_arr = vec![format!("auth_date={}", auth_date), format!("id={}", id)];
|
||||
if let Some(fn_) = first_name {
|
||||
data_check_arr.push(format!("first_name={}", fn_));
|
||||
}
|
||||
if let Some(ln) = last_name {
|
||||
data_check_arr.push(format!("last_name={}", ln));
|
||||
}
|
||||
if let Some(pu) = photo_url {
|
||||
data_check_arr.push(format!("photo_url={}", pu));
|
||||
}
|
||||
if let Some(un) = username {
|
||||
data_check_arr.push(format!("username={}", un));
|
||||
}
|
||||
|
||||
data_check_arr.sort();
|
||||
let data_check_string = data_check_arr.join("\n");
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bot_token.trim().as_bytes());
|
||||
let secret_key = hasher.finalize();
|
||||
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(&secret_key).map_err(|_| AppError::Internal)?;
|
||||
mac.update(data_check_string.as_bytes());
|
||||
|
||||
if hex::encode(mac.finalize().into_bytes()) != hash {
|
||||
return Err(AppError::Unauthorized("Invalid Telegram hash".into()));
|
||||
}
|
||||
if Utc::now().timestamp() - auth_date > 86400 {
|
||||
return Err(AppError::Unauthorized("Auth date expired".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn process_login(
|
||||
&self,
|
||||
tg_id: i64,
|
||||
username: Option<String>,
|
||||
ref_code: Option<String>,
|
||||
) -> Result<User, AppError> {
|
||||
let is_new = self.repo.get_user_by_tg_id(tg_id).await?.is_none();
|
||||
let user = self.repo.upsert_user(tg_id, username).await?;
|
||||
|
||||
if is_new {
|
||||
if let Some(code) = ref_code {
|
||||
if let Ok(referrer_tg) = code.parse::<i64>() {
|
||||
if referrer_tg != tg_id {
|
||||
let _ = self.handle_referral(referrer_tg, user.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn handle_referral(&self, referrer_tg: i64, new_user_id: Uuid) -> Result<(), AppError> {
|
||||
if let Some(referrer) = self.repo.get_user_by_tg_id(referrer_tg).await? {
|
||||
info!(
|
||||
"[Auth] Creating syndicate link: {} -> {}",
|
||||
referrer.id, new_user_id
|
||||
);
|
||||
self.repo
|
||||
.create_referral(referrer.id, new_user_id)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user