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
+26 -9
View File
@@ -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,
}
}