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:
@@ -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()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user