init
This commit is contained in:
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user