304994f
use std::net::SocketAddr;
use axum::Json;
use axum::body::Body;
use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use base64::Engine as _;
use models::users::user_access_keys::AccessKeyScope;
use rmcp::ErrorData;
use rmcp::RoleServer;
use rmcp::service::RequestContext;
use serde_json::json;
use uuid::Uuid;
use crate::{McpRuntime, net};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrincipalSource {
UserKey,
InternalKey,
SpaceToken,
}
#[derive(Clone)]
pub struct TemporaryKey {
pub token: String,
pub token_id: Uuid,
pub issued_by: Option<String>,
pub expires_at: time::OffsetDateTime,
}
impl std::fmt::Debug for TemporaryKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TemporaryKey")
.field("token", &"<redacted>")
.field("token_id", &self.token_id)
.field("issued_by", &self.issued_by)
.field("expires_at", &self.expires_at)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct Principal {
pub uid: Uuid,
pub scope: AccessKeyScope,
pub full_access: bool,
pub source: PrincipalSource,
/// `Some((space_id, lower_name))` for temporary space top-level tokens:
/// every tool call must stay inside that single space.
pub bound_space: Option<(Uuid, String)>,
/// `Some` only for temporary space top-level tokens; carries the presented
/// token and its allocation details so `me_get` can hand it back.
pub temporary_key: Option<TemporaryKey>,
}
impl Principal {
pub fn can_write(&self) -> bool {
self.full_access || self.scope.allows(true)
}
pub(crate) fn from_ctx(ctx: &RequestContext<RoleServer>) -> Result<Self, ErrorData> {
let parts = ctx
.extensions
.get::<axum::http::request::Parts>()
.ok_or_else(|| ErrorData::internal_error("missing http request parts", None))?;
parts
.extensions
.get::<Principal>()
.cloned()
.ok_or_else(|| ErrorData::invalid_request("unauthenticated", None))
}
}
pub(crate) async fn auth_middleware(
State(runtime): State<McpRuntime>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
mut request: Request<Body>,
next: Next,
) -> Response {
let Some(token) = extract_token(request.headers()) else {
return unauthorized("缺少访问密钥");
};
let peer = Some(peer);
let client_ip = net::resolve_client_ip(peer, request.headers(), runtime.trusted_proxies());
let principal = if let Some(name) = runtime.match_internal_key(&token) {
if !runtime.is_internal_ip(client_ip) {
tracing::warn!(key = %name, "internal mcp key rejected from a non-internal source");
return forbidden("内部访问密钥仅允许内网来源使用");
}
let Some(uid) = runtime.system_uid() else {
return unavailable("内部系统账号尚未就绪");
};
Principal {
uid,
scope: AccessKeyScope::Write,
full_access: true,
source: PrincipalSource::InternalKey,
bound_space: None,
temporary_key: None,
}
} else if export::space_token::looks_like(&token) {
match export::space_token::verify(&runtime.services().state.redis, &token).await {
Ok(Some(record)) => {
// The bot passes every owner-level authorization check; the
// space binding below is what keeps it scoped to one space.
export::authz::register_space_bot(record.bot_uid);
let expires_at = time::OffsetDateTime::from_unix_timestamp(record.expires_at_unix)
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
Principal {
uid: record.bot_uid,
scope: AccessKeyScope::Write,
full_access: true,
source: PrincipalSource::SpaceToken,
bound_space: Some((record.space_id, record.space_name)),
temporary_key: Some(TemporaryKey {
token: token.clone(),
token_id: record.token_id,
issued_by: record.issued_by,
expires_at,
}),
}
}
Ok(None) => return unauthorized("访问密钥无效或已过期"),
Err(error) => {
tracing::warn!(%error, "mcp space token verification failed");
return unavailable("鉴权服务暂不可用");
}
}
} else {
match runtime.services().auth.verify_access_token(&token).await {
Ok(Some(verified)) => Principal {
uid: verified.user_id,
scope: verified.scope,
full_access: false,
source: PrincipalSource::UserKey,
bound_space: None,
temporary_key: None,
},
Ok(None) => return unauthorized("访问密钥无效或已过期"),
Err(error) => {
tracing::warn!(%error, "mcp access key verification failed");
return unavailable("鉴权服务暂不可用");
}
}
};
request.extensions_mut().insert(principal);
next.run(request).await
}
fn extract_token(headers: &HeaderMap) -> Option<String> {
let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
if let Some(rest) = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
{
let token = rest.trim();
return (!token.is_empty()).then(|| token.to_string());
}
if let Some(rest) = value.strip_prefix("Basic ") {
let decoded = base64::engine::general_purpose::STANDARD
.decode(rest.trim())
.ok()?;
let credentials = String::from_utf8(decoded).ok()?;
return credentials
.split_once(':')
.map(|(_, password)| password.to_string());
}
None
}
fn error_response(status: StatusCode, message: &str) -> Response {
let body = Json(json!({
"error": status.canonical_reason().unwrap_or("error").to_lowercase(),
"message": message,
}));
(status, body).into_response()
}
fn unauthorized(message: &str) -> Response {
let mut response = error_response(StatusCode::UNAUTHORIZED, message);
response
.headers_mut()
.insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
response
}
fn forbidden(message: &str) -> Response {
error_response(StatusCode::FORBIDDEN, message)
}
fn unavailable(message: &str) -> Response {
error_response(StatusCode::SERVICE_UNAVAILABLE, message)
}