release(secrets-mcp): 0.5.9 — users.key_version 与会话失效;Web 条目解密 API 与列表增强
This commit is contained in:
@@ -22,6 +22,7 @@ use secrets_core::service::{
|
||||
api_key::{ensure_api_key, regenerate_api_key},
|
||||
audit_log::{count_for_user, list_for_user},
|
||||
delete::delete_by_id,
|
||||
get_secret::get_all_secrets_by_id,
|
||||
search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries},
|
||||
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
||||
user::{
|
||||
@@ -37,6 +38,7 @@ const SESSION_USER_ID: &str = "user_id";
|
||||
const SESSION_OAUTH_STATE: &str = "oauth_state";
|
||||
const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode";
|
||||
const SESSION_LOGIN_PROVIDER: &str = "login_provider";
|
||||
const SESSION_KEY_VERSION: &str = "key_version";
|
||||
|
||||
// ── Template types ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -175,6 +177,57 @@ async fn current_user_id(session: &Session) -> Option<Uuid> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and validate the current user from session and DB.
|
||||
///
|
||||
/// Returns the user if the session is valid. Flushes the session and returns
|
||||
/// `Err(Redirect::to("/login"))` when:
|
||||
/// - the session has no `user_id`,
|
||||
/// - the user no longer exists in the database, or
|
||||
/// - the stored `key_version` does not match the DB value (passphrase changed on
|
||||
/// another device since this session was created).
|
||||
async fn require_valid_user(
|
||||
pool: &sqlx::PgPool,
|
||||
session: &Session,
|
||||
context: &str,
|
||||
) -> Result<secrets_core::models::User, Response> {
|
||||
let Some(user_id) = current_user_id(session).await else {
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
};
|
||||
|
||||
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, %user_id, context, "failed to load user");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush stale session");
|
||||
}
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
}
|
||||
Ok(Some(u)) => u,
|
||||
};
|
||||
|
||||
let session_kv: Option<i64> = match session.get::<i64>(SESSION_KEY_VERSION).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to read key_version from session; treating as missing");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(kv) = session_kv
|
||||
&& kv != user.key_version
|
||||
{
|
||||
tracing::info!(%user_id, session_kv = kv, db_kv = user.key_version, "key_version mismatch; invalidating session");
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush outdated session");
|
||||
}
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
|
||||
let trust_proxy = std::env::var("TRUST_PROXY")
|
||||
.as_deref()
|
||||
@@ -267,6 +320,10 @@ pub fn web_router() -> Router<AppState> {
|
||||
"/api/entries/{entry_id}/secrets/{secret_id}",
|
||||
axum::routing::delete(api_entry_secret_unlink),
|
||||
)
|
||||
.route(
|
||||
"/api/entries/{id}/secrets/decrypt",
|
||||
get(api_entry_secrets_decrypt),
|
||||
)
|
||||
.route("/api/secrets/{secret_id}", patch(api_secret_patch))
|
||||
.route("/api/secrets/check-name", get(api_secret_check_name))
|
||||
}
|
||||
@@ -542,6 +599,9 @@ where
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
if let Err(e) = session.insert(SESSION_KEY_VERSION, user.key_version).await {
|
||||
tracing::warn!(error = %e, user_id = %user.id, "failed to insert key_version into session after OAuth");
|
||||
}
|
||||
|
||||
log_login(
|
||||
&state.pool,
|
||||
@@ -571,16 +631,9 @@ async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let Some(user_id) = current_user_id(&session).await else {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
};
|
||||
|
||||
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "failed to load user for dashboard");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})? {
|
||||
Some(u) => u,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
let user = match require_valid_user(&state.pool, &session, "dashboard").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
|
||||
let tmpl = DashboardTemplate {
|
||||
@@ -599,17 +652,11 @@ async fn entries_page(
|
||||
session: Session,
|
||||
Query(q): Query<EntriesQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let Some(user_id) = current_user_id(&session).await else {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
};
|
||||
|
||||
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "failed to load user for entries page");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})? {
|
||||
Some(u) => u,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
let user = match require_valid_user(&state.pool, &session, "entries_page").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
let folder_filter = q
|
||||
.folder
|
||||
@@ -855,17 +902,11 @@ async fn audit_page(
|
||||
session: Session,
|
||||
Query(aq): Query<AuditQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let Some(user_id) = current_user_id(&session).await else {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
};
|
||||
|
||||
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "failed to load user for audit page");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})? {
|
||||
Some(u) => u,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
let user = match require_valid_user(&state.pool, &session, "audit_page").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
let page = aq.page.unwrap_or(1).max(1);
|
||||
|
||||
@@ -1172,6 +1213,25 @@ async fn api_key_change(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Refresh the session's key_version so the current session is not immediately
|
||||
// invalidated by require_valid_user on the next page load.
|
||||
match get_user_by_id(&state.pool, user_id).await {
|
||||
Ok(Some(updated_user)) => {
|
||||
if let Err(e) = session
|
||||
.insert(SESSION_KEY_VERSION, updated_user.key_version)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, %user_id, "failed to update key_version in session after key change");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(%user_id, "user not found after key change; session not updated");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, %user_id, "failed to reload user after key change; session not updated");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(%user_id, secrets_count = "(see service log)", "passphrase changed and secrets re-encrypted");
|
||||
Ok(Json(KeySetupResponse { ok: true }))
|
||||
}
|
||||
@@ -1811,6 +1871,65 @@ async fn oauth_protected_resource_metadata(State(state): State<AppState>) -> imp
|
||||
)
|
||||
}
|
||||
|
||||
// ── Decrypt entry secrets (Web UI) ───────────────────────────────────────────
|
||||
|
||||
async fn api_entry_secrets_decrypt(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
let enc_key_hex = headers
|
||||
.get("x-encryption-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let master_key =
|
||||
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let secrets =
|
||||
get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("DecryptionFailed") || msg.contains("decryption") {
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": tr(lang, "解密失败,请确认密码短语正确", "解密失敗,請確認密碼短語正確", "Decryption failed, please verify your passphrase") })),
|
||||
)
|
||||
} else if msg.contains("not found") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": tr(lang, "条目不存在或无权访问", "條目不存在或無權存取", "Entry not found or no access") })),
|
||||
)
|
||||
} else {
|
||||
tracing::error!(error = %e, %entry_id, "decrypt entry secrets failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") })),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({ "ok": true, "secrets": secrets })))
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
|
||||
|
||||
Reference in New Issue
Block a user