mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-28 00:58:59 +00:00
425 lines
14 KiB
Rust
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);
|
|
}
|
|
}
|