mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 09:27:46 +08:00
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
256 lines
7.9 KiB
Rust
256 lines
7.9 KiB
Rust
use std::net::IpAddr;
|
|
|
|
use aether_data::repository::management_tokens::{
|
|
StoredManagementToken, StoredManagementTokenWithUser,
|
|
};
|
|
use aether_data::repository::users::StoredUserAuthRecord;
|
|
use axum::http::{self, HeaderMap};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::data::GatewayDataState;
|
|
|
|
const MANAGEMENT_TOKEN_MAX_LEN: usize = 512;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct AuthenticatedManagementToken {
|
|
pub(crate) token: StoredManagementToken,
|
|
pub(crate) user: StoredUserAuthRecord,
|
|
pub(crate) permissions: Vec<String>,
|
|
pub(crate) verified_token_hash: VerifiedManagementTokenHash,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub(crate) struct VerifiedManagementTokenHash(String);
|
|
|
|
impl VerifiedManagementTokenHash {
|
|
pub(crate) fn new(value: String) -> Self {
|
|
Self(value)
|
|
}
|
|
|
|
pub(crate) fn as_str(&self) -> &str {
|
|
self.0.as_str()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for VerifiedManagementTokenHash {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter.write_str("VerifiedManagementTokenHash([REDACTED])")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ManagementTokenAuthError {
|
|
Missing,
|
|
Invalid,
|
|
Unavailable,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
pub(crate) trait ManagementTokenAuthSource {
|
|
async fn find_management_token(
|
|
&self,
|
|
token_hash: &str,
|
|
) -> Result<Option<StoredManagementTokenWithUser>, ManagementTokenAuthError>;
|
|
|
|
async fn find_current_user(
|
|
&self,
|
|
user_id: &str,
|
|
) -> Result<Option<StoredUserAuthRecord>, ManagementTokenAuthError>;
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ManagementTokenAuthSource for GatewayDataState {
|
|
async fn find_management_token(
|
|
&self,
|
|
token_hash: &str,
|
|
) -> Result<Option<StoredManagementTokenWithUser>, ManagementTokenAuthError> {
|
|
self.get_management_token_with_user_by_hash(token_hash)
|
|
.await
|
|
.map_err(|_| ManagementTokenAuthError::Unavailable)
|
|
}
|
|
|
|
async fn find_current_user(
|
|
&self,
|
|
user_id: &str,
|
|
) -> Result<Option<StoredUserAuthRecord>, ManagementTokenAuthError> {
|
|
self.find_user_auth_by_id(user_id)
|
|
.await
|
|
.map_err(|_| ManagementTokenAuthError::Unavailable)
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ManagementTokenAuthSource for crate::AppState {
|
|
async fn find_management_token(
|
|
&self,
|
|
token_hash: &str,
|
|
) -> Result<Option<StoredManagementTokenWithUser>, ManagementTokenAuthError> {
|
|
self.get_management_token_with_user_by_hash(token_hash)
|
|
.await
|
|
.map_err(|_| ManagementTokenAuthError::Unavailable)
|
|
}
|
|
|
|
async fn find_current_user(
|
|
&self,
|
|
user_id: &str,
|
|
) -> Result<Option<StoredUserAuthRecord>, ManagementTokenAuthError> {
|
|
self.find_user_auth_by_id(user_id)
|
|
.await
|
|
.map_err(|_| ManagementTokenAuthError::Unavailable)
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn authenticate_management_token<S>(
|
|
source: &S,
|
|
headers: &HeaderMap,
|
|
remote_ip: IpAddr,
|
|
) -> Result<AuthenticatedManagementToken, ManagementTokenAuthError>
|
|
where
|
|
S: ManagementTokenAuthSource + Sync + ?Sized,
|
|
{
|
|
let token = extract_unique_management_token_bearer(headers)?;
|
|
let token_hash = VerifiedManagementTokenHash::new(hash_management_token(token));
|
|
authenticate_management_token_hash(source, &token_hash, remote_ip).await
|
|
}
|
|
|
|
pub(crate) async fn authenticate_management_token_hash<S>(
|
|
source: &S,
|
|
token_hash: &VerifiedManagementTokenHash,
|
|
remote_ip: IpAddr,
|
|
) -> Result<AuthenticatedManagementToken, ManagementTokenAuthError>
|
|
where
|
|
S: ManagementTokenAuthSource + Sync + ?Sized,
|
|
{
|
|
let token_with_user = source
|
|
.find_management_token(token_hash.as_str())
|
|
.await?
|
|
.ok_or(ManagementTokenAuthError::Invalid)?;
|
|
if token_with_user.token.user_id != token_with_user.user.id {
|
|
return Err(ManagementTokenAuthError::Invalid);
|
|
}
|
|
|
|
let now = chrono::Utc::now().timestamp().max(0) as u64;
|
|
if !token_with_user.token.is_active
|
|
|| token_with_user
|
|
.token
|
|
.expires_at_unix_secs
|
|
.is_some_and(|expires_at| expires_at <= now)
|
|
|| !crate::handlers::shared::json_ip_rules_allow(
|
|
token_with_user.token.allowed_ips.as_ref(),
|
|
remote_ip,
|
|
)
|
|
{
|
|
return Err(ManagementTokenAuthError::Invalid);
|
|
}
|
|
|
|
let user = source
|
|
.find_current_user(&token_with_user.token.user_id)
|
|
.await?
|
|
.ok_or(ManagementTokenAuthError::Invalid)?;
|
|
if !user.is_active || user.is_deleted || !crate::roles::can_access_admin_console(&user.role) {
|
|
return Err(ManagementTokenAuthError::Invalid);
|
|
}
|
|
|
|
let permissions = crate::control::management_token_permission_keys_from_value(
|
|
token_with_user.token.permissions.as_ref(),
|
|
)
|
|
.map_err(|_| ManagementTokenAuthError::Invalid)?
|
|
.unwrap_or_else(crate::control::legacy_full_management_token_permissions);
|
|
|
|
Ok(AuthenticatedManagementToken {
|
|
token: token_with_user.token,
|
|
user,
|
|
permissions,
|
|
verified_token_hash: token_hash.clone(),
|
|
})
|
|
}
|
|
|
|
fn extract_unique_management_token_bearer(
|
|
headers: &HeaderMap,
|
|
) -> Result<&str, ManagementTokenAuthError> {
|
|
let mut values = headers.get_all(http::header::AUTHORIZATION).iter();
|
|
let value = values.next().ok_or(ManagementTokenAuthError::Missing)?;
|
|
if values.next().is_some() {
|
|
return Err(ManagementTokenAuthError::Invalid);
|
|
}
|
|
let value = value
|
|
.to_str()
|
|
.map_err(|_| ManagementTokenAuthError::Invalid)?;
|
|
let (scheme, token) = value
|
|
.split_once(' ')
|
|
.ok_or(ManagementTokenAuthError::Invalid)?;
|
|
let token = token.trim();
|
|
if !scheme.eq_ignore_ascii_case("bearer")
|
|
|| token.is_empty()
|
|
|| token.len() > MANAGEMENT_TOKEN_MAX_LEN
|
|
|| (!token.starts_with("ae-") && !token.starts_with("ae_"))
|
|
|| token.chars().any(char::is_whitespace)
|
|
{
|
|
return Err(ManagementTokenAuthError::Invalid);
|
|
}
|
|
Ok(token)
|
|
}
|
|
|
|
fn hash_management_token(value: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(value.as_bytes());
|
|
format!("{:x}", hasher.finalize())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
extract_unique_management_token_bearer, ManagementTokenAuthError,
|
|
VerifiedManagementTokenHash,
|
|
};
|
|
use axum::http::{header, HeaderMap, HeaderValue};
|
|
|
|
#[test]
|
|
fn extracts_one_case_insensitive_bearer_value() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_static("bEaReR ae-valid-token"),
|
|
);
|
|
assert_eq!(
|
|
extract_unique_management_token_bearer(&headers),
|
|
Ok("ae-valid-token")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_or_ambiguous_authorization() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.append(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_static("Bearer ae-first"),
|
|
);
|
|
headers.append(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_static("Bearer ae-second"),
|
|
);
|
|
assert_eq!(
|
|
extract_unique_management_token_bearer(&headers),
|
|
Err(ManagementTokenAuthError::Invalid)
|
|
);
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_static("Bearer ae-valid extra"),
|
|
);
|
|
assert_eq!(
|
|
extract_unique_management_token_bearer(&headers),
|
|
Err(ManagementTokenAuthError::Invalid)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verified_management_token_hash_debug_is_redacted() {
|
|
let hash = VerifiedManagementTokenHash::new("credential-equivalent-hash".to_string());
|
|
let rendered = format!("{hash:?}");
|
|
assert!(rendered.contains("[REDACTED]"));
|
|
assert!(!rendered.contains("credential-equivalent-hash"));
|
|
}
|
|
}
|