Files
rustfs/crates/keystone/src/client.rs
T

425 lines
14 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{EC2Credential, KeystoneError, KeystoneToken, KeystoneVersion, Result};
use reqwest::{Client, StatusCode};
use serde_json::json;
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
/// Keystone client for API interactions
#[derive(Clone)]
pub struct KeystoneClient {
client: Client,
auth_url: String,
version: KeystoneVersion,
admin_token: Arc<RwLock<Option<AdminToken>>>,
admin_user: Option<String>,
admin_password: Option<String>,
admin_project: Option<String>,
admin_domain: String,
#[allow(dead_code)]
verify_ssl: bool,
/// Request timeout applied to the underlying HTTP client.
timeout: std::time::Duration,
}
#[derive(Clone)]
struct AdminToken {
token: String,
expires_at: OffsetDateTime,
}
impl AdminToken {
fn is_expired(&self) -> bool {
OffsetDateTime::now_utc() >= self.expires_at
}
}
impl KeystoneClient {
/// Create new Keystone client
#[allow(clippy::too_many_arguments)]
pub fn new(
auth_url: String,
version: KeystoneVersion,
admin_user: Option<String>,
admin_password: Option<String>,
admin_project: Option<String>,
admin_domain: String,
verify_ssl: bool,
timeout: std::time::Duration,
) -> Self {
if !verify_ssl {
warn!(
"Keystone client for '{}' is configured to skip TLS certificate verification. This permits MITM attacks and should not be used in production.",
auth_url
);
}
let client = Client::builder()
.danger_accept_invalid_certs(!verify_ssl)
.timeout(timeout)
.build()
.unwrap();
Self {
client,
auth_url,
version,
admin_token: Arc::new(RwLock::new(None)),
admin_user,
admin_password,
admin_project,
admin_domain,
verify_ssl,
timeout,
}
}
/// Request timeout applied to the underlying HTTP client.
pub fn timeout(&self) -> std::time::Duration {
self.timeout
}
/// Validate a Keystone token
pub async fn validate_token(&self, token: &str) -> Result<KeystoneToken> {
match self.version {
KeystoneVersion::V3 => self.validate_token_v3(token).await,
KeystoneVersion::V2_0 => self.validate_token_v2(token).await,
}
}
/// Validate token using Keystone v3 API
async fn validate_token_v3(&self, token: &str) -> Result<KeystoneToken> {
let url = format!("{}/v3/auth/tokens", self.auth_url);
debug!("Validating token with Keystone v3: {}", url);
let response = self
.client
.get(&url)
.header("X-Auth-Token", token)
.header("X-Subject-Token", token)
.send()
.await
.map_err(|e| {
error!("Failed to send token validation request: {}", e);
KeystoneError::HttpError(e.to_string())
})?;
let status = response.status();
debug!("Token validation response status: {}", status);
if status == StatusCode::NOT_FOUND || status == StatusCode::UNAUTHORIZED {
return Err(KeystoneError::InvalidToken);
}
if !status.is_success() {
return Err(KeystoneError::AuthenticationFailed(format!(
"Token validation failed with status: {}",
status
)));
}
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?;
self.parse_token_v3(&body)
}
fn parse_token_v3(&self, body: &serde_json::Value) -> Result<KeystoneToken> {
let token_data = body
.get("token")
.ok_or_else(|| KeystoneError::ParseError("Missing token field".to_string()))?;
let user = token_data
.get("user")
.ok_or_else(|| KeystoneError::ParseError("Missing user field".to_string()))?;
let user_id = user
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| KeystoneError::ParseError("Missing user id".to_string()))?
.to_string();
let username = user.get("name").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
let project = token_data.get("project");
let (project_id, project_name) = if let Some(proj) = project {
(
proj.get("id").and_then(|v| v.as_str()).map(String::from),
proj.get("name").and_then(|v| v.as_str()).map(String::from),
)
} else {
(None, None)
};
let domain = user.get("domain");
let (domain_id, domain_name) = if let Some(dom) = domain {
(
dom.get("id").and_then(|v| v.as_str()).map(String::from),
dom.get("name").and_then(|v| v.as_str()).map(String::from),
)
} else {
(None, None)
};
let roles = token_data
.get("roles")
.and_then(|v| v.as_array())
.map(|roles| {
roles
.iter()
.filter_map(|r| r.get("name").and_then(|n| n.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
let expires_at = token_data
.get("expires_at")
.and_then(|v| v.as_str())
.and_then(|s| OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok())
.ok_or_else(|| KeystoneError::ParseError("Invalid expires_at".to_string()))?;
let issued_at = token_data
.get("issued_at")
.and_then(|v| v.as_str())
.and_then(|s| OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok())
.unwrap_or_else(OffsetDateTime::now_utc);
Ok(KeystoneToken {
token: String::new(),
user_id,
username,
project_id,
project_name,
domain_id,
domain_name,
roles,
expires_at,
issued_at,
})
}
async fn validate_token_v2(&self, _token: &str) -> Result<KeystoneToken> {
warn!("Keystone v2.0 support is deprecated");
Err(KeystoneError::UnsupportedVersion)
}
/// Validate EC2 credentials
///
/// This path is intentionally not implemented and fails closed. A correct
/// implementation must derive the identity (user, project, roles, expiry)
/// from the Keystone `/v3/ec2tokens` response, not from client-supplied
/// input. The previous implementation discarded the response body and
/// fabricated an identity by splitting the client access key on `:`, which
/// let a caller shape their own user_id/project_id. Until the response
/// schema is modeled against a live server, refuse to produce credentials.
pub async fn validate_ec2_credentials(
&self,
_access_key: &str,
_signature: &str,
_string_to_sign: &str,
) -> Result<EC2Credential> {
warn!("EC2/SigV4 credential authentication is not supported; refusing to fabricate identity from client input");
Err(KeystoneError::Ec2AuthUnsupported)
}
/// Get EC2 credentials for a user
pub async fn get_ec2_credentials(&self, user_id: &str, project_id: Option<&str>) -> Result<Vec<EC2Credential>> {
let admin_token = self.get_admin_token().await?;
let url = if let Some(proj_id) = project_id {
format!("{}/v3/users/{}/credentials/OS-EC2?project_id={}", self.auth_url, user_id, proj_id)
} else {
format!("{}/v3/users/{}/credentials/OS-EC2", self.auth_url, user_id)
};
debug!("Fetching EC2 credentials for user: {}", user_id);
let response = self
.client
.get(&url)
.header("X-Auth-Token", admin_token)
.send()
.await
.map_err(|e| KeystoneError::HttpError(e.to_string()))?;
if !response.status().is_success() {
return Ok(vec![]);
}
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?;
let credentials = body
.get("credentials")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|cred| self.parse_ec2_credential(cred).ok()).collect())
.unwrap_or_default();
Ok(credentials)
}
fn parse_ec2_credential(&self, cred: &serde_json::Value) -> Result<EC2Credential> {
let access = cred
.get("access")
.and_then(|v| v.as_str())
.ok_or_else(|| KeystoneError::ParseError("Missing access key".to_string()))?
.to_string();
let secret = cred
.get("secret")
.and_then(|v| v.as_str())
.ok_or_else(|| KeystoneError::ParseError("Missing secret key".to_string()))?
.to_string();
let user_id = cred
.get("user_id")
.and_then(|v| v.as_str())
.ok_or_else(|| KeystoneError::ParseError("Missing user_id".to_string()))?
.to_string();
let project_id = cred.get("project_id").and_then(|v| v.as_str()).map(String::from);
let trust_id = cred.get("trust_id").and_then(|v| v.as_str()).map(String::from);
Ok(EC2Credential {
access,
secret,
user_id,
project_id,
trust_id,
})
}
/// Get admin token for privileged operations
async fn get_admin_token(&self) -> Result<String> {
// Check if we have a valid cached token
{
let guard = self.admin_token.read().await;
if let Some(token) = guard.as_ref()
&& !token.is_expired()
{
return Ok(token.token.clone());
}
}
// Need to authenticate as admin
let admin_user = self
.admin_user
.as_ref()
.ok_or_else(|| KeystoneError::ConfigError("Missing admin user".to_string()))?;
let admin_password = self
.admin_password
.as_ref()
.ok_or_else(|| KeystoneError::ConfigError("Missing admin password".to_string()))?;
let url = format!("{}/v3/auth/tokens", self.auth_url);
debug!("Authenticating as admin user: {}", admin_user);
let mut auth_payload = json!({
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": admin_user,
"password": admin_password,
"domain": {"name": self.admin_domain}
}
}
}
}
});
if let Some(proj) = &self.admin_project {
auth_payload["auth"]["scope"] = json!({
"project": {
"name": proj,
"domain": {"name": self.admin_domain}
}
});
}
let response = self
.client
.post(&url)
.json(&auth_payload)
.send()
.await
.map_err(|e| KeystoneError::HttpError(e.to_string()))?;
if !response.status().is_success() {
return Err(KeystoneError::AuthenticationFailed("Admin authentication failed".to_string()));
}
let token = response
.headers()
.get("X-Subject-Token")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| KeystoneError::ParseError("Missing X-Subject-Token header".to_string()))?
.to_string();
// Parse expiration from response body
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?;
let expires_at = body
.get("token")
.and_then(|t| t.get("expires_at"))
.and_then(|v| v.as_str())
.and_then(|s| OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok())
.unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::hours(1));
// Cache the token
let mut guard = self.admin_token.write().await;
*guard = Some(AdminToken {
token: token.clone(),
expires_at,
});
info!("Admin token obtained successfully");
Ok(token)
}
/// Clear cached admin token
pub async fn clear_admin_token(&self) {
let mut guard = self.admin_token.write().await;
*guard = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = KeystoneClient::new(
"http://keystone:5000".to_string(),
KeystoneVersion::V3,
Some("admin".to_string()),
Some("secret".to_string()),
Some("admin".to_string()),
"Default".to_string(),
true,
std::time::Duration::from_secs(30),
);
assert_eq!(client.auth_url, "http://keystone:5000");
assert_eq!(client.version, KeystoneVersion::V3);
}
}