- Add comprehensive documentation in botbook/ with 12 chapters - Add botapp/ Tauri desktop application - Add botdevice/ IoT device support - Add botlib/ shared library crate - Add botmodels/ Python ML models service - Add botplugin/ browser extension - Add botserver/ reorganized server code - Add bottemplates/ bot templates - Add bottest/ integration tests - Add botui/ web UI server - Add CI/CD workflows in .forgejo/workflows/ - Add AGENTS.md and PROD.md documentation - Add dependency management scripts (DEPENDENCIES.sh/ps1) - Remove legacy src/ structure and migrations - Clean up temporary and backup files
1006 lines
29 KiB
Rust
1006 lines
29 KiB
Rust
use anyhow::{anyhow, Result};
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use rand::Rng;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use tracing::info;
|
|
use uuid::Uuid;
|
|
|
|
const API_KEY_PREFIX: &str = "gb_";
|
|
const API_KEY_LENGTH: usize = 32;
|
|
const API_KEY_HASH_ITERATIONS: u32 = 100_000;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ApiKeyConfig {
|
|
pub default_rate_limit_per_minute: u32,
|
|
pub default_rate_limit_per_hour: u32,
|
|
pub default_rate_limit_per_day: u32,
|
|
pub max_keys_per_user: usize,
|
|
pub key_expiry_days: Option<u32>,
|
|
pub allow_key_rotation: bool,
|
|
pub rotation_grace_period_hours: u32,
|
|
}
|
|
|
|
impl Default for ApiKeyConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
default_rate_limit_per_minute: 60,
|
|
default_rate_limit_per_hour: 1000,
|
|
default_rate_limit_per_day: 10000,
|
|
max_keys_per_user: 10,
|
|
key_expiry_days: Some(365),
|
|
allow_key_rotation: true,
|
|
rotation_grace_period_hours: 24,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum ApiKeyScope {
|
|
Read,
|
|
Write,
|
|
Delete,
|
|
Admin,
|
|
Bot(Uuid),
|
|
Resource(String),
|
|
Custom(String),
|
|
}
|
|
|
|
impl ApiKeyScope {
|
|
pub fn as_str(&self) -> String {
|
|
match self {
|
|
Self::Read => "read".into(),
|
|
Self::Write => "write".into(),
|
|
Self::Delete => "delete".into(),
|
|
Self::Admin => "admin".into(),
|
|
Self::Bot(id) => format!("bot:{id}"),
|
|
Self::Resource(r) => format!("resource:{r}"),
|
|
Self::Custom(c) => format!("custom:{c}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for ApiKeyScope {
|
|
type Err = ();
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"read" => Ok(Self::Read),
|
|
"write" => Ok(Self::Write),
|
|
"delete" => Ok(Self::Delete),
|
|
"admin" => Ok(Self::Admin),
|
|
s if s.starts_with("bot:") => {
|
|
let id = s.strip_prefix("bot:").ok_or(())?;
|
|
Uuid::parse_str(id).map_err(|_| ()).map(Self::Bot)
|
|
}
|
|
s if s.starts_with("resource:") => {
|
|
let r = s.strip_prefix("resource:").ok_or(())?;
|
|
Ok(Self::Resource(r.to_string()))
|
|
}
|
|
s if s.starts_with("custom:") => {
|
|
let c = s.strip_prefix("custom:").ok_or(())?;
|
|
Ok(Self::Custom(c.to_string()))
|
|
}
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ApiKeyScope {
|
|
pub fn includes(&self, other: &Self) -> bool {
|
|
if self == &Self::Admin {
|
|
return true;
|
|
}
|
|
if self == other {
|
|
return true;
|
|
}
|
|
if self == &Self::Write && other == &Self::Read {
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ApiKeyStatus {
|
|
Active,
|
|
Expired,
|
|
Revoked,
|
|
Rotating,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RateLimitConfig {
|
|
pub requests_per_minute: u32,
|
|
pub requests_per_hour: u32,
|
|
pub requests_per_day: u32,
|
|
}
|
|
|
|
impl Default for RateLimitConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
requests_per_minute: 60,
|
|
requests_per_hour: 1000,
|
|
requests_per_day: 10000,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ApiKey {
|
|
pub id: Uuid,
|
|
pub user_id: Uuid,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub key_hash: String,
|
|
pub key_prefix: String,
|
|
pub scopes: HashSet<ApiKeyScope>,
|
|
pub status: ApiKeyStatus,
|
|
pub rate_limits: RateLimitConfig,
|
|
pub allowed_ips: Option<Vec<String>>,
|
|
pub allowed_origins: Option<Vec<String>>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub expires_at: Option<DateTime<Utc>>,
|
|
pub last_used_at: Option<DateTime<Utc>>,
|
|
pub rotated_from: Option<Uuid>,
|
|
pub rotation_deadline: Option<DateTime<Utc>>,
|
|
pub metadata: HashMap<String, String>,
|
|
}
|
|
|
|
impl ApiKey {
|
|
pub fn is_valid(&self) -> bool {
|
|
if self.status != ApiKeyStatus::Active && self.status != ApiKeyStatus::Rotating {
|
|
return false;
|
|
}
|
|
|
|
if let Some(expires) = self.expires_at {
|
|
if Utc::now() > expires {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if self.status == ApiKeyStatus::Rotating {
|
|
if let Some(deadline) = self.rotation_deadline {
|
|
if Utc::now() > deadline {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
pub fn has_scope(&self, scope: &ApiKeyScope) -> bool {
|
|
if self.scopes.contains(&ApiKeyScope::Admin) {
|
|
return true;
|
|
}
|
|
|
|
for s in &self.scopes {
|
|
if s.includes(scope) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
pub fn has_any_scope(&self, scopes: &[ApiKeyScope]) -> bool {
|
|
scopes.iter().any(|s| self.has_scope(s))
|
|
}
|
|
|
|
pub fn has_all_scopes(&self, scopes: &[ApiKeyScope]) -> bool {
|
|
scopes.iter().all(|s| self.has_scope(s))
|
|
}
|
|
|
|
pub fn is_ip_allowed(&self, ip: &str) -> bool {
|
|
match &self.allowed_ips {
|
|
Some(ips) if !ips.is_empty() => ips.iter().any(|allowed| {
|
|
if allowed.contains('/') {
|
|
matches_cidr(ip, allowed)
|
|
} else {
|
|
allowed == ip
|
|
}
|
|
}),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
pub fn is_origin_allowed(&self, origin: &str) -> bool {
|
|
match &self.allowed_origins {
|
|
Some(origins) if !origins.is_empty() => origins.iter().any(|allowed| {
|
|
if allowed == "*" {
|
|
true
|
|
} else if allowed.starts_with("*.") {
|
|
let suffix = allowed.strip_prefix("*").unwrap_or(allowed);
|
|
origin.ends_with(suffix)
|
|
} else {
|
|
allowed == origin
|
|
}
|
|
}),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
pub fn time_until_expiry(&self) -> Option<Duration> {
|
|
self.expires_at.map(|e| e - Utc::now())
|
|
}
|
|
|
|
pub fn is_expiring_soon(&self, days: i64) -> bool {
|
|
if let Some(remaining) = self.time_until_expiry() {
|
|
remaining < Duration::days(days)
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CreateApiKeyRequest {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub scopes: Vec<ApiKeyScope>,
|
|
pub expires_in_days: Option<u32>,
|
|
pub rate_limits: Option<RateLimitConfig>,
|
|
pub allowed_ips: Option<Vec<String>>,
|
|
pub allowed_origins: Option<Vec<String>>,
|
|
pub metadata: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CreateApiKeyResponse {
|
|
pub key: ApiKey,
|
|
pub secret: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ApiKeyUsage {
|
|
pub key_id: Uuid,
|
|
pub requests_this_minute: u32,
|
|
pub requests_this_hour: u32,
|
|
pub requests_this_day: u32,
|
|
pub minute_reset_at: DateTime<Utc>,
|
|
pub hour_reset_at: DateTime<Utc>,
|
|
pub day_reset_at: DateTime<Utc>,
|
|
pub total_requests: u64,
|
|
pub last_request_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl ApiKeyUsage {
|
|
pub fn new(key_id: Uuid) -> Self {
|
|
let now = Utc::now();
|
|
Self {
|
|
key_id,
|
|
requests_this_minute: 0,
|
|
requests_this_hour: 0,
|
|
requests_this_day: 0,
|
|
minute_reset_at: now + Duration::minutes(1),
|
|
hour_reset_at: now + Duration::hours(1),
|
|
day_reset_at: now + Duration::days(1),
|
|
total_requests: 0,
|
|
last_request_at: None,
|
|
}
|
|
}
|
|
|
|
pub fn record_request(&mut self) {
|
|
let now = Utc::now();
|
|
|
|
if now >= self.minute_reset_at {
|
|
self.requests_this_minute = 0;
|
|
self.minute_reset_at = now + Duration::minutes(1);
|
|
}
|
|
if now >= self.hour_reset_at {
|
|
self.requests_this_hour = 0;
|
|
self.hour_reset_at = now + Duration::hours(1);
|
|
}
|
|
if now >= self.day_reset_at {
|
|
self.requests_this_day = 0;
|
|
self.day_reset_at = now + Duration::days(1);
|
|
}
|
|
|
|
self.requests_this_minute += 1;
|
|
self.requests_this_hour += 1;
|
|
self.requests_this_day += 1;
|
|
self.total_requests += 1;
|
|
self.last_request_at = Some(now);
|
|
}
|
|
|
|
pub fn is_rate_limited(&self, limits: &RateLimitConfig) -> bool {
|
|
let now = Utc::now();
|
|
|
|
if now < self.minute_reset_at && self.requests_this_minute >= limits.requests_per_minute {
|
|
return true;
|
|
}
|
|
if now < self.hour_reset_at && self.requests_this_hour >= limits.requests_per_hour {
|
|
return true;
|
|
}
|
|
if now < self.day_reset_at && self.requests_this_day >= limits.requests_per_day {
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
pub fn time_until_reset(&self, limits: &RateLimitConfig) -> Option<Duration> {
|
|
let now = Utc::now();
|
|
|
|
if now < self.minute_reset_at && self.requests_this_minute >= limits.requests_per_minute {
|
|
return Some(self.minute_reset_at - now);
|
|
}
|
|
if now < self.hour_reset_at && self.requests_this_hour >= limits.requests_per_hour {
|
|
return Some(self.hour_reset_at - now);
|
|
}
|
|
if now < self.day_reset_at && self.requests_this_day >= limits.requests_per_day {
|
|
return Some(self.day_reset_at - now);
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|
|
|
|
pub struct ApiKeyManager {
|
|
config: ApiKeyConfig,
|
|
keys: Arc<RwLock<HashMap<Uuid, ApiKey>>>,
|
|
key_hashes: Arc<RwLock<HashMap<String, Uuid>>>,
|
|
usage: Arc<RwLock<HashMap<Uuid, ApiKeyUsage>>>,
|
|
}
|
|
|
|
impl ApiKeyManager {
|
|
pub fn new(config: ApiKeyConfig) -> Self {
|
|
Self {
|
|
config,
|
|
keys: Arc::new(RwLock::new(HashMap::new())),
|
|
key_hashes: Arc::new(RwLock::new(HashMap::new())),
|
|
usage: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
pub fn with_defaults() -> Self {
|
|
Self::new(ApiKeyConfig::default())
|
|
}
|
|
|
|
pub async fn create_key(
|
|
&self,
|
|
user_id: Uuid,
|
|
request: CreateApiKeyRequest,
|
|
) -> Result<CreateApiKeyResponse> {
|
|
let user_keys = self.get_user_keys(user_id).await;
|
|
if user_keys.len() >= self.config.max_keys_per_user {
|
|
return Err(anyhow!(
|
|
"Maximum number of API keys ({}) reached",
|
|
self.config.max_keys_per_user
|
|
));
|
|
}
|
|
|
|
let (secret, key_hash) = generate_api_key();
|
|
let key_prefix = extract_prefix(&secret);
|
|
|
|
let expires_at = request
|
|
.expires_in_days
|
|
.or(self.config.key_expiry_days)
|
|
.map(|days| Utc::now() + Duration::days(days as i64));
|
|
|
|
let rate_limits = request.rate_limits.unwrap_or(RateLimitConfig {
|
|
requests_per_minute: self.config.default_rate_limit_per_minute,
|
|
requests_per_hour: self.config.default_rate_limit_per_hour,
|
|
requests_per_day: self.config.default_rate_limit_per_day,
|
|
});
|
|
|
|
let key = ApiKey {
|
|
id: Uuid::new_v4(),
|
|
user_id,
|
|
name: request.name,
|
|
description: request.description,
|
|
key_hash: key_hash.clone(),
|
|
key_prefix,
|
|
scopes: request.scopes.into_iter().collect(),
|
|
status: ApiKeyStatus::Active,
|
|
rate_limits,
|
|
allowed_ips: request.allowed_ips,
|
|
allowed_origins: request.allowed_origins,
|
|
created_at: Utc::now(),
|
|
expires_at,
|
|
last_used_at: None,
|
|
rotated_from: None,
|
|
rotation_deadline: None,
|
|
metadata: request.metadata.unwrap_or_default(),
|
|
};
|
|
|
|
let key_id = key.id;
|
|
|
|
{
|
|
let mut keys = self.keys.write().await;
|
|
keys.insert(key_id, key.clone());
|
|
}
|
|
|
|
{
|
|
let mut hashes = self.key_hashes.write().await;
|
|
hashes.insert(key_hash, key_id);
|
|
}
|
|
|
|
info!("Created API key {} for user {}", key_id, user_id);
|
|
|
|
Ok(CreateApiKeyResponse { key, secret })
|
|
}
|
|
|
|
pub async fn validate_key(&self, secret: &str) -> Result<Option<ApiKey>> {
|
|
let key_hash = hash_api_key(secret);
|
|
|
|
let key_id = {
|
|
let hashes = self.key_hashes.read().await;
|
|
hashes.get(&key_hash).copied()
|
|
};
|
|
|
|
let key_id = match key_id {
|
|
Some(id) => id,
|
|
None => return Ok(None),
|
|
};
|
|
|
|
let key = {
|
|
let keys = self.keys.read().await;
|
|
keys.get(&key_id).cloned()
|
|
};
|
|
|
|
let key = match key {
|
|
Some(k) => k,
|
|
None => return Ok(None),
|
|
};
|
|
|
|
if !key.is_valid() {
|
|
return Ok(None);
|
|
}
|
|
|
|
{
|
|
let mut keys = self.keys.write().await;
|
|
if let Some(k) = keys.get_mut(&key_id) {
|
|
k.last_used_at = Some(Utc::now());
|
|
}
|
|
}
|
|
|
|
Ok(Some(key))
|
|
}
|
|
|
|
pub async fn validate_and_check_rate_limit(&self, secret: &str) -> Result<(ApiKey, bool)> {
|
|
let key = self
|
|
.validate_key(secret)
|
|
.await?
|
|
.ok_or_else(|| anyhow!("Invalid API key"))?;
|
|
|
|
let is_rate_limited = {
|
|
let mut usage = self.usage.write().await;
|
|
let key_usage = usage
|
|
.entry(key.id)
|
|
.or_insert_with(|| ApiKeyUsage::new(key.id));
|
|
|
|
if key_usage.is_rate_limited(&key.rate_limits) {
|
|
true
|
|
} else {
|
|
key_usage.record_request();
|
|
false
|
|
}
|
|
};
|
|
|
|
Ok((key, is_rate_limited))
|
|
}
|
|
|
|
pub async fn get_key(&self, key_id: Uuid) -> Option<ApiKey> {
|
|
let keys = self.keys.read().await;
|
|
keys.get(&key_id).cloned()
|
|
}
|
|
|
|
pub async fn get_user_keys(&self, user_id: Uuid) -> Vec<ApiKey> {
|
|
let keys = self.keys.read().await;
|
|
keys.values()
|
|
.filter(|k| k.user_id == user_id)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
pub async fn revoke_key(&self, key_id: Uuid) -> Result<bool> {
|
|
let mut keys = self.keys.write().await;
|
|
|
|
if let Some(key) = keys.get_mut(&key_id) {
|
|
key.status = ApiKeyStatus::Revoked;
|
|
info!("Revoked API key {}", key_id);
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
pub async fn revoke_all_user_keys(&self, user_id: Uuid) -> Result<usize> {
|
|
let mut keys = self.keys.write().await;
|
|
let mut revoked = 0;
|
|
|
|
for key in keys.values_mut() {
|
|
if key.user_id == user_id && key.status == ApiKeyStatus::Active {
|
|
key.status = ApiKeyStatus::Revoked;
|
|
revoked += 1;
|
|
}
|
|
}
|
|
|
|
info!("Revoked {} API keys for user {}", revoked, user_id);
|
|
Ok(revoked)
|
|
}
|
|
|
|
pub async fn rotate_key(&self, key_id: Uuid) -> Result<CreateApiKeyResponse> {
|
|
if !self.config.allow_key_rotation {
|
|
return Err(anyhow!("API key rotation is not enabled"));
|
|
}
|
|
|
|
let old_key = {
|
|
let keys = self.keys.read().await;
|
|
keys.get(&key_id).cloned()
|
|
};
|
|
|
|
let old_key = old_key.ok_or_else(|| anyhow!("API key not found"))?;
|
|
|
|
if old_key.status != ApiKeyStatus::Active {
|
|
return Err(anyhow!("Can only rotate active keys"));
|
|
}
|
|
|
|
let (secret, key_hash) = generate_api_key();
|
|
let key_prefix = extract_prefix(&secret);
|
|
let grace_period = Duration::hours(self.config.rotation_grace_period_hours as i64);
|
|
|
|
let new_key = ApiKey {
|
|
id: Uuid::new_v4(),
|
|
user_id: old_key.user_id,
|
|
name: old_key.name.clone(),
|
|
description: old_key.description.clone(),
|
|
key_hash: key_hash.clone(),
|
|
key_prefix,
|
|
scopes: old_key.scopes.clone(),
|
|
status: ApiKeyStatus::Active,
|
|
rate_limits: old_key.rate_limits.clone(),
|
|
allowed_ips: old_key.allowed_ips.clone(),
|
|
allowed_origins: old_key.allowed_origins.clone(),
|
|
created_at: Utc::now(),
|
|
expires_at: old_key.expires_at,
|
|
last_used_at: None,
|
|
rotated_from: Some(key_id),
|
|
rotation_deadline: None,
|
|
metadata: old_key.metadata.clone(),
|
|
};
|
|
|
|
let new_key_id = new_key.id;
|
|
|
|
{
|
|
let mut keys = self.keys.write().await;
|
|
|
|
if let Some(old) = keys.get_mut(&key_id) {
|
|
old.status = ApiKeyStatus::Rotating;
|
|
old.rotation_deadline = Some(Utc::now() + grace_period);
|
|
}
|
|
|
|
keys.insert(new_key_id, new_key.clone());
|
|
}
|
|
|
|
{
|
|
let mut hashes = self.key_hashes.write().await;
|
|
hashes.insert(key_hash, new_key_id);
|
|
}
|
|
|
|
info!(
|
|
"Rotated API key {} to {} (grace period: {} hours)",
|
|
key_id, new_key_id, self.config.rotation_grace_period_hours
|
|
);
|
|
|
|
Ok(CreateApiKeyResponse {
|
|
key: new_key,
|
|
secret,
|
|
})
|
|
}
|
|
|
|
pub async fn update_key_scopes(&self, key_id: Uuid, scopes: Vec<ApiKeyScope>) -> Result<bool> {
|
|
let mut keys = self.keys.write().await;
|
|
|
|
if let Some(key) = keys.get_mut(&key_id) {
|
|
key.scopes = scopes.into_iter().collect();
|
|
info!("Updated scopes for API key {}", key_id);
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
pub async fn update_key_rate_limits(
|
|
&self,
|
|
key_id: Uuid,
|
|
rate_limits: RateLimitConfig,
|
|
) -> Result<bool> {
|
|
let mut keys = self.keys.write().await;
|
|
|
|
if let Some(key) = keys.get_mut(&key_id) {
|
|
key.rate_limits = rate_limits;
|
|
info!("Updated rate limits for API key {}", key_id);
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
pub async fn get_key_usage(&self, key_id: Uuid) -> Option<ApiKeyUsage> {
|
|
let usage = self.usage.read().await;
|
|
usage.get(&key_id).cloned()
|
|
}
|
|
|
|
pub async fn cleanup_expired_keys(&self) -> Result<usize> {
|
|
let mut keys = self.keys.write().await;
|
|
let mut hashes = self.key_hashes.write().await;
|
|
|
|
let expired_ids: Vec<Uuid> = keys
|
|
.iter()
|
|
.filter(|(_, k)| {
|
|
if let Some(expires) = k.expires_at {
|
|
Utc::now() > expires
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
.map(|(id, _)| *id)
|
|
.collect();
|
|
|
|
for id in &expired_ids {
|
|
if let Some(key) = keys.remove(id) {
|
|
hashes.remove(&key.key_hash);
|
|
}
|
|
}
|
|
|
|
if !expired_ids.is_empty() {
|
|
info!("Cleaned up {} expired API keys", expired_ids.len());
|
|
}
|
|
|
|
Ok(expired_ids.len())
|
|
}
|
|
|
|
pub async fn cleanup_rotation_grace_periods(&self) -> Result<usize> {
|
|
let mut keys = self.keys.write().await;
|
|
let mut count = 0;
|
|
|
|
for key in keys.values_mut() {
|
|
if key.status == ApiKeyStatus::Rotating {
|
|
if let Some(deadline) = key.rotation_deadline {
|
|
if Utc::now() > deadline {
|
|
key.status = ApiKeyStatus::Revoked;
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if count > 0 {
|
|
info!(
|
|
"Expired {} API keys past rotation grace period",
|
|
count
|
|
);
|
|
}
|
|
|
|
Ok(count)
|
|
}
|
|
|
|
pub async fn get_expiring_keys(&self, days: i64) -> Vec<ApiKey> {
|
|
let keys = self.keys.read().await;
|
|
keys.values()
|
|
.filter(|k| k.status == ApiKeyStatus::Active && k.is_expiring_soon(days))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
pub fn config(&self) -> &ApiKeyConfig {
|
|
&self.config
|
|
}
|
|
}
|
|
|
|
fn generate_api_key() -> (String, String) {
|
|
let mut rng = rand::rng();
|
|
|
|
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
|
let random_part: String = (0..API_KEY_LENGTH)
|
|
.map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
|
|
.collect();
|
|
|
|
let secret = format!("{API_KEY_PREFIX}{random_part}");
|
|
let hash = hash_api_key(&secret);
|
|
|
|
(secret, hash)
|
|
}
|
|
|
|
fn hash_api_key(key: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(key.as_bytes());
|
|
|
|
for _ in 0..API_KEY_HASH_ITERATIONS {
|
|
let result = hasher.finalize_reset();
|
|
hasher.update(result);
|
|
}
|
|
|
|
let result = hasher.finalize();
|
|
hex::encode(result)
|
|
}
|
|
|
|
fn extract_prefix(key: &str) -> String {
|
|
let stripped = key.strip_prefix(API_KEY_PREFIX).unwrap_or(key);
|
|
format!("{API_KEY_PREFIX}{}...", &stripped[..8.min(stripped.len())])
|
|
}
|
|
|
|
fn matches_cidr(ip: &str, cidr: &str) -> bool {
|
|
let parts: Vec<&str> = cidr.split('/').collect();
|
|
if parts.len() != 2 {
|
|
return false;
|
|
}
|
|
|
|
let network = parts[0];
|
|
let prefix_len: u8 = match parts[1].parse() {
|
|
Ok(p) => p,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
let ip_octets: Vec<u8> = match ip
|
|
.split('.')
|
|
.map(|s| s.parse())
|
|
.collect::<Result<Vec<u8>, _>>()
|
|
{
|
|
Ok(o) if o.len() == 4 => o,
|
|
_ => return false,
|
|
};
|
|
|
|
let network_octets: Vec<u8> = match network
|
|
.split('.')
|
|
.map(|s| s.parse())
|
|
.collect::<Result<Vec<u8>, _>>()
|
|
{
|
|
Ok(o) if o.len() == 4 => o,
|
|
_ => return false,
|
|
};
|
|
|
|
let ip_u32 = u32::from_be_bytes([ip_octets[0], ip_octets[1], ip_octets[2], ip_octets[3]]);
|
|
let network_u32 = u32::from_be_bytes([
|
|
network_octets[0],
|
|
network_octets[1],
|
|
network_octets[2],
|
|
network_octets[3],
|
|
]);
|
|
|
|
let mask = if prefix_len >= 32 {
|
|
u32::MAX
|
|
} else {
|
|
u32::MAX << (32 - prefix_len)
|
|
};
|
|
|
|
(ip_u32 & mask) == (network_u32 & mask)
|
|
}
|
|
|
|
pub fn extract_api_key_from_header(header_value: &str) -> Option<&str> {
|
|
if let Some(key) = header_value.strip_prefix("Bearer ") {
|
|
return Some(key);
|
|
}
|
|
if let Some(key) = header_value.strip_prefix("bearer ") {
|
|
return Some(key);
|
|
}
|
|
if let Some(key) = header_value.strip_prefix("ApiKey ") {
|
|
return Some(key);
|
|
}
|
|
if let Some(key) = header_value.strip_prefix("api-key ") {
|
|
return Some(key);
|
|
}
|
|
if header_value.starts_with(API_KEY_PREFIX) {
|
|
return Some(header_value);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_create_and_validate_key() {
|
|
let manager = ApiKeyManager::with_defaults();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let request = CreateApiKeyRequest {
|
|
name: "Test Key".into(),
|
|
description: Some("A test API key".into()),
|
|
scopes: vec![ApiKeyScope::Read, ApiKeyScope::Write],
|
|
expires_in_days: Some(30),
|
|
rate_limits: None,
|
|
allowed_ips: None,
|
|
allowed_origins: None,
|
|
metadata: None,
|
|
};
|
|
|
|
let response = manager.create_key(user_id, request).await.expect("Create failed");
|
|
|
|
assert!(response.secret.starts_with(API_KEY_PREFIX));
|
|
assert_eq!(response.key.user_id, user_id);
|
|
assert!(response.key.is_valid());
|
|
|
|
let validated = manager
|
|
.validate_key(&response.secret)
|
|
.await
|
|
.expect("Validate failed");
|
|
assert!(validated.is_some());
|
|
assert_eq!(validated.as_ref().map(|k| k.id), Some(response.key.id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_key_scopes() {
|
|
let manager = ApiKeyManager::with_defaults();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let request = CreateApiKeyRequest {
|
|
name: "Limited Key".into(),
|
|
description: None,
|
|
scopes: vec![ApiKeyScope::Read],
|
|
expires_in_days: None,
|
|
rate_limits: None,
|
|
allowed_ips: None,
|
|
allowed_origins: None,
|
|
metadata: None,
|
|
};
|
|
|
|
let response = manager.create_key(user_id, request).await.expect("Create failed");
|
|
|
|
assert!(response.key.has_scope(&ApiKeyScope::Read));
|
|
assert!(!response.key.has_scope(&ApiKeyScope::Write));
|
|
assert!(!response.key.has_scope(&ApiKeyScope::Admin));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_admin_scope_includes_all() {
|
|
let manager = ApiKeyManager::with_defaults();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let request = CreateApiKeyRequest {
|
|
name: "Admin Key".into(),
|
|
description: None,
|
|
scopes: vec![ApiKeyScope::Admin],
|
|
expires_in_days: None,
|
|
rate_limits: None,
|
|
allowed_ips: None,
|
|
allowed_origins: None,
|
|
metadata: None,
|
|
};
|
|
|
|
let response = manager.create_key(user_id, request).await.expect("Create failed");
|
|
|
|
assert!(response.key.has_scope(&ApiKeyScope::Read));
|
|
assert!(response.key.has_scope(&ApiKeyScope::Write));
|
|
assert!(response.key.has_scope(&ApiKeyScope::Delete));
|
|
assert!(response.key.has_scope(&ApiKeyScope::Admin));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_revoke_key() {
|
|
let manager = ApiKeyManager::with_defaults();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let request = CreateApiKeyRequest {
|
|
name: "Revokable Key".into(),
|
|
description: None,
|
|
scopes: vec![ApiKeyScope::Read],
|
|
expires_in_days: None,
|
|
rate_limits: None,
|
|
allowed_ips: None,
|
|
allowed_origins: None,
|
|
metadata: None,
|
|
};
|
|
|
|
let response = manager.create_key(user_id, request).await.expect("Create failed");
|
|
let key_id = response.key.id;
|
|
|
|
manager.revoke_key(key_id).await.expect("Revoke failed");
|
|
|
|
let validated = manager
|
|
.validate_key(&response.secret)
|
|
.await
|
|
.expect("Validate failed");
|
|
assert!(validated.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rate_limiting() {
|
|
let manager = ApiKeyManager::with_defaults();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let request = CreateApiKeyRequest {
|
|
name: "Rate Limited Key".into(),
|
|
description: None,
|
|
scopes: vec![ApiKeyScope::Read],
|
|
expires_in_days: None,
|
|
rate_limits: Some(RateLimitConfig {
|
|
requests_per_minute: 2,
|
|
requests_per_hour: 100,
|
|
requests_per_day: 1000,
|
|
}),
|
|
allowed_ips: None,
|
|
allowed_origins: None,
|
|
metadata: None,
|
|
};
|
|
|
|
let response = manager.create_key(user_id, request).await.expect("Create failed");
|
|
|
|
let (_, limited1) = manager
|
|
.validate_and_check_rate_limit(&response.secret)
|
|
.await
|
|
.expect("Validate failed");
|
|
assert!(!limited1);
|
|
|
|
let (_, limited2) = manager
|
|
.validate_and_check_rate_limit(&response.secret)
|
|
.await
|
|
.expect("Validate failed");
|
|
assert!(!limited2);
|
|
|
|
let (_, limited3) = manager
|
|
.validate_and_check_rate_limit(&response.secret)
|
|
.await
|
|
.expect("Validate failed");
|
|
assert!(limited3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ip_cidr_matching() {
|
|
assert!(matches_cidr("192.168.1.100", "192.168.1.0/24"));
|
|
assert!(matches_cidr("192.168.1.1", "192.168.1.0/24"));
|
|
assert!(!matches_cidr("192.168.2.1", "192.168.1.0/24"));
|
|
assert!(matches_cidr("10.0.0.1", "10.0.0.0/8"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_api_key_from_header() {
|
|
assert_eq!(
|
|
extract_api_key_from_header("Bearer gb_abc123"),
|
|
Some("gb_abc123")
|
|
);
|
|
assert_eq!(
|
|
extract_api_key_from_header("ApiKey gb_xyz789"),
|
|
Some("gb_xyz789")
|
|
);
|
|
assert_eq!(
|
|
extract_api_key_from_header("gb_direct_key"),
|
|
Some("gb_direct_key")
|
|
);
|
|
assert_eq!(extract_api_key_from_header("Basic abc123"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scope_inclusion() {
|
|
assert!(ApiKeyScope::Admin.includes(&ApiKeyScope::Read));
|
|
assert!(ApiKeyScope::Admin.includes(&ApiKeyScope::Write));
|
|
assert!(ApiKeyScope::Write.includes(&ApiKeyScope::Read));
|
|
assert!(!ApiKeyScope::Read.includes(&ApiKeyScope::Write));
|
|
}
|
|
|
|
#[test]
|
|
fn test_origin_matching() {
|
|
let key = ApiKey {
|
|
id: Uuid::new_v4(),
|
|
user_id: Uuid::new_v4(),
|
|
name: "Test".into(),
|
|
description: None,
|
|
key_hash: "hash".into(),
|
|
key_prefix: "gb_abc...".into(),
|
|
scopes: HashSet::new(),
|
|
status: ApiKeyStatus::Active,
|
|
rate_limits: RateLimitConfig::default(),
|
|
allowed_ips: None,
|
|
allowed_origins: Some(vec!["*.example.com".into(), "https://specific.org".into()]),
|
|
created_at: Utc::now(),
|
|
expires_at: None,
|
|
last_used_at: None,
|
|
rotated_from: None,
|
|
rotation_deadline: None,
|
|
metadata: HashMap::new(),
|
|
};
|
|
|
|
assert!(key.is_origin_allowed("api.example.com"));
|
|
assert!(key.is_origin_allowed("sub.example.com"));
|
|
assert!(key.is_origin_allowed("https://specific.org"));
|
|
assert!(!key.is_origin_allowed("https://other.org"));
|
|
}
|
|
}
|