init
This commit is contained in:
1
ticktalk/crates/backend/.gitignore
vendored
Executable file
1
ticktalk/crates/backend/.gitignore
vendored
Executable file
@@ -0,0 +1 @@
|
||||
/target
|
||||
7
ticktalk/crates/backend/Cargo.lock
generated
Executable file
7
ticktalk/crates/backend/Cargo.lock
generated
Executable file
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ticktalk-backend"
|
||||
version = "0.1.0"
|
||||
24
ticktalk/crates/backend/Cargo.toml
Executable file
24
ticktalk/crates/backend/Cargo.toml
Executable file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "ticktalk-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.12.1"
|
||||
tracing = "0.1.43"
|
||||
tracing-actix-web = "0.7.19"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ticktalk-db = { path = "../db" }
|
||||
ticktalk-types = { path = "../types" }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1.0.145"
|
||||
actix = "0.13.5"
|
||||
tokio = { version = "1.48.0", features = ["process"] }
|
||||
dashmap = "6.1.0"
|
||||
actix-web-actors = "4.3.1"
|
||||
base64 = "0.22"
|
||||
actix-cors = "0.7.1"
|
||||
futures-util = "0.3"
|
||||
72
ticktalk/crates/backend/src/error.rs
Executable file
72
ticktalk/crates/backend/src/error.rs
Executable file
@@ -0,0 +1,72 @@
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::error::ResponseError;
|
||||
use serde_json;
|
||||
use thiserror::Error;
|
||||
use ticktalk_db::errors::RepositoryError;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WebError {
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] RepositoryError),
|
||||
#[error("Invalid UUID: {0}")]
|
||||
InvalidUuid(#[from] uuid::Error),
|
||||
#[error("Not found")]
|
||||
NotFound,
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Bad request")]
|
||||
BadRequest(String),
|
||||
#[error("Internal server error")]
|
||||
InternalServerError(String),
|
||||
}
|
||||
|
||||
impl ResponseError for WebError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match self {
|
||||
WebError::DatabaseError(_) => {
|
||||
error!("Database error");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Database error occurred",
|
||||
"message": "An error occurred while accessing the database"
|
||||
}))
|
||||
}
|
||||
WebError::InvalidUuid(e) => {
|
||||
error!("Invalid UUID error: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Invalid UUID",
|
||||
"message": "An error occurred while parsing the UUID"
|
||||
}))
|
||||
}
|
||||
WebError::NotFound => {
|
||||
error!("Resource not found");
|
||||
HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "Not found",
|
||||
"message": "The requested resource was not found"
|
||||
}))
|
||||
}
|
||||
WebError::Unauthorized => {
|
||||
warn!("Unauthorized access attempt");
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({
|
||||
"error": "Unauthorized",
|
||||
"message": "Access denied"
|
||||
}))
|
||||
}
|
||||
WebError::InternalServerError(e) => {
|
||||
error!("Internal server error: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Internal server error",
|
||||
"message": "An unexpected error occurred"
|
||||
}))
|
||||
}
|
||||
|
||||
WebError::BadRequest(msg) => {
|
||||
error!("Bad Request: {}", msg);
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Bad Request",
|
||||
"message": msg
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
ticktalk/crates/backend/src/handlers/http.rs
Executable file
191
ticktalk/crates/backend/src/handlers/http.rs
Executable file
@@ -0,0 +1,191 @@
|
||||
use crate::error::WebError;
|
||||
use crate::services::*;
|
||||
use crate::session::{SessionLayer, SessionClaims, SessionManager};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use serde::Deserialize;
|
||||
use ticktalk_types::{LoginRequest, *};
|
||||
use tracing::{debug, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_chat(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
chat_id = %path,
|
||||
"get_chat request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
let chat = service.get_chat(*path).await?;
|
||||
if chat.first_user_id != user_id && chat.second_user_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
Ok(HttpResponse::Ok().json(chat))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
req: HttpRequest,
|
||||
session_manager: web::Data<SessionManager>,
|
||||
service: web::Data<AuthService>,
|
||||
data: web::Json<LoginRequest>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
"login attempt"
|
||||
);
|
||||
|
||||
let login_payload = data.into_inner();
|
||||
info!(target: "auth", "Login attempt for ticket len={}", login_payload.ticket.len());
|
||||
let user = service.login(login_payload).await?;
|
||||
info!(target: "auth", user_id = %user.id, username = %user.username, "Kerberos login successful");
|
||||
let session_id = session_manager
|
||||
.create_session(user.id, user.username.clone());
|
||||
debug!(target: "auth", session_id = %session_id, "Session created for Kerberos user");
|
||||
let response = LoginResponse {
|
||||
session_id,
|
||||
user,
|
||||
};
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
|
||||
pub async fn create_chat(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
data: web::Json<CreateChatRequest>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
"create_chat request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
if data.first_user_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let chat = service.create_chat(data.into_inner()).await?;
|
||||
Ok(HttpResponse::Ok().json(chat))
|
||||
}
|
||||
|
||||
pub async fn get_user_chats(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
user_id = %path,
|
||||
"get_user_chats request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
if user_id != *path {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let chats = service.get_user_chats(user_id).await?;
|
||||
Ok(HttpResponse::Ok().json(chats))
|
||||
}
|
||||
|
||||
pub async fn get_chat_messages(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
chat_id = %path,
|
||||
"get_chat_messages request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
let chat = service.get_chat(*path).await?;
|
||||
if chat.first_user_id != user_id && chat.second_user_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let messages = service.get_chat_messages(chat.id).await?;
|
||||
Ok(HttpResponse::Ok().json(messages))
|
||||
}
|
||||
|
||||
pub async fn create_message(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
data: web::Json<CreateMessageRequest>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
"create_message request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
let payload = data.into_inner();
|
||||
if payload.sender_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let chat = service.get_chat(payload.chat_id).await?;
|
||||
if chat.first_user_id != user_id && chat.second_user_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let message = service.create_message(payload).await?;
|
||||
Ok(HttpResponse::Ok().json(message))
|
||||
}
|
||||
|
||||
pub async fn get_user_data(
|
||||
req: HttpRequest,
|
||||
service: web::Data<UserService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
let _requester = require_user(&req)?;
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
user_id = %path,
|
||||
"get_user_data request"
|
||||
);
|
||||
let user = service.get_user(*path).await?;
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UsernameQuery {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(
|
||||
req: HttpRequest,
|
||||
service: web::Data<UserService>,
|
||||
query: web::Query<UsernameQuery>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
let _requester = require_user(&req)?;
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
username = %query.username,
|
||||
"get_user_by_username request"
|
||||
);
|
||||
let user = service.get_user_by_username(&query.username).await?;
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
}
|
||||
|
||||
fn require_user(req: &HttpRequest) -> Result<Uuid, WebError> {
|
||||
let claims = require_session(req)?;
|
||||
debug!(target: "auth", user_id = %claims.user_id, session_id = %claims.session_id, "Session user retrieved");
|
||||
Ok(claims.user_id)
|
||||
}
|
||||
|
||||
fn require_session(req: &HttpRequest) -> Result<SessionClaims, WebError> {
|
||||
SessionLayer::claims(req).ok_or_else(|| {
|
||||
debug!(target: "auth", "Missing or invalid session header");
|
||||
WebError::Unauthorized
|
||||
})
|
||||
}
|
||||
1
ticktalk/crates/backend/src/handlers/mod.rs
Executable file
1
ticktalk/crates/backend/src/handlers/mod.rs
Executable file
@@ -0,0 +1 @@
|
||||
pub mod http;
|
||||
150
ticktalk/crates/backend/src/handlers/websocket.rs
Executable file
150
ticktalk/crates/backend/src/handlers/websocket.rs
Executable file
@@ -0,0 +1,150 @@
|
||||
use actix::AsyncContext;
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use actix_web_actors::ws;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Простой менеджер: user_id -> адрес WS соединения
|
||||
#[derive(Clone)]
|
||||
pub struct WsManager {
|
||||
connections: Arc<Mutex<HashMap<Uuid, actix::Addr<WsActor>>>>,
|
||||
}
|
||||
|
||||
impl WsManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&self, user_id: Uuid, addr: actix::Addr<WsActor>) {
|
||||
self.connections.lock().unwrap().insert(user_id, addr);
|
||||
}
|
||||
|
||||
pub fn unregister(&self, user_id: &Uuid) {
|
||||
self.connections.lock().unwrap().remove(user_id);
|
||||
}
|
||||
|
||||
pub fn send_to_user(&self, user_id: Uuid, message: String) -> bool {
|
||||
if let Some(addr) = self.connections.lock().unwrap().get(&user_id) {
|
||||
addr.do_send(SendToClient(message));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сообщение для отправки клиенту
|
||||
struct SendToClient(pub String);
|
||||
|
||||
impl actix::Message for SendToClient {
|
||||
type Result = ();
|
||||
}
|
||||
|
||||
// WebSocket актор
|
||||
pub struct WsActor {
|
||||
user_id: Option<Uuid>,
|
||||
manager: WsManager,
|
||||
}
|
||||
|
||||
impl actix::Actor for WsActor {
|
||||
type Context = ws::WebsocketContext<Self>;
|
||||
|
||||
fn started(&mut self, _: &mut Self::Context) {
|
||||
info!("WebSocket started");
|
||||
}
|
||||
|
||||
fn stopped(&mut self, _ctx: &mut Self::Context) {
|
||||
if let Some(user_id) = self.user_id {
|
||||
self.manager.unregister(&user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчик входящих сообщений
|
||||
impl actix::StreamHandler<Result<ws::Message, ws::ProtocolError>> for WsActor {
|
||||
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
|
||||
match msg {
|
||||
Ok(ws::Message::Text(text)) => {
|
||||
self.handle_message(&text, ctx);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчик SendToClient сообщений
|
||||
impl actix::Handler<SendToClient> for WsActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: SendToClient, ctx: &mut Self::Context) {
|
||||
ctx.text(msg.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl WsActor {
|
||||
fn handle_message(&mut self, text: &str, ctx: &mut ws::WebsocketContext<Self>) {
|
||||
// Парсим JSON
|
||||
if let Ok(data) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
match data.get("type").and_then(|t| t.as_str()) {
|
||||
Some("auth") => {
|
||||
// Аутентификация
|
||||
if let Some(user_id_str) = data.get("user_id").and_then(|u| u.as_str()) {
|
||||
if let Ok(user_id) = Uuid::parse_str(user_id_str) {
|
||||
self.user_id = Some(user_id);
|
||||
self.manager.register(user_id, ctx.address());
|
||||
|
||||
ctx.text(r#"{"type": "auth_success"}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("message") => {
|
||||
if let (Some(to_user_str), Some(content), Some(chat_id_str)) = (
|
||||
data.get("to_user").and_then(|t| t.as_str()),
|
||||
data.get("content").and_then(|c| c.as_str()),
|
||||
data.get("chat_id").and_then(|c| c.as_str()),
|
||||
) {
|
||||
if let (Some(from_user), Ok(to_user), Ok(chat_id)) = (
|
||||
self.user_id,
|
||||
Uuid::parse_str(to_user_str),
|
||||
Uuid::parse_str(chat_id_str),
|
||||
) {
|
||||
// 1. Сохраняем в БД
|
||||
// message_repo.create(...).await;
|
||||
|
||||
// 2. Отправляем получателю
|
||||
let message_json = serde_json::json!({
|
||||
"type": "new_message",
|
||||
"from": from_user,
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"timestamp": chrono::Utc::now().timestamp()
|
||||
});
|
||||
|
||||
self.manager.send_to_user(to_user, message_json.to_string());
|
||||
|
||||
ctx.text(r#"{"type": "message_sent"}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ws_handler(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
manager: web::Data<WsManager>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let actor = WsActor {
|
||||
user_id: None,
|
||||
manager: manager.get_ref().clone(),
|
||||
};
|
||||
|
||||
ws::start(actor, &req, stream)
|
||||
}
|
||||
71
ticktalk/crates/backend/src/main.rs
Executable file
71
ticktalk/crates/backend/src/main.rs
Executable file
@@ -0,0 +1,71 @@
|
||||
use crate::services::*;
|
||||
use crate::session::{SessionLayer, SessionManager};
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use ticktalk_db::create_db_pool;
|
||||
use ticktalk_db::repositories::{ChatRepository, MessageRepository, UserRepository};
|
||||
use tracing::{error, info};
|
||||
use tracing_actix_web::TracingLogger;
|
||||
use tracing_subscriber::filter::EnvFilter;
|
||||
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod routes;
|
||||
mod services;
|
||||
mod session;
|
||||
mod types;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
if tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.try_init()
|
||||
.is_err()
|
||||
{
|
||||
error!("Tracing subscriber already initialised");
|
||||
}
|
||||
info!("Starting TickTalk backend");
|
||||
|
||||
let db_pool = create_db_pool().await;
|
||||
|
||||
let user_repo = UserRepository::new(db_pool.clone());
|
||||
let chat_repo = ChatRepository::new(db_pool.clone());
|
||||
let message_repo = MessageRepository::new(db_pool.clone());
|
||||
|
||||
let chat_service = ChatService::new(chat_repo, message_repo);
|
||||
let user_service = UserService::new(user_repo.clone());
|
||||
let auth_service = AuthService::new(user_repo);
|
||||
let session_manager = SessionManager::new();
|
||||
|
||||
HttpServer::new(move || {
|
||||
let session_manager = session_manager.clone();
|
||||
let cors = Cors::default()
|
||||
.allowed_origin_fn(|origin, _| {
|
||||
origin
|
||||
.to_str()
|
||||
.map(|value| {
|
||||
value.starts_with("tauri://")
|
||||
|| value.starts_with("http://")
|
||||
|| value.starts_with("https://")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.allow_any_method()
|
||||
.allow_any_header()
|
||||
.supports_credentials();
|
||||
App::new()
|
||||
.app_data(web::Data::new(session_manager.clone()))
|
||||
.wrap(SessionLayer::new(session_manager))
|
||||
.wrap(cors)
|
||||
.wrap(TracingLogger::default())
|
||||
.app_data(web::Data::new(chat_service.clone()))
|
||||
.app_data(web::Data::new(auth_service.clone()))
|
||||
.app_data(web::Data::new(user_service.clone()))
|
||||
.configure(routes::configure_routes)
|
||||
})
|
||||
.bind("0.0.0.0:2228")?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
16
ticktalk/crates/backend/src/routes.rs
Executable file
16
ticktalk/crates/backend/src/routes.rs
Executable file
@@ -0,0 +1,16 @@
|
||||
use crate::handlers::http::*;
|
||||
use actix_web::web;
|
||||
|
||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/api")
|
||||
.route("/chat", web::post().to(create_chat))
|
||||
.route("/chat/user/{user_id}", web::get().to(get_user_chats))
|
||||
.route("/chat/{chat_id}", web::get().to(get_chat))
|
||||
.route("/chat/{chat_id}/message", web::get().to(get_chat_messages))
|
||||
.route("/chat/{chat_id}/message", web::post().to(create_message))
|
||||
.route("/user/by-username", web::get().to(get_user_by_username))
|
||||
.route("/user/{user_id}", web::get().to(get_user_data))
|
||||
.route("/auth/login", web::post().to(login)),
|
||||
);
|
||||
}
|
||||
211
ticktalk/crates/backend/src/services/auth.rs
Executable file
211
ticktalk/crates/backend/src/services/auth.rs
Executable file
@@ -0,0 +1,211 @@
|
||||
use crate::error::WebError;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use std::convert::TryInto;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use ticktalk_db::{models::NewUser, repositories::UserRepository};
|
||||
use ticktalk_types::{LoginRequest, UserResponse};
|
||||
use tokio::process::Command;
|
||||
use tracing::debug;
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
user_repo: UserRepository,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(user_repo: UserRepository) -> Self {
|
||||
Self { user_repo }
|
||||
}
|
||||
|
||||
pub async fn login(&self, creds: LoginRequest) -> Result<UserResponse, WebError> {
|
||||
let ticket_bytes = STANDARD
|
||||
.decode(creds.ticket.as_bytes())
|
||||
.map_err(|_| WebError::BadRequest("Invalid ticket payload".into()))?;
|
||||
|
||||
let parsed: ParsedTicket = ParsedTicket::from_bytes(&ticket_bytes)?;
|
||||
let username = normalize_username(&parsed.client_principal);
|
||||
|
||||
let ticket_cache_path = format!("/tmp/user_{}.cache", username);
|
||||
let cache_exists = Path::new(&ticket_cache_path).exists();
|
||||
if !cache_exists {
|
||||
debug!(
|
||||
target: "auth::ticket",
|
||||
"No cache for {}, will persist received ticket",
|
||||
username
|
||||
);
|
||||
fs::write(&ticket_cache_path, &ticket_bytes)
|
||||
.map_err(|e| WebError::InternalServerError(format!("Failed to persist ticket cache: {e}")))?;
|
||||
|
||||
}
|
||||
|
||||
Self::login_via_kerberos(
|
||||
&ticket_cache_path,
|
||||
&parsed.client_principal,
|
||||
&normalize_realm(&parsed.service_principal),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = self
|
||||
.user_repo
|
||||
.find_by_username(username.clone())
|
||||
.await?
|
||||
{
|
||||
return Ok(UserResponse {
|
||||
id: user.id,
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
let created = self
|
||||
.user_repo
|
||||
.create(NewUser {
|
||||
username,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(UserResponse {
|
||||
id: created.id,
|
||||
username: created.username,
|
||||
})
|
||||
}
|
||||
|
||||
async fn login_via_kerberos(tgs_path: &str, principal: &str, realm: &str) -> Result<(), WebError> {
|
||||
let service_principal = format!("service@{}", realm);
|
||||
|
||||
debug!(
|
||||
target: "auth::kerberos",
|
||||
"Invoking kauth --cache {tgs_path} {principal} {service_principal} kdc"
|
||||
);
|
||||
|
||||
let output = Command::new("kauth")
|
||||
.arg("--cache")
|
||||
.arg(tgs_path)
|
||||
.arg(principal)
|
||||
.arg(&service_principal)
|
||||
.arg("kdc")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| WebError::InternalServerError(format!("Kerberos exec failed: {e}")))?;
|
||||
|
||||
debug!(
|
||||
target: "auth::kerberos",
|
||||
status = %output.status,
|
||||
"Kerberos helper finished"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if stdout.contains("OK") {
|
||||
return Ok(());
|
||||
} else if stdout.contains("FAIL") {
|
||||
return Err(WebError::Unauthorized)
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(WebError::InternalServerError(format!(
|
||||
"Kerberos rejected {} via {}: {}",
|
||||
principal, service_principal, stderr.trim()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_username(principal: &str) -> String {
|
||||
principal.split('@').next().unwrap_or(principal).to_string()
|
||||
}
|
||||
|
||||
fn normalize_realm(principal: &str) -> String {
|
||||
principal
|
||||
.split('@')
|
||||
.nth(1)
|
||||
.map(|realm| realm.trim().to_ascii_uppercase())
|
||||
.filter(|realm| !realm.is_empty())
|
||||
.unwrap_or_else(|| "TICKTALK.LOCAL".to_string())
|
||||
}
|
||||
fn ticket_cache_dir() -> PathBuf {
|
||||
PathBuf::from("/tmp/ticktalk-cache")
|
||||
}
|
||||
|
||||
fn ticket_cache_path(username: &str) -> PathBuf {
|
||||
ticket_cache_dir().join(format!("user_{}.cache", username))
|
||||
}
|
||||
|
||||
struct ParsedTicket {
|
||||
ticket_id: String,
|
||||
client_principal: String,
|
||||
service_principal: String,
|
||||
realm: String,
|
||||
expires_at: u64,
|
||||
forwardable: bool,
|
||||
is_tgt: bool,
|
||||
ticket_blob: Vec<u8>,
|
||||
client_key: String,
|
||||
}
|
||||
|
||||
impl ParsedTicket {
|
||||
fn from_bytes(data: &[u8]) -> Result<Self, WebError> {
|
||||
let mut offset = 0usize;
|
||||
|
||||
fn ensure(data: &[u8], offset: usize, needed: usize) -> Result<(), WebError> {
|
||||
if offset + needed > data.len() {
|
||||
return Err(WebError::BadRequest("Ticket blob truncated".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: &mut usize) -> Result<u32, WebError> {
|
||||
ensure(data, *offset, 4)?;
|
||||
let value = u32::from_be_bytes(data[*offset..*offset + 4].try_into().unwrap());
|
||||
*offset += 4;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn read_u64(data: &[u8], offset: &mut usize) -> Result<u64, WebError> {
|
||||
ensure(data, *offset, 8)?;
|
||||
let value = u64::from_be_bytes(data[*offset..*offset + 8].try_into().unwrap());
|
||||
*offset += 8;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn read_string(data: &[u8], offset: &mut usize) -> Result<String, WebError> {
|
||||
let len = read_u32(data, offset)? as usize;
|
||||
ensure(data, *offset, len)?;
|
||||
let slice = &data[*offset..*offset + len];
|
||||
*offset += len;
|
||||
String::from_utf8(slice.to_vec())
|
||||
.map_err(|_| WebError::BadRequest("Ticket string is not UTF-8".into()))
|
||||
}
|
||||
|
||||
let ticket_id = read_string(data, &mut offset)?;
|
||||
let service_principal = read_string(data, &mut offset)?;
|
||||
let client_principal = read_string(data, &mut offset)?;
|
||||
let realm = read_string(data, &mut offset)?;
|
||||
let expires_at = read_u64(data, &mut offset)?;
|
||||
|
||||
ensure(data, offset, 2)?;
|
||||
let forwardable = data[offset] != 0;
|
||||
offset += 1;
|
||||
let is_tgt = data[offset] != 0;
|
||||
offset += 1;
|
||||
|
||||
let ticket_blob_len = read_u32(data, &mut offset)? as usize;
|
||||
ensure(data, offset, ticket_blob_len)?;
|
||||
let ticket_blob = data[offset..offset + ticket_blob_len].to_vec();
|
||||
offset += ticket_blob_len;
|
||||
|
||||
let client_key = read_string(data, &mut offset)?;
|
||||
|
||||
Ok(Self {
|
||||
ticket_id,
|
||||
client_principal,
|
||||
service_principal,
|
||||
realm,
|
||||
expires_at,
|
||||
forwardable,
|
||||
is_tgt,
|
||||
ticket_blob,
|
||||
client_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
91
ticktalk/crates/backend/src/services/chat.rs
Executable file
91
ticktalk/crates/backend/src/services/chat.rs
Executable file
@@ -0,0 +1,91 @@
|
||||
use crate::error::WebError;
|
||||
use ticktalk_db::models::{Chat, Message, NewChat, NewMessage};
|
||||
use ticktalk_db::repositories::{ChatRepository, MessageRepository};
|
||||
use ticktalk_types::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChatService {
|
||||
chat_repo: ChatRepository,
|
||||
message_repo: MessageRepository,
|
||||
}
|
||||
|
||||
impl ChatService {
|
||||
pub fn new(chat_repo: ChatRepository, message_repo: MessageRepository) -> Self {
|
||||
Self {
|
||||
chat_repo,
|
||||
message_repo,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_chat(&self, chat_id: Uuid) -> Result<ChatResponse, WebError> {
|
||||
match self.chat_repo.find_by_id(chat_id).await {
|
||||
Ok(Some(chat)) => Ok(ChatResponse {
|
||||
id: chat.id,
|
||||
first_user_id: chat.first_user_id,
|
||||
second_user_id: chat.second_user_id,
|
||||
}),
|
||||
Ok(None) => Err(WebError::NotFound),
|
||||
Err(err) => Err(WebError::DatabaseError(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_chat(&self, chat_req: CreateChatRequest) -> Result<ChatResponse, WebError> {
|
||||
let new_chat = NewChat {
|
||||
first_user_id: chat_req.first_user_id,
|
||||
second_user_id: chat_req.second_user_id,
|
||||
};
|
||||
|
||||
let chat = self.chat_repo.create(new_chat).await?;
|
||||
Ok(chat_into_response(chat))
|
||||
}
|
||||
|
||||
pub async fn get_chat_messages(&self, chat_id: Uuid) -> Result<Vec<MessageResponse>, WebError> {
|
||||
let messages = self
|
||||
.message_repo
|
||||
.find_by_chat_id(chat_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(message_into_response)
|
||||
.collect::<Vec<MessageResponse>>();
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub async fn create_message(
|
||||
&self,
|
||||
message_req: CreateMessageRequest,
|
||||
) -> Result<MessageResponse, WebError> {
|
||||
let new_message = NewMessage {
|
||||
chat_id: message_req.chat_id,
|
||||
sender_id: message_req.sender_id,
|
||||
recipient_id: message_req.recipient_id,
|
||||
content: message_req.content,
|
||||
};
|
||||
|
||||
let message = self.message_repo.create(new_message).await?;
|
||||
Ok(message_into_response(message))
|
||||
}
|
||||
|
||||
pub async fn get_user_chats(&self, user_id: Uuid) -> Result<Vec<ChatResponse>, WebError> {
|
||||
let chats = self.chat_repo.find_by_user(user_id).await?;
|
||||
Ok(chats.into_iter().map(chat_into_response).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_into_response(chat: Chat) -> ChatResponse {
|
||||
ChatResponse {
|
||||
id: chat.id,
|
||||
first_user_id: chat.first_user_id,
|
||||
second_user_id: chat.second_user_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn message_into_response(message: Message) -> MessageResponse {
|
||||
MessageResponse {
|
||||
sender_id: message.sender_id,
|
||||
recipient_id: message.recipient_id,
|
||||
content: message.content,
|
||||
created_at: message.created_at,
|
||||
}
|
||||
}
|
||||
7
ticktalk/crates/backend/src/services/mod.rs
Executable file
7
ticktalk/crates/backend/src/services/mod.rs
Executable file
@@ -0,0 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod chat;
|
||||
pub mod user;
|
||||
|
||||
pub use self::auth::*;
|
||||
pub use self::chat::*;
|
||||
pub use self::user::*;
|
||||
38
ticktalk/crates/backend/src/services/user.rs
Executable file
38
ticktalk/crates/backend/src/services/user.rs
Executable file
@@ -0,0 +1,38 @@
|
||||
use crate::error::WebError;
|
||||
use ticktalk_db::{repositories::UserRepository};
|
||||
use ticktalk_types::UserResponse;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserService {
|
||||
user_repo: UserRepository,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub fn new(user_repo: UserRepository) -> Self {
|
||||
Self { user_repo }
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, id: Uuid) -> Result<UserResponse, WebError> {
|
||||
match self.user_repo.find_by_id(id).await? {
|
||||
Some(user) => Ok(UserResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}),
|
||||
None => Err(WebError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(
|
||||
&self,
|
||||
username_query: &str,
|
||||
) -> Result<UserResponse, WebError> {
|
||||
match self.user_repo.find_by_username(username_query.to_string()).await? {
|
||||
Some(user) => Ok(UserResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}),
|
||||
None => Err(WebError::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
132
ticktalk/crates/backend/src/session.rs
Executable file
132
ticktalk/crates/backend/src/session.rs
Executable file
@@ -0,0 +1,132 @@
|
||||
use actix_web::{
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
Error, HttpMessage, HttpRequest,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use futures_util::future::{ready, LocalBoxFuture, Ready};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SESSION_HEADER: &str = "X-Session-Id";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionManager {
|
||||
sessions: Arc<DashMap<Uuid, SessionRecord>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionRecord {
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionClaims {
|
||||
pub session_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_session(&self, user_id: Uuid, username: String) -> Uuid {
|
||||
let session_id = Uuid::new_v4();
|
||||
self.sessions.insert(
|
||||
session_id,
|
||||
SessionRecord {
|
||||
user_id,
|
||||
username,
|
||||
},
|
||||
);
|
||||
session_id
|
||||
}
|
||||
|
||||
pub fn get(&self, session_id: &Uuid) -> Option<SessionRecord> {
|
||||
self.sessions.get(session_id).map(|entry| entry.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionLayer {
|
||||
manager: SessionManager,
|
||||
}
|
||||
|
||||
impl SessionLayer {
|
||||
pub fn new(manager: SessionManager) -> Self {
|
||||
Self { manager }
|
||||
}
|
||||
|
||||
pub fn claims(req: &HttpRequest) -> Option<SessionClaims> {
|
||||
req.extensions().get::<SessionClaims>().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for SessionLayer
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type InitError = ();
|
||||
type Transform = SessionMiddleware<S>;
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(SessionMiddleware {
|
||||
service,
|
||||
manager: self.manager.clone(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionMiddleware<S> {
|
||||
service: S,
|
||||
manager: SessionManager,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for SessionMiddleware<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, mut req: ServiceRequest) -> Self::Future {
|
||||
let manager = self.manager.clone();
|
||||
|
||||
if let Some(claims) = extract_claims(&req, &manager) {
|
||||
req.extensions_mut().insert(claims);
|
||||
}
|
||||
|
||||
let fut = self.service.call(req);
|
||||
|
||||
Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_claims(req: &ServiceRequest, manager: &SessionManager) -> Option<SessionClaims> {
|
||||
let session_id = req
|
||||
.headers()
|
||||
.get(SESSION_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| Uuid::parse_str(value).ok())?;
|
||||
|
||||
manager.get(&session_id).map(|record| SessionClaims {
|
||||
session_id,
|
||||
user_id: record.user_id,
|
||||
username: record.username,
|
||||
})
|
||||
}
|
||||
1
ticktalk/crates/backend/src/types.rs
Executable file
1
ticktalk/crates/backend/src/types.rs
Executable file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user