2026-01-06 22:56:35 -03:00
|
|
|
use axum::{
|
|
|
|
|
extract::State,
|
|
|
|
|
http::{header, StatusCode},
|
|
|
|
|
response::{IntoResponse, Json},
|
|
|
|
|
routing::{get, post},
|
|
|
|
|
Router,
|
|
|
|
|
};
|
|
|
|
|
use log::{error, info, warn};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-01-10 17:31:50 -03:00
|
|
|
use std::collections::HashMap;
|
2026-01-06 22:56:35 -03:00
|
|
|
use std::sync::Arc;
|
2026-01-10 17:31:50 -03:00
|
|
|
use tokio::sync::RwLock;
|
|
|
|
|
use once_cell::sync::Lazy;
|
2026-01-06 22:56:35 -03:00
|
|
|
|
2026-02-12 21:09:30 +00:00
|
|
|
use crate::core::shared::state::AppState;
|
2026-01-06 22:56:35 -03:00
|
|
|
|
2026-01-10 17:31:50 -03:00
|
|
|
#[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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 22:21:25 -03:00
|
|
|
pub static SESSION_CACHE: Lazy<RwLock<HashMap<String, SessionUserData>>> =
|
2026-01-10 17:31:50 -03:00
|
|
|
Lazy::new(|| RwLock::new(HashMap::new()));
|
|
|
|
|
|
2026-01-06 22:56:35 -03:00
|
|
|
const BOOTSTRAP_SECRET_ENV: &str = "GB_BOOTSTRAP_SECRET";
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
pub struct LoginRequest {
|
|
|
|
|
pub email: String,
|
|
|
|
|
pub password: String,
|
2026-01-22 19:45:18 -03:00
|
|
|
pub remember: Option<String>,
|
2026-01-06 22:56:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct LoginResponse {
|
|
|
|
|
pub success: bool,
|
|
|
|
|
pub user_id: Option<String>,
|
|
|
|
|
pub session_id: Option<String>,
|
|
|
|
|
pub access_token: Option<String>,
|
|
|
|
|
pub refresh_token: Option<String>,
|
|
|
|
|
pub expires_in: Option<i64>,
|
|
|
|
|
pub requires_2fa: bool,
|
|
|
|
|
pub session_token: Option<String>,
|
|
|
|
|
pub redirect: Option<String>,
|
|
|
|
|
pub message: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct CurrentUserResponse {
|
2026-02-04 14:10:05 -03:00
|
|
|
pub id: Option<String>,
|
|
|
|
|
pub username: Option<String>,
|
2026-01-06 22:56:35 -03:00
|
|
|
pub email: Option<String>,
|
|
|
|
|
pub first_name: Option<String>,
|
|
|
|
|
pub last_name: Option<String>,
|
|
|
|
|
pub display_name: Option<String>,
|
2026-02-04 14:10:05 -03:00
|
|
|
pub roles: Option<Vec<String>>,
|
2026-01-06 22:56:35 -03:00
|
|
|
pub organization_id: Option<String>,
|
|
|
|
|
pub avatar_url: Option<String>,
|
2026-02-04 14:10:05 -03:00
|
|
|
pub is_anonymous: bool,
|
2026-01-06 22:56:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct ErrorResponse {
|
|
|
|
|
pub error: String,
|
|
|
|
|
pub details: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct LogoutResponse {
|
|
|
|
|
pub success: bool,
|
|
|
|
|
pub message: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
pub struct TwoFactorRequest {
|
|
|
|
|
pub session_token: String,
|
|
|
|
|
pub code: String,
|
|
|
|
|
pub trust_device: Option<bool>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
pub struct RefreshTokenRequest {
|
|
|
|
|
pub refresh_token: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
pub struct BootstrapAdminRequest {
|
|
|
|
|
pub bootstrap_secret: String,
|
|
|
|
|
pub email: String,
|
|
|
|
|
pub username: String,
|
|
|
|
|
pub password: String,
|
|
|
|
|
pub first_name: String,
|
|
|
|
|
pub last_name: String,
|
|
|
|
|
pub organization_name: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize)]
|
|
|
|
|
pub struct BootstrapResponse {
|
|
|
|
|
pub success: bool,
|
|
|
|
|
pub message: String,
|
|
|
|
|
pub user_id: Option<String>,
|
|
|
|
|
pub organization_id: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn configure() -> Router<Arc<AppState>> {
|
|
|
|
|
Router::new()
|
2026-01-22 19:45:18 -03:00
|
|
|
.route("/", get(crate::directory::auth_handler))
|
|
|
|
|
.route("/login", post(login))
|
|
|
|
|
.route("/logout", post(logout))
|
|
|
|
|
.route("/me", get(get_current_user))
|
|
|
|
|
.route("/refresh", post(refresh_token))
|
|
|
|
|
.route("/2fa/verify", post(verify_2fa))
|
|
|
|
|
.route("/2fa/resend", post(resend_2fa))
|
|
|
|
|
.route("/bootstrap", post(bootstrap_admin))
|
2026-01-06 22:56:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn login(
|
|
|
|
|
State(state): State<Arc<AppState>>,
|
|
|
|
|
Json(req): Json<LoginRequest>,
|
|
|
|
|
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
|
|
|
info!("Login attempt for: {}", req.email);
|
|
|
|
|
|
|
|
|
|
let client = {
|
|
|
|
|
let auth_service = state.auth_service.lock().await;
|
|
|
|
|
auth_service.client().clone()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 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");
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Authentication service not configured".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let search_url = format!("{}/v2/users", client.api_url());
|
|
|
|
|
let search_body = serde_json::json!({
|
|
|
|
|
"queries": [{
|
|
|
|
|
"emailQuery": {
|
|
|
|
|
"emailAddress": req.email,
|
|
|
|
|
"method": "TEXT_QUERY_METHOD_EQUALS"
|
|
|
|
|
}
|
|
|
|
|
}]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let user_response = http_client
|
|
|
|
|
.post(&search_url)
|
|
|
|
|
.bearer_auth(&admin_token)
|
|
|
|
|
.json(&search_body)
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
error!("Failed to search user: {}", e);
|
|
|
|
|
(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Authentication service error".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
if !user_response.status().is_success() {
|
|
|
|
|
let error_text = user_response.text().await.unwrap_or_default();
|
|
|
|
|
error!("User search failed: {}", error_text);
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Invalid email or password".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: "Authentication service error".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
2026-02-04 13:47:02 -03:00
|
|
|
let user_id_str = user_data
|
2026-01-06 22:56:35 -03:00
|
|
|
.get("result")
|
|
|
|
|
.and_then(|r| r.as_array())
|
|
|
|
|
.and_then(|arr| arr.first())
|
|
|
|
|
.and_then(|u| u.get("userId"))
|
2026-02-04 13:47:02 -03:00
|
|
|
.and_then(|v| v.as_str())
|
2026-01-06 22:56:35 -03:00
|
|
|
.map(String::from);
|
|
|
|
|
|
2026-02-04 13:47:02 -03:00
|
|
|
let user_id_str = match user_id_str {
|
2026-01-06 22:56:35 -03:00
|
|
|
Some(id) => id,
|
|
|
|
|
None => {
|
|
|
|
|
error!("User not found: {}", req.email);
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Invalid email or password".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let session_url = format!("{}/v2/sessions", client.api_url());
|
|
|
|
|
let session_body = serde_json::json!({
|
|
|
|
|
"checks": {
|
|
|
|
|
"user": {
|
2026-02-04 13:47:02 -03:00
|
|
|
"userId": user_id_str
|
2026-01-06 22:56:35 -03:00
|
|
|
},
|
|
|
|
|
"password": {
|
|
|
|
|
"password": req.password
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let session_response = http_client
|
|
|
|
|
.post(&session_url)
|
|
|
|
|
.bearer_auth(&admin_token)
|
|
|
|
|
.json(&session_body)
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
error!("Failed to create session: {}", e);
|
|
|
|
|
(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Authentication failed".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
if !session_response.status().is_success() {
|
|
|
|
|
let status = session_response.status();
|
|
|
|
|
let error_text = session_response.text().await.unwrap_or_default();
|
|
|
|
|
error!("Session creation failed: {} - {}", status, error_text);
|
|
|
|
|
|
|
|
|
|
if error_text.contains("password") || error_text.contains("invalid") {
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Invalid email or password".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Authentication failed".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: "Invalid response from authentication server".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let session_id = session_data
|
|
|
|
|
.get("sessionId")
|
|
|
|
|
.and_then(|s| s.as_str())
|
|
|
|
|
.map(String::from);
|
|
|
|
|
|
|
|
|
|
let session_token = session_data
|
|
|
|
|
.get("sessionToken")
|
|
|
|
|
.and_then(|s| s.as_str())
|
|
|
|
|
.map(String::from);
|
|
|
|
|
|
2026-02-04 14:02:01 -03:00
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
let api_token = format!("gb_{}_{}", Uuid::new_v4(), chrono::Utc::now().timestamp());
|
2026-02-04 13:28:37 -03:00
|
|
|
|
2026-01-10 17:31:50 -03:00
|
|
|
let session_user = SessionUserData {
|
2026-02-04 13:47:02 -03:00
|
|
|
user_id: user_id_str.clone(),
|
2026-01-10 17:31:50 -03:00
|
|
|
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;
|
2026-02-04 14:02:01 -03:00
|
|
|
cache.insert(api_token.clone(), session_user.clone());
|
|
|
|
|
info!("Session cached for user: {} with token: {}...", req.email, &api_token[..std::cmp::min(20, api_token.len())]);
|
2026-01-10 17:31:50 -03:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 13:47:02 -03:00
|
|
|
info!("Login successful for: {} (user_id: {})", req.email, user_id_str);
|
2026-01-06 22:56:35 -03:00
|
|
|
|
|
|
|
|
Ok(Json(LoginResponse {
|
|
|
|
|
success: true,
|
2026-02-04 13:47:02 -03:00
|
|
|
user_id: Some(user_id_str),
|
2026-01-06 22:56:35 -03:00
|
|
|
session_id: session_id.clone(),
|
2026-02-04 14:02:01 -03:00
|
|
|
access_token: Some(api_token),
|
2026-01-06 22:56:35 -03:00
|
|
|
refresh_token: None,
|
|
|
|
|
expires_in: Some(3600),
|
|
|
|
|
requires_2fa: false,
|
|
|
|
|
session_token,
|
|
|
|
|
redirect: Some("/".to_string()),
|
|
|
|
|
message: Some("Login successful".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn logout(
|
|
|
|
|
State(_state): State<Arc<AppState>>,
|
|
|
|
|
headers: axum::http::HeaderMap,
|
|
|
|
|
) -> Result<Json<LogoutResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
|
|
|
let token = headers
|
|
|
|
|
.get(header::AUTHORIZATION)
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.and_then(|auth| auth.strip_prefix("Bearer "))
|
|
|
|
|
.map(String::from);
|
|
|
|
|
|
2026-01-10 17:31:50 -03:00
|
|
|
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)");
|
|
|
|
|
}
|
2026-01-06 22:56:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(Json(LogoutResponse {
|
|
|
|
|
success: true,
|
|
|
|
|
message: "Logged out successfully".to_string(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_current_user(
|
2026-02-04 14:10:05 -03:00
|
|
|
State(_state): State<Arc<AppState>>,
|
2026-01-06 22:56:35 -03:00
|
|
|
headers: axum::http::HeaderMap,
|
2026-02-04 14:10:05 -03:00
|
|
|
) -> Json<CurrentUserResponse> {
|
2026-01-06 22:56:35 -03:00
|
|
|
let session_token = headers
|
|
|
|
|
.get(header::AUTHORIZATION)
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
2026-02-19 19:42:41 +00:00
|
|
|
.and_then(|auth| auth.strip_prefix("Bearer "))
|
|
|
|
|
.filter(|token| !token.is_empty());
|
2026-01-06 22:56:35 -03:00
|
|
|
|
2026-02-04 14:10:05 -03:00
|
|
|
match session_token {
|
|
|
|
|
None => {
|
|
|
|
|
info!("get_current_user: no authorization header - returning anonymous user");
|
|
|
|
|
Json(CurrentUserResponse {
|
|
|
|
|
id: None,
|
|
|
|
|
username: None,
|
|
|
|
|
email: None,
|
|
|
|
|
first_name: None,
|
|
|
|
|
last_name: None,
|
|
|
|
|
display_name: None,
|
|
|
|
|
roles: None,
|
|
|
|
|
organization_id: None,
|
|
|
|
|
avatar_url: None,
|
|
|
|
|
is_anonymous: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
Some(session_token) => {
|
|
|
|
|
info!("get_current_user: looking up session token (len={}, prefix={}...)",
|
|
|
|
|
session_token.len(),
|
|
|
|
|
&session_token[..std::cmp::min(20, session_token.len())]);
|
|
|
|
|
|
|
|
|
|
let cache = SESSION_CACHE.read().await;
|
|
|
|
|
|
|
|
|
|
if let Some(user_data) = cache.get(session_token) {
|
|
|
|
|
info!("get_current_user: found cached session for user: {}", user_data.email);
|
|
|
|
|
Json(CurrentUserResponse {
|
|
|
|
|
id: Some(user_data.user_id.clone()),
|
|
|
|
|
username: Some(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: Some(user_data.roles.clone()),
|
|
|
|
|
organization_id: user_data.organization_id.clone(),
|
|
|
|
|
avatar_url: None,
|
|
|
|
|
is_anonymous: false,
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
info!("get_current_user: session not found in cache - returning anonymous user");
|
|
|
|
|
Json(CurrentUserResponse {
|
|
|
|
|
id: None,
|
|
|
|
|
username: None,
|
|
|
|
|
email: None,
|
|
|
|
|
first_name: None,
|
|
|
|
|
last_name: None,
|
|
|
|
|
display_name: None,
|
|
|
|
|
roles: None,
|
|
|
|
|
organization_id: None,
|
|
|
|
|
avatar_url: None,
|
|
|
|
|
is_anonymous: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-06 22:56:35 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn refresh_token(
|
|
|
|
|
State(state): State<Arc<AppState>>,
|
|
|
|
|
Json(req): Json<RefreshTokenRequest>,
|
|
|
|
|
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
|
|
|
let client = {
|
|
|
|
|
let auth_service = state.auth_service.lock().await;
|
|
|
|
|
auth_service.client().clone()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let token_url = format!("{}/oauth/v2/token", client.api_url());
|
|
|
|
|
|
|
|
|
|
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 params = [
|
|
|
|
|
("grant_type", "refresh_token"),
|
|
|
|
|
("refresh_token", &req.refresh_token),
|
|
|
|
|
("scope", "openid profile email offline_access"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let response = http_client
|
|
|
|
|
.post(&token_url)
|
|
|
|
|
.form(¶ms)
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
error!("Failed to refresh token: {}", e);
|
|
|
|
|
(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Token refresh failed".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Invalid or expired refresh token".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let token_data: serde_json::Value = response.json().await.map_err(|e| {
|
|
|
|
|
error!("Failed to parse token response: {}", e);
|
|
|
|
|
(
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Invalid response from authentication server".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let access_token = token_data
|
|
|
|
|
.get("access_token")
|
|
|
|
|
.and_then(|t| t.as_str())
|
|
|
|
|
.map(String::from);
|
|
|
|
|
|
|
|
|
|
let refresh_token = token_data
|
|
|
|
|
.get("refresh_token")
|
|
|
|
|
.and_then(|t| t.as_str())
|
|
|
|
|
.map(String::from);
|
|
|
|
|
|
|
|
|
|
let expires_in = token_data.get("expires_in").and_then(|t| t.as_i64());
|
|
|
|
|
|
|
|
|
|
Ok(Json(LoginResponse {
|
|
|
|
|
success: true,
|
|
|
|
|
user_id: None,
|
|
|
|
|
session_id: None,
|
|
|
|
|
access_token,
|
|
|
|
|
refresh_token,
|
|
|
|
|
expires_in,
|
|
|
|
|
requires_2fa: false,
|
|
|
|
|
session_token: None,
|
|
|
|
|
redirect: None,
|
|
|
|
|
message: Some("Token refreshed successfully".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn verify_2fa(
|
|
|
|
|
State(_state): State<Arc<AppState>>,
|
|
|
|
|
Json(req): Json<TwoFactorRequest>,
|
|
|
|
|
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
|
|
|
info!(
|
|
|
|
|
"2FA verification attempt for session: {}",
|
|
|
|
|
req.session_token
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Err((
|
|
|
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "2FA verification not yet implemented".to_string(),
|
|
|
|
|
details: Some("This feature will be available in a future update".to_string()),
|
|
|
|
|
}),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn resend_2fa(
|
|
|
|
|
State(_state): State<Arc<AppState>>,
|
|
|
|
|
Json(_req): Json<serde_json::Value>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
(
|
|
|
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "2FA resend not yet implemented".to_string(),
|
|
|
|
|
details: Some("This feature will be available in a future update".to_string()),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn bootstrap_admin(
|
|
|
|
|
State(state): State<Arc<AppState>>,
|
|
|
|
|
Json(req): Json<BootstrapAdminRequest>,
|
|
|
|
|
) -> Result<Json<BootstrapResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
|
|
|
info!("Bootstrap admin request received");
|
|
|
|
|
|
|
|
|
|
let expected_secret = std::env::var(BOOTSTRAP_SECRET_ENV).unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
if expected_secret.is_empty() {
|
|
|
|
|
warn!("Bootstrap endpoint called but GB_BOOTSTRAP_SECRET not set");
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Bootstrap not enabled".to_string(),
|
|
|
|
|
details: Some("Set GB_BOOTSTRAP_SECRET environment variable to enable bootstrap".to_string()),
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if req.bootstrap_secret != expected_secret {
|
|
|
|
|
warn!("Bootstrap attempt with invalid secret");
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Invalid bootstrap secret".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let client = {
|
|
|
|
|
let auth_service = state.auth_service.lock().await;
|
|
|
|
|
auth_service.client().clone()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let existing_users = client.list_users(1, 0).await.unwrap_or_default();
|
|
|
|
|
if !existing_users.is_empty() {
|
|
|
|
|
let has_admin = existing_users.iter().any(|u| {
|
|
|
|
|
u.get("roles")
|
|
|
|
|
.and_then(|r| r.as_array())
|
|
|
|
|
.map(|roles| {
|
|
|
|
|
roles.iter().any(|r| {
|
|
|
|
|
r.as_str()
|
|
|
|
|
.map(|s| s.to_lowercase().contains("admin"))
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if has_admin {
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::CONFLICT,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Admin user already exists".to_string(),
|
|
|
|
|
details: Some("Bootstrap can only be used for initial setup".to_string()),
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 13:47:02 -03:00
|
|
|
let new_user_id = match client
|
2026-01-06 22:56:35 -03:00
|
|
|
.create_user(&req.email, &req.first_name, &req.last_name, Some(&req.username))
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(id) => {
|
|
|
|
|
info!("Bootstrap admin user created: {}", id);
|
|
|
|
|
id
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
error!("Failed to create bootstrap admin: {}", e);
|
|
|
|
|
return Err((
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
Json(ErrorResponse {
|
|
|
|
|
error: "Failed to create admin user".to_string(),
|
|
|
|
|
details: Some(e.to_string()),
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-04 13:47:02 -03:00
|
|
|
if let Err(e) = set_user_password(&client, &new_user_id, &req.password).await {
|
2026-01-06 22:56:35 -03:00
|
|
|
error!("Failed to set admin password: {}", e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let org_name = req.organization_name.unwrap_or_else(|| "Default Organization".to_string());
|
2026-02-04 13:47:02 -03:00
|
|
|
let new_org_id = match create_organization(&client, &org_name).await {
|
2026-01-06 22:56:35 -03:00
|
|
|
Ok(id) => {
|
|
|
|
|
info!("Bootstrap organization created: {}", id);
|
|
|
|
|
Some(id)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!("Failed to create organization (may already exist): {}", e);
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-04 13:47:02 -03:00
|
|
|
if let Some(ref oid) = new_org_id {
|
2026-01-06 22:56:35 -03:00
|
|
|
let admin_roles = vec![
|
|
|
|
|
"admin".to_string(),
|
|
|
|
|
"org_owner".to_string(),
|
|
|
|
|
"user_manager".to_string(),
|
|
|
|
|
];
|
2026-02-04 13:47:02 -03:00
|
|
|
if let Err(e) = client.add_org_member(oid, &new_user_id, admin_roles).await {
|
2026-01-06 22:56:35 -03:00
|
|
|
error!("Failed to add admin to organization: {}", e);
|
|
|
|
|
} else {
|
|
|
|
|
info!("Admin user added to organization with admin roles");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
info!(
|
|
|
|
|
"Bootstrap complete: admin user {} created successfully",
|
|
|
|
|
req.username
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(Json(BootstrapResponse {
|
|
|
|
|
success: true,
|
|
|
|
|
message: format!(
|
|
|
|
|
"Admin user '{}' created successfully. You can now login with your credentials.",
|
|
|
|
|
req.username
|
|
|
|
|
),
|
2026-02-04 13:47:02 -03:00
|
|
|
user_id: Some(new_user_id),
|
|
|
|
|
organization_id: new_org_id,
|
2026-01-06 22:56:35 -03:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn set_user_password(
|
|
|
|
|
client: &crate::directory::client::ZitadelClient,
|
|
|
|
|
user_id: &str,
|
|
|
|
|
password: &str,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let url = format!("{}/v2/users/{}/password", client.api_url(), user_id);
|
|
|
|
|
|
|
|
|
|
let body = serde_json::json!({
|
|
|
|
|
"newPassword": {
|
|
|
|
|
"password": password,
|
|
|
|
|
"changeRequired": false
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let response = client
|
|
|
|
|
.http_post(url)
|
|
|
|
|
.await
|
|
|
|
|
.json(&body)
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
|
|
|
|
|
|
if response.status().is_success() {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
let error_text = response.text().await.unwrap_or_default();
|
|
|
|
|
Err(format!("Failed to set password: {}", error_text))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn create_organization(
|
|
|
|
|
client: &crate::directory::client::ZitadelClient,
|
|
|
|
|
name: &str,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
let url = format!("{}/v2/organizations", client.api_url());
|
|
|
|
|
|
|
|
|
|
let body = serde_json::json!({
|
|
|
|
|
"name": name
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let response = client
|
|
|
|
|
.http_post(url)
|
|
|
|
|
.await
|
|
|
|
|
.json(&body)
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
|
|
|
|
|
|
if response.status().is_success() {
|
|
|
|
|
let data: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
|
|
|
|
let org_id = data
|
|
|
|
|
.get("organizationId")
|
|
|
|
|
.or_else(|| data.get("id"))
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.ok_or_else(|| "No organization ID in response".to_string())?
|
|
|
|
|
.to_string();
|
|
|
|
|
Ok(org_id)
|
|
|
|
|
} else {
|
|
|
|
|
let error_text = response.text().await.unwrap_or_default();
|
|
|
|
|
Err(format!("Failed to create organization: {}", error_text))
|
|
|
|
|
}
|
|
|
|
|
}
|