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 serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use once_cell::sync::Lazy;
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";
#[derive(Debug, Deserialize)]
@ -288,13 +307,33 @@ pub async fn login(
.and_then(|s| s.as_str())
.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);
Ok(Json(LoginResponse {
success: true,
user_id: Some(user_id),
session_id: session_id.clone(),
access_token: session_id,
access_token: Some(access_token),
refresh_token: None,
expires_in: Some(3600),
requires_2fa: false,
@ -314,8 +353,13 @@ pub async fn logout(
.and_then(|auth| auth.strip_prefix("Bearer "))
.map(String::from);
if let Some(ref _token) = token {
info!("User logged out");
if let Some(ref token_str) = token {
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 {
@ -325,7 +369,7 @@ pub async fn logout(
}
pub async fn get_current_user(
State(state): State<Arc<AppState>>,
State(_state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
) -> Result<Json<CurrentUserResponse>, (StatusCode, Json<ErrorResponse>)> {
let session_token = headers
@ -333,6 +377,7 @@ pub async fn get_current_user(
.and_then(|v| v.to_str().ok())
.and_then(|auth| auth.strip_prefix("Bearer "))
.ok_or_else(|| {
warn!("get_current_user: Missing authorization header");
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
@ -342,192 +387,49 @@ pub async fn get_current_user(
)
})?;
let client = {
let auth_service = state.auth_service.lock().await;
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);
if session_token.is_empty() {
warn!("get_current_user: Empty authorization token");
return Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Invalid or expired session".to_string(),
error: "Invalid authorization token".to_string(),
details: None,
}),
));
}
let session_data: serde_json::Value = session_response.json().await.map_err(|e| {
error!("Failed to parse session response: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to parse session data".to_string(),
details: None,
}),
)
})?;
info!("get_current_user: looking up session token (len={}, prefix={}...)",
session_token.len(),
&session_token[..std::cmp::min(20, session_token.len())]);
let user_id = session_data
.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();
let cache = SESSION_CACHE.read().await;
if user_id.is_empty() {
return Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Invalid session - no user found".to_string(),
details: None,
}),
));
if let Some(user_data) = cache.get(session_token) {
info!("get_current_user: found cached session for user: {}", user_data.email);
return Ok(Json(CurrentUserResponse {
id: user_data.user_id.clone(),
username: user_data.username.clone(),
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);
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,
}),
)
})?;
drop(cache);
if !user_response.status().is_success() {
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,
}),
));
}
warn!("get_current_user: session not found in cache, token may be from previous server run");
let user_data: serde_json::Value = user_response.json().await.map_err(|e| {
error!("Failed to parse user response: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
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,
}))
Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Session expired or invalid. Please log in again.".to_string(),
details: None,
}),
))
}
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/product")
.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("/auth")
.add_public_path("/static")
@ -494,27 +496,28 @@ async fn run_axum_server(
let rbac_manager_for_middleware = Arc::clone(&rbac_manager);
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(security_headers_extension)
.layer(rate_limit_extension)
// Request ID tracking for all requests
.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
// 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| {
let rbac = Arc::clone(&rbac_manager_for_middleware);
async move {
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
.layer(middleware::from_fn(move |req, next| {
let config = panic_config.clone();

View file

@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::security::auth_provider::AuthProviderRegistry;
@ -530,11 +530,8 @@ impl Default for AuthConfig {
"/.well-known".to_string(),
"/metrics".to_string(),
"/api/auth/login".to_string(),
"/api/auth/logout".to_string(),
"/api/auth/refresh".to_string(),
"/api/auth/bootstrap".to_string(),
"/api/auth/2fa/verify".to_string(),
"/api/auth/2fa/resend".to_string(),
"/api/auth/refresh".to_string(),
"/oauth".to_string(),
"/auth/callback".to_string(),
],
@ -873,32 +870,46 @@ pub async fn auth_middleware_with_providers(
) -> Response {
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) {
info!("[AUTH] Path is public/anonymous, skipping auth");
request
.extensions_mut()
.insert(AuthenticatedUser::anonymous());
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 user = authenticate_with_extracted_data(extracted, &state.config, &state.provider_registry).await;
match 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);
next.run(request).await
}
Err(e) => {
if !state.config.require_auth {
warn!("Authentication failed but not required, allowing anonymous: {:?}", e);
warn!("[AUTH] Failed but not required, allowing anonymous: {:?}", e);
request
.extensions_mut()
.insert(AuthenticatedUser::anonymous());
return next.run(request).await;
}
debug!("Authentication failed: {:?}", e);
info!("[AUTH] Failed: {:?}", e);
e.into_response()
}
}