Files
zesdex/apps/interfaces/api/src/state.rs
T
asepharyana 884b19ccb5 fix(api): cegah race condition pada register users.json (TOCTOU)
- Tambah users_lock (Mutex) di ApiState untuk serialisasi read-modify-write
  users.json pada endpoint register; lock hanya dipegang selama operasi
  file sinkron (tidak pernah lintas .await, menjaga future tetap Send)
- Hash password dihitung sebelum lock sehingga request concurrent tidak
  saling blokir selama hashing Argon2
2026-08-27 22:09:22 +07:00

318 lines
12 KiB
Rust

//! Shared application state for the REST API server.
//!
//! `ApiState` holds concrete service implementations wired to infrastructure
//! adapters. It is constructed at the composition root and shared across all
//! handlers via Axum's `State` extractor (wrapped in `Arc`).
//!
//! # Flow
//!
//! 1. `ApiState::new(base_dir)` creates all services with their concrete repos.
//! 2. `build_router()` wraps it in `Arc` and passes it to the Axum `Router`.
//! 3. Handlers extract `State<Arc<ApiState>>` and delegate to the services.
//!
//! # Port trait implementations
//!
//! This module also provides simple wrapper types that implement the
//! application-layer port traits using infrastructure functions:
//!
//! - `Argon2PasswordService` — implements `PasswordService` via
//! `infrastructure::auth::password`
//! - `JwtTokenService` — implements `TokenService` via
//! `infrastructure::auth::jwt`
use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use zesdex_application::ports::{PasswordService, TokenService};
// ---------------------------------------------------------------------------
// Port trait implementations (wrap infrastructure free functions)
// ---------------------------------------------------------------------------
/// Password-hashing service backed by Argon2id (infrastructure).
///
/// Delegates to `zesdex_infrastructure::auth::password::{hash_password, verify_password}`.
#[derive(Clone)]
pub struct Argon2PasswordService;
impl fmt::Debug for Argon2PasswordService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Argon2PasswordService").finish()
}
}
impl PasswordService for Argon2PasswordService {
/// Hash a plaintext password using Argon2id with a random salt.
fn hash(&self, password: &str) -> impl Future<Output = anyhow::Result<String>> + Send {
zesdex_infrastructure::auth::password::hash_password(password)
}
/// Verify a plaintext password against a stored PHC string.
fn verify(
&self,
password: &str,
hash: &str,
) -> impl Future<Output = anyhow::Result<bool>> + Send {
zesdex_infrastructure::auth::password::verify_password(password, hash)
}
}
/// JWT token service backed by HS256 (infrastructure).
///
/// Delegates to `zesdex_infrastructure::auth::jwt::{create_token, verify_token}`.
#[derive(Clone)]
pub struct JwtTokenService {
/// HMAC secret key used for signing and verification.
pub secret: String,
/// Token expiry in seconds (default: 3600 = 1 hour).
pub access_token_expiry_secs: u64,
/// Refresh token expiry in seconds (default: 604800 = 7 days).
pub refresh_token_expiry_secs: u64,
}
impl fmt::Debug for JwtTokenService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JwtTokenService")
.field("access_token_expiry_secs", &self.access_token_expiry_secs)
.field("refresh_token_expiry_secs", &self.refresh_token_expiry_secs)
.finish_non_exhaustive()
}
}
impl JwtTokenService {
/// Create a new JWT service with the given HMAC secret.
pub fn new(secret: impl Into<String>) -> Self {
JwtTokenService {
secret: secret.into(),
access_token_expiry_secs: 3600,
refresh_token_expiry_secs: 604800,
}
}
}
impl TokenService for JwtTokenService {
/// Generate an access + refresh token pair for the given subject.
fn generate_tokens(&self, sub: &str) -> anyhow::Result<(String, String)> {
use zesdex_infrastructure::auth::jwt::{create_token, JwtClaims, TokenType};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Access token
let access_claims = JwtClaims::new(
sub.to_string(),
now + self.access_token_expiry_secs,
TokenType::Access,
None,
);
let access_token = create_token(&self.secret, access_claims)?;
// Refresh token (longer-lived)
let refresh_claims = JwtClaims::new(
sub.to_string(),
now + self.refresh_token_expiry_secs,
TokenType::Refresh,
None,
);
let refresh_token = create_token(&self.secret, refresh_claims)?;
Ok((access_token, refresh_token))
}
/// Verify an access token and return the subject claim.
fn verify_access_token(&self, token: &str) -> anyhow::Result<String> {
use zesdex_infrastructure::auth::jwt::verify_token;
let claims = verify_token(&self.secret, token)?;
Ok(claims.sub)
}
/// Verify a refresh token and return the subject claim.
///
/// Enforces that the presented token is a **refresh** token (`typ =
/// "refresh"`) — an access token presented here is rejected, closing
/// the replay-window escalation where a stolen 1-hour access token
/// could otherwise be exchanged for a fresh 7-day credential.
fn verify_refresh_token(&self, token: &str) -> anyhow::Result<String> {
use zesdex_infrastructure::auth::jwt::{verify_token, TokenType};
let claims = verify_token(&self.secret, token)?;
if claims.token_type != TokenType::Refresh {
anyhow::bail!("token is not a refresh token");
}
Ok(claims.sub)
}
}
// ---------------------------------------------------------------------------
// ApiState
// ---------------------------------------------------------------------------
/// Shared application state for the REST API server.
///
/// Holds all service implementations, repository instances, and configuration
/// needed by the HTTP handlers. Constructed once at startup and shared
/// across all requests via `Arc<ApiState>`.
///
/// `ApiState` does NOT derive `Clone` or `Debug` because the inner service
/// types may not implement those traits. It is always wrapped in `Arc`.
pub struct ApiState {
/// Base directory for all Zesdex data stores (sessions, settings, etc.).
pub store_base_dir: PathBuf,
/// JWT secret key for token signing/verification.
pub jwt_secret: String,
// -----------------------------------------------------------------------
// Service implementations (application-layer use cases)
// -----------------------------------------------------------------------
/// Session lifecycle management (create, list, archive).
pub session_service:
zesdex_application::auth::SessionServiceImpl<
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository,
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository,
>,
/// Conversation message history CRUD.
pub conversation_service:
zesdex_application::cms::ConversationServiceImpl<
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository,
>,
/// Settings load/save.
pub settings_service:
zesdex_application::cms::SettingsServiceImpl<
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository,
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository,
>,
/// Long-term memory CRUD.
pub memory_service:
zesdex_application::cms::MemoryServiceImpl<
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository,
>,
// -----------------------------------------------------------------------
// Port trait implementations (infrastructure wrappers)
// -----------------------------------------------------------------------
/// Argon2id password hashing and verification.
pub password_service: Argon2PasswordService,
/// HS256 JWT token generation and verification.
pub token_service: JwtTokenService,
/// Shared sliding-window limiter for auth endpoints (login/register/refresh).
pub auth_rate_limiter: zesdex_infrastructure::middleware::rate_limit::RateLimiter,
/// Serializes read-modify-write of `users.json` so concurrent register
/// requests cannot lose writes (TOCTOU race).
pub users_lock: Arc<std::sync::Mutex<()>>,
/// LLM provider client for chat completions.
pub llm_client: zesdex_infrastructure::llm::provider::LlmClient,
}
impl fmt::Debug for ApiState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiState")
.field("store_base_dir", &self.store_base_dir)
.field("jwt_secret", &"**redacted**")
.field("session_service", &"SessionServiceImpl { .. }")
.field("conversation_service", &"ConversationServiceImpl { .. }")
.field("settings_service", &"SettingsServiceImpl { .. }")
.field("memory_service", &"MemoryServiceImpl { .. }")
.field("password_service", &self.password_service)
.field("token_service", &self.token_service)
.field("llm_client", &"LlmClient { .. }")
.finish()
}
}
impl ApiState {
/// Construct a new API state with all services wired to their default
/// infrastructure implementations.
///
/// ## Arguments
/// * `base_dir` — the Zesdex data store root directory (sessions, settings, etc.)
/// * `jwt_secret` — HMAC secret for JWT signing/verification
/// * `llm_api_key` — API key for the LLM provider
/// * `llm_model` — model identifier string
/// * `llm_base_url` — optional custom API base URL
///
/// ## Flow
///
/// Creates concrete repository instances → wraps them in application-layer
/// service implementations → stores everything in `ApiState`.
pub fn new(
base_dir: PathBuf,
jwt_secret: impl Into<String>,
llm_api_key: impl Into<String>,
llm_model: impl Into<String>,
llm_base_url: Option<String>,
) -> Self {
let jwt_secret = jwt_secret.into();
let sessions_dir = base_dir.join("sessions");
let memory_dir = base_dir.join("memories");
// IAM repositories
let session_repo =
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository;
let lock_repo =
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository;
// CMS repositories
let conversation_repo =
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository;
let settings_repo =
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository;
let app_config_repo =
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository;
let memory_repo =
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository;
// Application-layer services
let session_service = zesdex_application::auth::SessionServiceImpl::new(
session_repo,
lock_repo,
base_dir.clone(),
);
let conversation_service =
zesdex_application::cms::ConversationServiceImpl::new(conversation_repo, sessions_dir);
let settings_service = zesdex_application::cms::SettingsServiceImpl::new(
settings_repo,
app_config_repo,
base_dir.clone(),
);
let memory_service =
zesdex_application::cms::MemoryServiceImpl::new(memory_repo, memory_dir);
let token_service = JwtTokenService::new(&jwt_secret);
let auth_rate_limiter = zesdex_infrastructure::middleware::rate_limit::RateLimiter::new();
let users_lock = Arc::new(std::sync::Mutex::new(()));
let llm_client = zesdex_infrastructure::llm::provider::LlmClient::new(
llm_api_key.into(),
llm_model.into(),
llm_base_url,
);
ApiState {
store_base_dir: base_dir,
jwt_secret,
session_service,
conversation_service,
settings_service,
memory_service,
password_service: Argon2PasswordService,
token_service,
auth_rate_limiter,
users_lock,
llm_client,
}
}
}