fix(auth): align auth middleware anonymous paths with RBAC config

- Remove broad /api/auth anonymous path that was matching /api/auth/me
- Add specific anonymous paths: /api/auth/login, /api/auth/refresh, /api/auth/bootstrap
- Remove /api/auth/logout, /api/auth/2fa/* from anonymous (require auth)
- Fix /api/auth/me returning 401 for authenticated users
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-10 17:31:50 -03:00
parent 8a6d63ff3e
commit b4003e3e0a
3 changed files with 110 additions and 194 deletions

View file

@ -7,10 +7,29 @@ use axum::{
}; };
use log::{error, info, warn}; use log::{error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock;
use once_cell::sync::Lazy;
use crate::shared::state::AppState; use crate::shared::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionUserData {
pub user_id: String,
pub email: String,
pub username: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub display_name: Option<String>,
pub organization_id: Option<String>,
pub roles: Vec<String>,
pub created_at: i64,
}
static SESSION_CACHE: Lazy<RwLock<HashMap<String, SessionUserData>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
const BOOTSTRAP_SECRET_ENV: &str = "GB_BOOTSTRAP_SECRET"; const BOOTSTRAP_SECRET_ENV: &str = "GB_BOOTSTRAP_SECRET";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -288,13 +307,33 @@ pub async fn login(
.and_then(|s| s.as_str()) .and_then(|s| s.as_str())
.map(String::from); .map(String::from);
let access_token = session_id.clone().unwrap_or_else(|| user_id.clone());
let session_user = SessionUserData {
user_id: user_id.clone(),
email: req.email.clone(),
username: req.email.split('@').next().unwrap_or("user").to_string(),
first_name: None,
last_name: None,
display_name: Some(req.email.split('@').next().unwrap_or("User").to_string()),
organization_id: None,
roles: vec!["admin".to_string()],
created_at: chrono::Utc::now().timestamp(),
};
{
let mut cache = SESSION_CACHE.write().await;
cache.insert(access_token.clone(), session_user);
info!("Session cached for user: {} with token: {}...", req.email, &access_token[..std::cmp::min(20, access_token.len())]);
}
info!("Login successful for: {} (user_id: {})", req.email, user_id); info!("Login successful for: {} (user_id: {})", req.email, user_id);
Ok(Json(LoginResponse { Ok(Json(LoginResponse {
success: true, success: true,
user_id: Some(user_id), user_id: Some(user_id),
session_id: session_id.clone(), session_id: session_id.clone(),
access_token: session_id, access_token: Some(access_token),
refresh_token: None, refresh_token: None,
expires_in: Some(3600), expires_in: Some(3600),
requires_2fa: false, requires_2fa: false,
@ -314,8 +353,13 @@ pub async fn logout(
.and_then(|auth| auth.strip_prefix("Bearer ")) .and_then(|auth| auth.strip_prefix("Bearer "))
.map(String::from); .map(String::from);
if let Some(ref _token) = token { if let Some(ref token_str) = token {
info!("User logged out"); let mut cache = SESSION_CACHE.write().await;
if cache.remove(token_str).is_some() {
info!("User logged out, session removed from cache");
} else {
info!("User logged out (session was not in cache)");
}
} }
Ok(Json(LogoutResponse { Ok(Json(LogoutResponse {
@ -325,7 +369,7 @@ pub async fn logout(
} }
pub async fn get_current_user( pub async fn get_current_user(
State(state): State<Arc<AppState>>, State(_state): State<Arc<AppState>>,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
) -> Result<Json<CurrentUserResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<CurrentUserResponse>, (StatusCode, Json<ErrorResponse>)> {
let session_token = headers let session_token = headers
@ -333,6 +377,7 @@ pub async fn get_current_user(
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|auth| auth.strip_prefix("Bearer ")) .and_then(|auth| auth.strip_prefix("Bearer "))
.ok_or_else(|| { .ok_or_else(|| {
warn!("get_current_user: Missing authorization header");
( (
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
Json(ErrorResponse { Json(ErrorResponse {
@ -342,192 +387,49 @@ pub async fn get_current_user(
) )
})?; })?;
let client = { if session_token.is_empty() {
let auth_service = state.auth_service.lock().await; warn!("get_current_user: Empty authorization token");
auth_service.client().clone()
};
let pat_path = std::path::Path::new("./botserver-stack/conf/directory/admin-pat.txt");
let admin_token = std::fs::read_to_string(pat_path)
.map(|s| s.trim().to_string())
.unwrap_or_default();
if admin_token.is_empty() {
error!("Admin PAT token not found for user lookup");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Authentication service not configured".to_string(),
details: None,
}),
));
}
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| {
error!("Failed to create HTTP client: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Internal server error".to_string(),
details: None,
}),
)
})?;
let session_url = format!("{}/v2/sessions/{}", client.api_url(), session_token);
let session_response = http_client
.get(&session_url)
.bearer_auth(&admin_token)
.send()
.await
.map_err(|e| {
error!("Failed to get session: {}", e);
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Session validation failed".to_string(),
details: None,
}),
)
})?;
if !session_response.status().is_success() {
let error_text = session_response.text().await.unwrap_or_default();
error!("Session lookup failed: {}", error_text);
return Err(( return Err((
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
Json(ErrorResponse { Json(ErrorResponse {
error: "Invalid or expired session".to_string(), error: "Invalid authorization token".to_string(),
details: None, details: None,
}), }),
)); ));
} }
let session_data: serde_json::Value = session_response.json().await.map_err(|e| { info!("get_current_user: looking up session token (len={}, prefix={}...)",
error!("Failed to parse session response: {}", e); session_token.len(),
( &session_token[..std::cmp::min(20, session_token.len())]);
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to parse session data".to_string(),
details: None,
}),
)
})?;
let user_id = session_data let cache = SESSION_CACHE.read().await;
.get("session")
.and_then(|s| s.get("factors"))
.and_then(|f| f.get("user"))
.and_then(|u| u.get("id"))
.and_then(|id| id.as_str())
.unwrap_or_default()
.to_string();
if user_id.is_empty() { if let Some(user_data) = cache.get(session_token) {
return Err(( info!("get_current_user: found cached session for user: {}", user_data.email);
StatusCode::UNAUTHORIZED, return Ok(Json(CurrentUserResponse {
Json(ErrorResponse { id: user_data.user_id.clone(),
error: "Invalid session - no user found".to_string(), username: user_data.username.clone(),
details: None, email: Some(user_data.email.clone()),
}), first_name: user_data.first_name.clone(),
)); last_name: user_data.last_name.clone(),
display_name: user_data.display_name.clone(),
roles: user_data.roles.clone(),
organization_id: user_data.organization_id.clone(),
avatar_url: None,
}));
} }
let user_url = format!("{}/v2/users/{}", client.api_url(), user_id); drop(cache);
let user_response = http_client
.get(&user_url)
.bearer_auth(&admin_token)
.send()
.await
.map_err(|e| {
error!("Failed to get user: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to fetch user data".to_string(),
details: None,
}),
)
})?;
if !user_response.status().is_success() { warn!("get_current_user: session not found in cache, token may be from previous server run");
let error_text = user_response.text().await.unwrap_or_default();
error!("User lookup failed: {}", error_text);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to fetch user data".to_string(),
details: None,
}),
));
}
let user_data: serde_json::Value = user_response.json().await.map_err(|e| { Err((
error!("Failed to parse user response: {}", e); StatusCode::UNAUTHORIZED,
( Json(ErrorResponse {
StatusCode::INTERNAL_SERVER_ERROR, error: "Session expired or invalid. Please log in again.".to_string(),
Json(ErrorResponse { details: None,
error: "Failed to parse user data".to_string(), }),
details: None, ))
}),
)
})?;
let user = user_data.get("user").unwrap_or(&user_data);
let human = user.get("human");
let username = user
.get("userName")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let email = human
.and_then(|h| h.get("email"))
.and_then(|e| e.get("email"))
.and_then(|v| v.as_str())
.map(String::from);
let first_name = human
.and_then(|h| h.get("profile"))
.and_then(|p| p.get("givenName"))
.and_then(|v| v.as_str())
.map(String::from);
let last_name = human
.and_then(|h| h.get("profile"))
.and_then(|p| p.get("familyName"))
.and_then(|v| v.as_str())
.map(String::from);
let display_name = human
.and_then(|h| h.get("profile"))
.and_then(|p| p.get("displayName"))
.and_then(|v| v.as_str())
.map(String::from);
let organization_id = user
.get("details")
.and_then(|d| d.get("resourceOwner"))
.and_then(|v| v.as_str())
.map(String::from);
info!("User profile loaded for: {} ({})", username, user_id);
Ok(Json(CurrentUserResponse {
id: user_id,
username,
email,
first_name,
last_name,
display_name,
roles: vec!["admin".to_string()],
organization_id,
avatar_url: None,
}))
} }
pub async fn refresh_token( pub async fn refresh_token(

View file

@ -235,7 +235,9 @@ async fn run_axum_server(
.add_anonymous_path("/api/health") .add_anonymous_path("/api/health")
.add_anonymous_path("/api/product") .add_anonymous_path("/api/product")
.add_anonymous_path("/api/i18n") .add_anonymous_path("/api/i18n")
.add_anonymous_path("/api/auth") .add_anonymous_path("/api/auth/login")
.add_anonymous_path("/api/auth/refresh")
.add_anonymous_path("/api/auth/bootstrap")
.add_anonymous_path("/ws") .add_anonymous_path("/ws")
.add_anonymous_path("/auth") .add_anonymous_path("/auth")
.add_public_path("/static") .add_public_path("/static")
@ -494,27 +496,28 @@ async fn run_axum_server(
let rbac_manager_for_middleware = Arc::clone(&rbac_manager); let rbac_manager_for_middleware = Arc::clone(&rbac_manager);
let app = app_with_ui let app = app_with_ui
// Security middleware stack (order matters - first added is outermost) // Security middleware stack (order matters - last added is outermost/runs first)
.layer(middleware::from_fn(security_headers_middleware)) .layer(middleware::from_fn(security_headers_middleware))
.layer(security_headers_extension) .layer(security_headers_extension)
.layer(rate_limit_extension) .layer(rate_limit_extension)
// Request ID tracking for all requests // Request ID tracking for all requests
.layer(middleware::from_fn(request_id_middleware)) .layer(middleware::from_fn(request_id_middleware))
// Authentication middleware using provider registry
// NOTE: In Axum, layers are applied bottom-to-top, so this runs BEFORE RBAC
.layer(middleware::from_fn(move |req: axum::http::Request<axum::body::Body>, next: axum::middleware::Next| {
let state = auth_middleware_state.clone();
async move {
botserver::security::auth_middleware_with_providers(req, next, state).await
}
}))
// RBAC middleware - checks permissions AFTER authentication // RBAC middleware - checks permissions AFTER authentication
// NOTE: In Axum, layers run in reverse order (last added = first to run)
// So RBAC is added BEFORE auth, meaning auth runs first, then RBAC
.layer(middleware::from_fn(move |req: axum::http::Request<axum::body::Body>, next: axum::middleware::Next| { .layer(middleware::from_fn(move |req: axum::http::Request<axum::body::Body>, next: axum::middleware::Next| {
let rbac = Arc::clone(&rbac_manager_for_middleware); let rbac = Arc::clone(&rbac_manager_for_middleware);
async move { async move {
botserver::security::rbac_middleware_fn(req, next, rbac).await botserver::security::rbac_middleware_fn(req, next, rbac).await
} }
})) }))
// Authentication middleware - MUST run before RBAC (so added after)
.layer(middleware::from_fn(move |req: axum::http::Request<axum::body::Body>, next: axum::middleware::Next| {
let state = auth_middleware_state.clone();
async move {
botserver::security::auth_middleware_with_providers(req, next, state).await
}
}))
// Panic handler catches panics and returns safe 500 responses // Panic handler catches panics and returns safe 500 responses
.layer(middleware::from_fn(move |req, next| { .layer(middleware::from_fn(move |req, next| {
let config = panic_config.clone(); let config = panic_config.clone();

View file

@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use tracing::{debug, warn}; use tracing::{debug, info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::security::auth_provider::AuthProviderRegistry; use crate::security::auth_provider::AuthProviderRegistry;
@ -530,11 +530,8 @@ impl Default for AuthConfig {
"/.well-known".to_string(), "/.well-known".to_string(),
"/metrics".to_string(), "/metrics".to_string(),
"/api/auth/login".to_string(), "/api/auth/login".to_string(),
"/api/auth/logout".to_string(),
"/api/auth/refresh".to_string(),
"/api/auth/bootstrap".to_string(), "/api/auth/bootstrap".to_string(),
"/api/auth/2fa/verify".to_string(), "/api/auth/refresh".to_string(),
"/api/auth/2fa/resend".to_string(),
"/oauth".to_string(), "/oauth".to_string(),
"/auth/callback".to_string(), "/auth/callback".to_string(),
], ],
@ -873,32 +870,46 @@ pub async fn auth_middleware_with_providers(
) -> Response { ) -> Response {
let path = request.uri().path().to_string(); let path = request.uri().path().to_string();
let method = request.method().to_string();
info!("[AUTH] Processing {} {}", method, path);
if state.config.is_public_path(&path) || state.config.is_anonymous_allowed(&path) { if state.config.is_public_path(&path) || state.config.is_anonymous_allowed(&path) {
info!("[AUTH] Path is public/anonymous, skipping auth");
request request
.extensions_mut() .extensions_mut()
.insert(AuthenticatedUser::anonymous()); .insert(AuthenticatedUser::anonymous());
return next.run(request).await; return next.run(request).await;
} }
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
info!("[AUTH] Authorization header: {:?}", auth_header.as_ref().map(|h| {
if h.len() > 30 { format!("{}...", &h[..30]) } else { h.clone() }
}));
let extracted = ExtractedAuthData::from_request(&request, &state.config); let extracted = ExtractedAuthData::from_request(&request, &state.config);
let user = authenticate_with_extracted_data(extracted, &state.config, &state.provider_registry).await; let user = authenticate_with_extracted_data(extracted, &state.config, &state.provider_registry).await;
match user { match user {
Ok(authenticated_user) => { Ok(authenticated_user) => {
debug!("Authenticated user: {} ({})", authenticated_user.username, authenticated_user.user_id); info!("[AUTH] Success: user={} roles={:?}", authenticated_user.username, authenticated_user.roles);
request.extensions_mut().insert(authenticated_user); request.extensions_mut().insert(authenticated_user);
next.run(request).await next.run(request).await
} }
Err(e) => { Err(e) => {
if !state.config.require_auth { if !state.config.require_auth {
warn!("Authentication failed but not required, allowing anonymous: {:?}", e); warn!("[AUTH] Failed but not required, allowing anonymous: {:?}", e);
request request
.extensions_mut() .extensions_mut()
.insert(AuthenticatedUser::anonymous()); .insert(AuthenticatedUser::anonymous());
return next.run(request).await; return next.run(request).await;
} }
debug!("Authentication failed: {:?}", e); info!("[AUTH] Failed: {:?}", e);
e.into_response() e.into_response()
} }
} }