Skip to content
Merged

Session #1172

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion backend/modules/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ tokio = { version = "1", features = ["full"] }
# For Prometheus metrics
actix-web-prom = "0.7"
once_cell = "1.19"
futures-util = "0.3"

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
actix-rt = "2.9"
awc = "3"
futures-util = "0.3"
actix-test = "0.1"
sha2 = "0.10"
sea-orm = { version = "1.1.0", features = ["sqlx-sqlite"] }

14 changes: 9 additions & 5 deletions backend/modules/api/src/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ use serde_json::json;
use tracing::error;
use validator::Validate;

use crate::metrics::increment_ai_requests;
use service::engine_service::EngineService;
use std::env;
use crate::metrics::increment_ai_requests;

#[utoipa::path(
post,
Expand All @@ -31,7 +31,7 @@ use crate::metrics::increment_ai_requests;
pub async fn get_ai_suggestion(payload: Json<AiSuggestionRequest>) -> HttpResponse {
// Track AI request
increment_ai_requests("suggestion");

match payload.0.validate() {
Ok(_) => {
let engine_path = env::var("ENGINE_PATH").unwrap_or_else(|_| "stockfish".to_string());
Expand Down Expand Up @@ -61,7 +61,9 @@ pub async fn get_ai_suggestion(payload: Json<AiSuggestionRequest>) -> HttpRespon
}
Err(errors) => {
let error_strings: Vec<String> = errors
.field_errors().values().flat_map(|errs| {
.field_errors()
.values()
.flat_map(|errs| {
errs.iter()
.map(|err| err.message.clone().unwrap_or_default().to_string())
})
Expand Down Expand Up @@ -93,7 +95,7 @@ pub async fn get_ai_suggestion(payload: Json<AiSuggestionRequest>) -> HttpRespon
pub async fn analyze_position(payload: Json<PositionAnalysisRequest>) -> HttpResponse {
// Track AI request
increment_ai_requests("analysis");

match payload.0.validate() {
Ok(_) => {
let engine_path = env::var("ENGINE_PATH").unwrap_or_else(|_| "stockfish".to_string());
Expand Down Expand Up @@ -121,7 +123,9 @@ pub async fn analyze_position(payload: Json<PositionAnalysisRequest>) -> HttpRes
}
Err(errors) => {
let error_strings: Vec<String> = errors
.field_errors().values().flat_map(|errs| {
.field_errors()
.values()
.flat_map(|errs| {
errs.iter()
.map(|err| err.message.clone().unwrap_or_default().to_string())
})
Expand Down
211 changes: 182 additions & 29 deletions backend/modules/api/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ use actix_web::{
cookie::{time::Duration, Cookie},
post, web, HttpRequest, HttpResponse,
};
use deadpool_redis::Pool;
use redis::AsyncCommands;
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{error, warn};
use uuid::Uuid;
use validator::Validate;

use crate::metrics::increment_auth_events;
use db::DbPool;
use db_entity::player;
use dto::auth::{
Expand All @@ -17,6 +21,42 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use security::{JwtService, TokenService, TokenServiceError};
use service::helper::password;

async fn add_jti_to_blacklist(
redis_pool: &Pool,
jti: &str,
ttl_seconds: usize,
) -> Result<(), String> {
if jti.trim().is_empty() {
return Ok(());
}

let mut conn = redis_pool.get().await.map_err(|e| e.to_string())?;
let key = format!("token_blacklist:{}", jti);
let ttl = ttl_seconds.max(1) as usize;
let _: () = redis::cmd("SET")
.arg(&key)
.arg("1")
.arg("EX")
.arg(ttl)
.query_async(&mut conn)
.await
.map_err(|e| format!("Redis blacklist write failed: {}", e))?;
Ok(())
}

async fn is_jti_blacklisted(redis_pool: &Pool, jti: &str) -> bool {
if jti.trim().is_empty() {
return false;
}

let mut conn = match redis_pool.get().await {
Ok(c) => c,
Err(_) => return false,
};
let key = format!("token_blacklist:{}", jti);
conn.exists(&key).await.unwrap_or(false)
}

/// Register a new user
#[utoipa::path(
post,
Expand All @@ -42,7 +82,7 @@ pub async fn register(

// For now, return a mock response
increment_auth_events("register", true);

HttpResponse::Created().json(AuthResponse {
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...".to_string(),
refresh_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...".to_string(),
Expand Down Expand Up @@ -157,10 +197,10 @@ pub async fn login(
.finish();

response.add_cookie(&cookie).ok();

// Track successful login
increment_auth_events("login", true);

response
}

Expand All @@ -181,6 +221,7 @@ pub async fn refresh(
req: HttpRequest,
payload: Option<web::Json<RefreshTokenRequest>>,
jwt_service: web::Data<JwtService>,
redis_pool: web::Data<Pool>,
) -> HttpResponse {
let refresh_token = if let Some(cookie) = req.cookie("refresh_token") {
cookie.value().to_string()
Expand Down Expand Up @@ -231,35 +272,41 @@ pub async fn refresh(
}
};

// WRITE: mark refresh token as used (must go to primary for atomicity)
let family_id = match TokenService::verify_and_mark_used(
pool.primary(),
&refresh_token,
claims.user_id,
)
.await
{
Ok(fid) => fid,
Err(TokenServiceError::TokenReuseDetected) => {
warn!("Token reuse detected for player {}", claims.user_id);
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Token reuse detected. Account locked for security.".to_string(),
code: "TOKEN_THEFT_DETECTED".to_string(),
});
}
Err(TokenServiceError::TokenExpired) => {
if let Some(jti) = claims.jti.as_ref() {
if is_jti_blacklisted(redis_pool.as_ref(), jti).await {
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Refresh token has expired".to_string(),
code: "TOKEN_EXPIRED".to_string(),
message: "Token revoked".to_string(),
code: "TOKEN_REVOKED".to_string(),
});
}
Err(_) => {
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Invalid refresh token".to_string(),
code: "INVALID_REFRESH_TOKEN".to_string(),
});
}
};
}

// WRITE: mark refresh token as used (must go to primary for atomicity)
let family_id =
match TokenService::verify_and_mark_used(pool.primary(), &refresh_token, claims.user_id)
.await
{
Ok(fid) => fid,
Err(TokenServiceError::TokenReuseDetected) => {
warn!("Token reuse detected for player {}", claims.user_id);
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Token reuse detected. Account locked for security.".to_string(),
code: "TOKEN_THEFT_DETECTED".to_string(),
});
}
Err(TokenServiceError::TokenExpired) => {
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Refresh token has expired".to_string(),
code: "TOKEN_EXPIRED".to_string(),
});
}
Err(_) => {
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Invalid refresh token".to_string(),
code: "INVALID_REFRESH_TOKEN".to_string(),
});
}
};

let new_access_token =
match jwt_service.generate_token(claims.user_id, &claims.username, claims.player_id) {
Expand Down Expand Up @@ -329,6 +376,7 @@ pub async fn logout(
pool: web::Data<DbPool>,
req: HttpRequest,
jwt_service: web::Data<JwtService>,
redis_pool: web::Data<Pool>,
) -> HttpResponse {
let auth_header = match req.headers().get("Authorization") {
Some(h) => match h.to_str() {
Expand Down Expand Up @@ -370,6 +418,18 @@ pub async fn logout(

let user_id = claims.user_id;

if let Some(jti) = claims.jti.as_ref() {
let ttl = claims.exp.saturating_sub(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as usize,
);
if let Err(e) = add_jti_to_blacklist(redis_pool.as_ref(), jti, ttl).await {
error!("Failed to blacklist access token jti {}: {}", jti, e);
}
}

// WRITE: revoke tokens on primary
if let Err(e) = TokenService::revoke_player_tokens(pool.primary(), user_id).await {
error!("Failed to revoke tokens: {}", e);
Expand All @@ -393,3 +453,96 @@ pub async fn logout(
response.add_cookie(&cookie).ok();
response
}

/// Logout all sessions for the current user and immediately blacklist the active token.
#[utoipa::path(
post,
path = "/api/v1/auth/logout_all",
responses(
(status = 200, description = "Logout all sessions successful", body = LogoutResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse)
),
tag = "Authentication"
)]
#[post("/logout_all")]
pub async fn logout_all(
pool: web::Data<DbPool>,
req: HttpRequest,
jwt_service: web::Data<JwtService>,
redis_pool: web::Data<Pool>,
) -> HttpResponse {
let auth_header = match req.headers().get("Authorization") {
Some(h) => match h.to_str() {
Ok(s) => s.to_string(),
Err(_) => {
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Invalid authorization header".to_string(),
code: "INVALID_AUTH_HEADER".to_string(),
});
}
},
None => {
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Missing authorization header".to_string(),
code: "MISSING_AUTH_HEADER".to_string(),
});
}
};

let token = match auth_header.strip_prefix("Bearer ") {
Some(t) => t,
None => {
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Invalid authorization format".to_string(),
code: "INVALID_AUTH_FORMAT".to_string(),
});
}
};

let claims = match jwt_service.validate_token(token) {
Ok(c) => c,
Err(_) => {
return HttpResponse::Unauthorized().json(ErrorResponse {
message: "Invalid or expired access token".to_string(),
code: "INVALID_ACCESS_TOKEN".to_string(),
});
}
};

if let Some(jti) = claims.jti.as_ref() {
let ttl = claims.exp.saturating_sub(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as usize,
);
if let Err(e) = add_jti_to_blacklist(redis_pool.as_ref(), jti, ttl).await {
error!("Failed to blacklist access token jti {}: {}", jti, e);
}
}

if let Err(e) = TokenService::revoke_player_tokens(pool.primary(), claims.user_id).await {
error!(
"Failed to revoke all sessions for user {}: {}",
claims.user_id, e
);
return HttpResponse::InternalServerError().json(ErrorResponse {
message: "Failed to logout all devices".to_string(),
code: "LOGOUT_ALL_ERROR".to_string(),
});
}

let mut response = HttpResponse::Ok().json(LogoutResponse {
message: "Logged out all devices successfully".to_string(),
});

let cookie = Cookie::build("refresh_token", "")
.http_only(true)
.secure(false)
.same_site(actix_web::cookie::SameSite::Strict)
.max_age(Duration::seconds(0))
.finish();

response.add_cookie(&cookie).ok();
response
}
Loading
Loading