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:
asepharyana
2026-08-27 22:04:32 +07:00
parent 7f64423615
commit 6db00b2266
7 changed files with 219 additions and 25 deletions
+53
View File
@@ -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()));
}