fix(api): perbaiki keamanan auth & WebSocket, tambah rate limiting
Security fixes hasil audit: - fix(auth): refresh token kini memakai claim typ=refresh; access token tidak bisa dipakai sebagai refresh token (sebelumnya bisa — eskalasi masa berlaku 1 jam -> 7 hari) - fix(api): layer JWT hanya melindungi route /sessions dan /chat; /auth/login, /auth/register, /auth/refresh, /health kini publik (sebelumnya semua route 401-lock, API tidak bisa dipakai sama sekali) - fix(ws): endpoint /ws kini memverifikasi token ZESDEX_WS_TOKEN via query param jika env diset (mencegah pemakaian LLM proxy terbuka) - feat(api): rate limiting login/register/refresh (20 request / 10 menit per client IP) memakai RateLimiter yang tadinya dead code - test(jwt): tambah unit test token type access vs refresh + expired
This commit is contained in:
@@ -44,6 +44,10 @@ pub enum ApiError {
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// The client has sent too many requests in a given time window.
|
||||
#[error("Too many requests: {0}")]
|
||||
TooManyRequests(String),
|
||||
|
||||
/// An unexpected internal error occurred.
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
@@ -65,6 +69,9 @@ impl IntoResponse for ApiError {
|
||||
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||
ApiError::TooManyRequests(msg) => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, msg.clone())
|
||||
}
|
||||
ApiError::Internal(msg) => {
|
||||
tracing::error!(error = %msg, "Internal server error");
|
||||
(
|
||||
|
||||
@@ -24,6 +24,11 @@ use crate::dto::auth::{AuthResponse, LoginRequest, RefreshRequest, RegisterReque
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Login/register brute-force protection: 20 attempts per 10-minute window
|
||||
/// per client IP.
|
||||
const AUTH_RATE_LIMIT_MAX: u32 = 20;
|
||||
const AUTH_RATE_LIMIT_WINDOW_SECS: u64 = 600;
|
||||
|
||||
/// Build the auth sub-router (`/auth/*`).
|
||||
pub fn router() -> Router<Arc<ApiState>> {
|
||||
Router::new()
|
||||
@@ -32,6 +37,30 @@ pub fn router() -> Router<Arc<ApiState>> {
|
||||
.route("/refresh", post(refresh_handler))
|
||||
}
|
||||
|
||||
/// Extract a coarse client identity from the request headers (IP via
|
||||
/// X-Forwarded-For fallback). Used as the rate-limit key.
|
||||
fn client_id(headers: &axum::http::HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// Enforce the auth rate limit, returning `true` if the request is allowed.
|
||||
fn rate_limited(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
|
||||
!state
|
||||
.auth_rate_limiter
|
||||
.check_rate_limit(
|
||||
&client_id(headers),
|
||||
AUTH_RATE_LIMIT_MAX,
|
||||
AUTH_RATE_LIMIT_WINDOW_SECS,
|
||||
)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// POST /auth/login — authenticate and issue JWT tokens.
|
||||
///
|
||||
/// ## Flow
|
||||
@@ -50,8 +79,16 @@ pub fn router() -> Router<Arc<ApiState>> {
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn login_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Rate-limit login attempts (brute-force protection).
|
||||
if rate_limited(&state, &headers) {
|
||||
return Err(ApiError::TooManyRequests(
|
||||
"Too many login attempts, try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if req.username.is_empty() || req.password.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
@@ -119,8 +156,16 @@ pub async fn login_handler(
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn register_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Rate-limit registration (abuse protection).
|
||||
if rate_limited(&state, &headers) {
|
||||
return Err(ApiError::TooManyRequests(
|
||||
"Too many registration attempts, try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if req.username.is_empty() {
|
||||
return Err(ApiError::BadRequest("Username is required".into()));
|
||||
@@ -194,8 +239,16 @@ pub async fn register_handler(
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn refresh_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Rate-limit refresh attempts.
|
||||
if rate_limited(&state, &headers) {
|
||||
return Err(ApiError::TooManyRequests(
|
||||
"Too many requests, try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if req.refresh_token.is_empty() {
|
||||
return Err(ApiError::BadRequest("Refresh token is required".into()));
|
||||
}
|
||||
|
||||
@@ -56,25 +56,34 @@ pub fn build_router(state: ApiState) -> Router {
|
||||
// CORS layer — permissive for local daemon / development use
|
||||
let cors = CorsLayer::permissive();
|
||||
|
||||
// JWT auth middleware — validates Bearer tokens on all API routes.
|
||||
// Health and auth endpoints (login/register/refresh) are also
|
||||
// protected; adjust route ordering or add an allow-list inside the
|
||||
// middleware if public access is needed.
|
||||
// JWT auth middleware — protects session/chat routes.
|
||||
// Auth (login/register/refresh) and health endpoints stay public.
|
||||
let jwt_auth = middleware::auth::JwtAuthLayer::new(shared_state.clone());
|
||||
|
||||
// Combine all sub-routers under a versioned prefix
|
||||
Router::new()
|
||||
.nest("/api/v1", api_v1_router())
|
||||
.nest("/api/v1/auth", auth_router())
|
||||
.nest("/api/v1/health", health_router())
|
||||
.nest(
|
||||
"/api/v1",
|
||||
protected_router().layer(jwt_auth),
|
||||
)
|
||||
.layer(cors)
|
||||
.layer(jwt_auth)
|
||||
.with_state(shared_state)
|
||||
}
|
||||
|
||||
/// Version 1 API sub-router.
|
||||
///
|
||||
/// Groups all resource routes under `/api/v1/*`.
|
||||
fn api_v1_router() -> Router<Arc<ApiState>> {
|
||||
use handlers::{auth, chat, conversations, health, sessions};
|
||||
/// Auth + health sub-routers — publicly accessible (no JWT required).
|
||||
fn auth_router() -> Router<Arc<ApiState>> {
|
||||
handlers::auth::router()
|
||||
}
|
||||
|
||||
fn health_router() -> Router<Arc<ApiState>> {
|
||||
Router::new().route("/", axum::routing::get(handlers::health::health))
|
||||
}
|
||||
|
||||
/// Protected sub-router — sessions + chat, guarded by JWT auth layer.
|
||||
fn protected_router() -> Router<Arc<ApiState>> {
|
||||
use handlers::{chat, conversations, sessions};
|
||||
|
||||
// Sessions router combines session CRUD + nested conversations
|
||||
let sessions_router = Router::new()
|
||||
@@ -96,8 +105,6 @@ fn api_v1_router() -> Router<Arc<ApiState>> {
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.route("/health", axum::routing::get(health::health))
|
||||
.nest("/auth", auth::router())
|
||||
.nest("/sessions", sessions_router)
|
||||
.nest("/chat", chat::router())
|
||||
}
|
||||
|
||||
@@ -98,6 +98,21 @@ where
|
||||
if let Some(token) = auth_value.strip_prefix("Bearer ") {
|
||||
match zesdex_infrastructure::auth::jwt::verify_token(&secret, token) {
|
||||
Ok(claims) => {
|
||||
// Reject refresh tokens on protected routes — only access
|
||||
// tokens are acceptable here.
|
||||
if claims.token_type
|
||||
!= zesdex_infrastructure::auth::jwt::TokenType::Access
|
||||
{
|
||||
let response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Invalid token",
|
||||
"detail": "refresh tokens are not accepted on protected routes"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return Box::pin(async move { Ok(response) });
|
||||
}
|
||||
// Inject claims as extension for downstream handlers
|
||||
let mut req = req;
|
||||
req.extensions_mut().insert(JwtClaims {
|
||||
|
||||
@@ -94,7 +94,7 @@ impl JwtTokenService {
|
||||
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};
|
||||
use zesdex_infrastructure::auth::jwt::{create_token, JwtClaims, TokenType};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -102,12 +102,21 @@ impl TokenService for JwtTokenService {
|
||||
.as_secs();
|
||||
|
||||
// Access token
|
||||
let access_claims = JwtClaims::new(sub.to_string(), now + self.access_token_expiry_secs, None);
|
||||
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, None);
|
||||
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))
|
||||
@@ -123,14 +132,17 @@ impl TokenService for JwtTokenService {
|
||||
|
||||
/// Verify a refresh token and return the subject claim.
|
||||
///
|
||||
/// Delegates to the same JWT verification function as access tokens;
|
||||
/// the signature algorithm and secret are shared. Expiry validation
|
||||
/// is handled by the JWT library against the `exp` claim embedded
|
||||
/// in the token payload.
|
||||
/// 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;
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -193,6 +205,9 @@ pub struct ApiState {
|
||||
/// 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,
|
||||
|
||||
/// LLM provider client for chat completions.
|
||||
pub llm_client: zesdex_infrastructure::llm::provider::LlmClient,
|
||||
}
|
||||
@@ -272,6 +287,7 @@ impl ApiState {
|
||||
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 llm_client = zesdex_infrastructure::llm::provider::LlmClient::new(
|
||||
llm_api_key.into(),
|
||||
llm_model.into(),
|
||||
@@ -287,6 +303,7 @@ impl ApiState {
|
||||
memory_service,
|
||||
password_service: Argon2PasswordService,
|
||||
token_service,
|
||||
auth_rate_limiter,
|
||||
llm_client,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
//!
|
||||
//! Enables web clients and other WS-capable consumers to connect
|
||||
//! and participate in sessions. Built on Axum's WebSocket support.
|
||||
//!
|
||||
//! # Security
|
||||
//!
|
||||
//! The WS endpoint accepts an optional `?token=` query parameter. When a
|
||||
//! `ZEESDEX_WS_TOKEN` env var is set, connections MUST present a matching
|
||||
//! token — otherwise the connection is rejected. This prevents the endpoint
|
||||
//! from being used as an open LLM proxy (anyone who can reach the port would
|
||||
//! otherwise run prompts at the server's API cost).
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::Query;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use futures_util::stream::StreamExt;
|
||||
use futures_util::SinkExt;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Shared application state for the WS server.
|
||||
pub struct WsState {
|
||||
@@ -18,6 +28,12 @@ pub struct WsState {
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Query parameters accepted on the `/ws` upgrade.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WsQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the WebSocket router.
|
||||
pub fn build_router(state: Arc<WsState>) -> Router {
|
||||
Router::new()
|
||||
@@ -28,9 +44,18 @@ pub fn build_router(state: Arc<WsState>) -> Router {
|
||||
/// WebSocket upgrade handler.
|
||||
async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
Query(query): Query<WsQuery>,
|
||||
axum::extract::State(state): axum::extract::State<Arc<WsState>>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
let configured = std::env::var("ZESDEX_WS_TOKEN").ok().filter(|s| !s.is_empty());
|
||||
match configured {
|
||||
Some(expected) if query.token.as_deref() != Some(expected.as_str()) => {
|
||||
warn!("rejecting WS connection: missing/invalid token");
|
||||
// 401 Unauthorized — client did not present the required token.
|
||||
(axum::http::StatusCode::UNAUTHORIZED, "missing or invalid token").into_response()
|
||||
}
|
||||
_ => ws.on_upgrade(move |socket| handle_socket(socket, state)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an established WebSocket connection.
|
||||
|
||||
Reference in New Issue
Block a user