feat(mcp): persist login audit for OAuth and API key
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m16s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m32s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m33s

- Add audit::log_login in secrets-core (audit_log detail: user_id, provider, client_ip, user_agent)
- Log web Google OAuth success after session established
- Log MCP Bearer API key auth success in middleware
- Bump secrets-mcp to 0.1.6 (tag 0.1.5 existed)

Made-with: Cursor
This commit is contained in:
voson
2026-03-21 09:48:52 +08:00
parent a595081c4c
commit a44c8ebf08
5 changed files with 168 additions and 20 deletions

2
Cargo.lock generated
View File

@@ -1949,7 +1949,7 @@ dependencies = [
[[package]] [[package]]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.1.5" version = "0.1.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama", "askama",

View File

@@ -1,11 +1,60 @@
use serde_json::Value; use serde_json::{Value, json};
use sqlx::{Postgres, Transaction}; use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub const ACTION_LOGIN: &str = "login";
pub const NAMESPACE_AUTH: &str = "auth";
/// Return the current OS user as the audit actor (falls back to empty string). /// Return the current OS user as the audit actor (falls back to empty string).
pub fn current_actor() -> String { pub fn current_actor() -> String {
std::env::var("USER").unwrap_or_default() std::env::var("USER").unwrap_or_default()
} }
fn login_detail(
user_id: Uuid,
provider: &str,
client_ip: Option<&str>,
user_agent: Option<&str>,
) -> Value {
json!({
"user_id": user_id,
"provider": provider,
"client_ip": client_ip,
"user_agent": user_agent,
})
}
/// Write a login audit entry without requiring an explicit transaction.
pub async fn log_login(
pool: &PgPool,
kind: &str,
provider: &str,
user_id: Uuid,
client_ip: Option<&str>,
user_agent: Option<&str>,
) {
let actor = current_actor();
let detail = login_detail(user_id, provider, client_ip, user_agent);
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(ACTION_LOGIN)
.bind(NAMESPACE_AUTH)
.bind(kind)
.bind(provider)
.bind(&detail)
.bind(&actor)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, kind, provider, "failed to write login audit log");
} else {
tracing::debug!(kind, provider, ?user_id, actor, "login audit logged");
}
}
/// Write an audit entry within an existing transaction. /// Write an audit entry within an existing transaction.
pub async fn log_tx( pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>, tx: &mut Transaction<'_, Postgres>,
@@ -35,3 +84,19 @@ pub async fn log_tx(
tracing::debug!(action, namespace, kind, name, actor, "audit logged"); tracing::debug!(action, namespace, kind, name, actor, "audit logged");
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_detail_includes_expected_fields() {
let user_id = Uuid::nil();
let detail = login_detail(user_id, "google", Some("127.0.0.1"), Some("Mozilla/5.0"));
assert_eq!(detail["user_id"], json!(user_id));
assert_eq!(detail["provider"], "google");
assert_eq!(detail["client_ip"], "127.0.0.1");
assert_eq!(detail["user_agent"], "Mozilla/5.0");
}
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.1.5" version = "0.1.6"
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]

View File

@@ -9,6 +9,7 @@ use axum::{
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use secrets_core::audit::log_login;
use secrets_core::service::api_key::validate_api_key; use secrets_core::service::api_key::validate_api_key;
/// Injected into request extensions after Bearer token validation. /// Injected into request extensions after Bearer token validation.
@@ -34,6 +35,15 @@ fn log_client_ip(req: &Request) -> Option<String> {
.map(|c| c.ip().to_string()) .map(|c| c.ip().to_string())
} }
fn log_user_agent(req: &Request) -> Option<String> {
req.headers()
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
/// Axum middleware that validates Bearer API keys for the /mcp route. /// Axum middleware that validates Bearer API keys for the /mcp route.
/// Passes all non-MCP paths through without authentication. /// Passes all non-MCP paths through without authentication.
pub async fn bearer_auth_middleware( pub async fn bearer_auth_middleware(
@@ -44,6 +54,7 @@ pub async fn bearer_auth_middleware(
let path = req.uri().path(); let path = req.uri().path();
let method = req.method().as_str(); let method = req.method().as_str();
let client_ip = log_client_ip(&req); let client_ip = log_client_ip(&req);
let user_agent = log_user_agent(&req);
// Only authenticate /mcp paths // Only authenticate /mcp paths
if !path.starts_with("/mcp") { if !path.starts_with("/mcp") {
@@ -84,6 +95,15 @@ pub async fn bearer_auth_middleware(
match validate_api_key(&pool, raw_key).await { match validate_api_key(&pool, raw_key).await {
Ok(Some(user_id)) => { Ok(Some(user_id)) => {
log_login(
&pool,
"api_key",
"bearer",
user_id,
client_ip.as_deref(),
user_agent.as_deref(),
)
.await;
tracing::debug!(?user_id, "api key authenticated"); tracing::debug!(?user_id, "api key authenticated");
let mut req = req; let mut req = req;
req.extensions_mut().insert(AuthUser { user_id }); req.extensions_mut().insert(AuthUser { user_id });

View File

@@ -1,9 +1,11 @@
use askama::Template; use askama::Template;
use std::net::SocketAddr;
use axum::{ use axum::{
Json, Router, Json, Router,
body::Body, body::Body,
extract::{Path, Query, State}, extract::{ConnectInfo, Path, Query, State},
http::{StatusCode, header}, http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
routing::{get, post}, routing::{get, post},
}; };
@@ -11,6 +13,7 @@ use serde::{Deserialize, Serialize};
use tower_sessions::Session; use tower_sessions::Session;
use uuid::Uuid; use uuid::Uuid;
use secrets_core::audit::log_login;
use secrets_core::crypto::hex; use secrets_core::crypto::hex;
use secrets_core::service::{ use secrets_core::service::{
api_key::{ensure_api_key, regenerate_api_key}, api_key::{ensure_api_key, regenerate_api_key},
@@ -62,6 +65,30 @@ async fn current_user_id(session: &Session) -> Option<Uuid> {
.and_then(|s| Uuid::parse_str(&s).ok()) .and_then(|s| Uuid::parse_str(&s).ok())
} }
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
if let Some(first) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let value = first.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
Some(connect_info.ip().to_string())
}
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
// ── Routes ──────────────────────────────────────────────────────────────────── // ── Routes ────────────────────────────────────────────────────────────────────
pub fn web_router() -> Router<AppState> { pub fn web_router() -> Router<AppState> {
@@ -141,16 +168,28 @@ struct OAuthCallbackQuery {
async fn auth_google_callback( async fn auth_google_callback(
State(state): State<AppState>, State(state): State<AppState>,
connect_info: ConnectInfo<SocketAddr>,
headers: HeaderMap,
session: Session, session: Session,
Query(params): Query<OAuthCallbackQuery>, Query(params): Query<OAuthCallbackQuery>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
handle_oauth_callback(&state, &session, params, "google", |s, cfg, code| { let client_ip = request_client_ip(&headers, connect_info);
let user_agent = request_user_agent(&headers);
handle_oauth_callback(
&state,
&session,
params,
"google",
client_ip.as_deref(),
user_agent.as_deref(),
|s, cfg, code| {
Box::pin(crate::oauth::google::exchange_code( Box::pin(crate::oauth::google::exchange_code(
&s.http_client, &s.http_client,
cfg, cfg,
code, code,
)) ))
}) },
)
.await .await
} }
@@ -161,6 +200,8 @@ async fn handle_oauth_callback<F>(
session: &Session, session: &Session,
params: OAuthCallbackQuery, params: OAuthCallbackQuery,
provider: &str, provider: &str,
client_ip: Option<&str>,
user_agent: Option<&str>,
exchange_fn: F, exchange_fn: F,
) -> Result<Response, StatusCode> ) -> Result<Response, StatusCode>
where where
@@ -274,6 +315,16 @@ where
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
log_login(
&state.pool,
"oauth",
provider,
user.id,
client_ip,
user_agent,
)
.await;
Ok(Redirect::to("/dashboard").into_response()) Ok(Redirect::to("/dashboard").into_response())
} }
@@ -342,16 +393,28 @@ async fn account_bind_google(
async fn account_bind_google_callback( async fn account_bind_google_callback(
State(state): State<AppState>, State(state): State<AppState>,
connect_info: ConnectInfo<SocketAddr>,
headers: HeaderMap,
session: Session, session: Session,
Query(params): Query<OAuthCallbackQuery>, Query(params): Query<OAuthCallbackQuery>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
handle_oauth_callback(&state, &session, params, "google", |s, cfg, code| { let client_ip = request_client_ip(&headers, connect_info);
let user_agent = request_user_agent(&headers);
handle_oauth_callback(
&state,
&session,
params,
"google",
client_ip.as_deref(),
user_agent.as_deref(),
|s, cfg, code| {
Box::pin(crate::oauth::google::exchange_code( Box::pin(crate::oauth::google::exchange_code(
&s.http_client, &s.http_client,
cfg, cfg,
code, code,
)) ))
}) },
)
.await .await
} }